From ee2467b83907203cb90da8967ab591a018d5e703 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 10 Feb 2026 17:57:13 -0600 Subject: [PATCH 01/26] Stop persisting QuiescentAction and remove legacy code Now that the Splice variant (containing non-serializable FundingContribution) is the only variant produced, and the previous commit consumes the acceptor's quiescent_action in splice_init(), there is no longer a need to persist it. This allows removing LegacySplice, SpliceInstructions, ChangeStrategy, and related code paths including calculate_change_output, calculate_change_output_value, and the legacy send_splice_init method. With ChangeStrategy removed, the only remaining path in calculate_change_output was FromCoinSelection which always returned Ok(None), making it dead code. The into_interactive_tx_constructor method is simplified accordingly, and the signer_provider parameter is removed from it and from splice_init/splice_ack since it was only needed for the removed change output calculation. On deserialization, quiescent_action (TLV 65) is still read for backwards compatibility but discarded, and the awaiting_quiescence channel state flag is cleared since it cannot be acted upon without a quiescent_action. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 213 +++------------------------- lightning/src/ln/channelmanager.rs | 2 - lightning/src/ln/interactivetxs.rs | 215 ++--------------------------- lightning/src/ln/splicing_tests.rs | 76 ++-------- 4 files changed, 37 insertions(+), 469 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index cd98ed70a43..13bc26768b8 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -28,7 +28,7 @@ use bitcoin::{secp256k1, sighash, FeeRate, Sequence, TxIn}; use crate::blinded_path::message::BlindedMessagePath; use crate::chain::chaininterface::{ - fee_for_weight, ConfirmationTarget, FeeEstimator, LowerBoundedFeeEstimator, TransactionType, + ConfirmationTarget, FeeEstimator, LowerBoundedFeeEstimator, TransactionType, }; use crate::chain::channelmonitor::{ ChannelMonitor, ChannelMonitorUpdate, ChannelMonitorUpdateStep, CommitmentHTLCData, @@ -57,9 +57,9 @@ use crate::ln::channelmanager::{ }; use crate::ln::funding::{FundingContribution, FundingTemplate, FundingTxInput}; use crate::ln::interactivetxs::{ - calculate_change_output_value, get_output_weight, AbortReason, HandleTxCompleteValue, - InteractiveTxConstructor, InteractiveTxConstructorArgs, InteractiveTxMessageSend, - InteractiveTxSigningSession, NegotiationError, SharedOwnedInput, SharedOwnedOutput, + AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, + InteractiveTxMessageSend, InteractiveTxSigningSession, NegotiationError, SharedOwnedInput, + SharedOwnedOutput, }; use crate::ln::msgs; use crate::ln::msgs::{ClosingSigned, ClosingSignedFeeRange, DecodeError, OnionErrorPacket}; @@ -2924,7 +2924,6 @@ impl_writeable_tlv_based!(PendingFunding, { enum FundingNegotiation { AwaitingAck { context: FundingNegotiationContext, - change_strategy: ChangeStrategy, new_holder_funding_key: PublicKey, }, ConstructingTransaction { @@ -3010,38 +3009,8 @@ impl PendingFunding { } } -#[derive(Debug)] -pub(crate) struct SpliceInstructions { - adjusted_funding_contribution: SignedAmount, - our_funding_inputs: Vec, - our_funding_outputs: Vec, - change_script: Option, - funding_feerate_per_kw: u32, - locktime: u32, -} - -impl SpliceInstructions { - fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { - ( - self.our_funding_inputs.into_iter().map(|input| input.utxo.outpoint).collect(), - self.our_funding_outputs, - ) - } -} - -impl_writeable_tlv_based!(SpliceInstructions, { - (1, adjusted_funding_contribution, required), - (3, our_funding_inputs, required_vec), - (5, our_funding_outputs, required_vec), - (7, change_script, option), - (9, funding_feerate_per_kw, required), - (11, locktime, required), -}); - #[derive(Debug)] pub(crate) enum QuiescentAction { - // Deprecated in favor of the Splice variant and no longer produced as of LDK 0.3. - LegacySplice(SpliceInstructions), Splice { contribution: FundingContribution, locktime: LockTime, @@ -3059,10 +3028,6 @@ pub(super) enum QuiescentError { impl From for QuiescentError { fn from(action: QuiescentAction) -> Self { match action { - QuiescentAction::LegacySplice(_) => { - debug_assert!(false); - QuiescentError::DoNothing - }, QuiescentAction::Splice { contribution, .. } => { let (contributed_inputs, contributed_outputs) = contribution.into_contributed_inputs_and_outputs(); @@ -3091,7 +3056,6 @@ impl_writeable_tlv_based_enum_upgradable!(QuiescentAction, (0, contribution, required), (1, locktime, required), }, - {1, LegacySplice} => (), ); #[cfg(not(any(test, fuzzing, feature = "_test_utils")))] impl_writeable_tlv_based_enum_upgradable!(QuiescentAction, @@ -3099,7 +3063,6 @@ impl_writeable_tlv_based_enum_upgradable!(QuiescentAction, (0, contribution, required), (1, locktime, required), }, - {1, LegacySplice} => (), ); /// Wrapper around a [`Transaction`] useful for caching the result of [`Transaction::compute_txid`]. @@ -6737,23 +6700,12 @@ pub(super) struct FundingNegotiationContext { pub our_funding_outputs: Vec, } -/// How the funding transaction's change is determined. -#[derive(Debug)] -pub(super) enum ChangeStrategy { - /// The change output, if any, is included in the FundingContribution's outputs. - FromCoinSelection, - - /// The change output script. This will be used if needed or -- if not set -- generated using - /// `SignerProvider::get_destination_script`. - LegacyUserProvided(Option), -} - impl FundingNegotiationContext { /// Prepare and start interactive transaction negotiation. /// If error occurs, it is caused by our side, not the counterparty. fn into_interactive_tx_constructor( - mut self, context: &ChannelContext, funding: &FundingScope, signer_provider: &SP, - entropy_source: &ES, holder_node_id: PublicKey, change_strategy: ChangeStrategy, + self, context: &ChannelContext, funding: &FundingScope, entropy_source: &ES, + holder_node_id: PublicKey, ) -> Result { debug_assert_eq!( self.shared_funding_input.is_some(), @@ -6766,25 +6718,11 @@ impl FundingNegotiationContext { debug_assert!(matches!(context.channel_state, ChannelState::NegotiatingFunding(_))); } - // Note: For the error case when the inputs are insufficient, it will be handled after - // the `calculate_change_output_value` call below - let shared_funding_output = TxOut { value: Amount::from_sat(funding.get_value_satoshis()), script_pubkey: funding.get_funding_redeemscript().to_p2wsh(), }; - match self.calculate_change_output( - context, - signer_provider, - &shared_funding_output, - change_strategy, - ) { - Ok(Some(change_output)) => self.our_funding_outputs.push(change_output), - Ok(None) => {}, - Err(reason) => return Err(self.into_negotiation_error(reason)), - } - let constructor_args = InteractiveTxConstructorArgs { entropy_source, holder_node_id, @@ -6804,57 +6742,6 @@ impl FundingNegotiationContext { InteractiveTxConstructor::new(constructor_args) } - fn calculate_change_output( - &self, context: &ChannelContext, signer_provider: &SP, shared_funding_output: &TxOut, - change_strategy: ChangeStrategy, - ) -> Result, AbortReason> { - if self.our_funding_inputs.is_empty() { - return Ok(None); - } - - let change_script = match change_strategy { - ChangeStrategy::FromCoinSelection => return Ok(None), - ChangeStrategy::LegacyUserProvided(change_script) => change_script, - }; - - let change_value = calculate_change_output_value( - &self, - self.shared_funding_input.is_some(), - &shared_funding_output.script_pubkey, - context.holder_dust_limit_satoshis, - )?; - - if let Some(change_value) = change_value { - let change_script = match change_script { - Some(script) => script, - None => match signer_provider.get_destination_script(context.channel_keys_id) { - Ok(script) => script, - Err(_) => { - return Err(AbortReason::InternalError("Error getting change script")) - }, - }, - }; - let mut change_output = TxOut { value: change_value, script_pubkey: change_script }; - let change_output_weight = get_output_weight(&change_output.script_pubkey).to_wu(); - let change_output_fee = - fee_for_weight(self.funding_feerate_sat_per_1000_weight, change_output_weight); - let change_value_decreased_with_fee = - change_value.to_sat().saturating_sub(change_output_fee); - // Check dust limit again - if change_value_decreased_with_fee > context.holder_dust_limit_satoshis { - change_output.value = Amount::from_sat(change_value_decreased_with_fee); - return Ok(Some(change_output)); - } - } - - Ok(None) - } - - fn into_negotiation_error(self, reason: AbortReason) -> NegotiationError { - let (contributed_inputs, contributed_outputs) = self.into_contributed_inputs_and_outputs(); - NegotiationError { reason, contributed_inputs, contributed_outputs } - } - fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { let contributed_inputs = self.our_funding_inputs.into_iter().map(|input| input.utxo.outpoint).collect(); @@ -7101,15 +6988,6 @@ where fn abandon_quiescent_action(&mut self) -> Option { match self.quiescent_action.take() { - Some(QuiescentAction::LegacySplice(instructions)) => { - let (inputs, outputs) = instructions.into_contributed_inputs_and_outputs(); - Some(SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs: inputs, - contributed_outputs: outputs, - }) - }, Some(QuiescentAction::Splice { contribution, .. }) => { let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs(); Some(SpliceFundingFailed { @@ -12318,32 +12196,8 @@ where self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }) } - fn send_splice_init(&mut self, instructions: SpliceInstructions) -> msgs::SpliceInit { - let SpliceInstructions { - adjusted_funding_contribution, - our_funding_inputs, - our_funding_outputs, - change_script, - funding_feerate_per_kw, - locktime, - } = instructions; - - let prev_funding_input = self.funding.to_splice_funding_input(); - let context = FundingNegotiationContext { - is_initiator: true, - our_funding_contribution: adjusted_funding_contribution, - funding_tx_locktime: LockTime::from_consensus(locktime), - funding_feerate_sat_per_1000_weight: funding_feerate_per_kw, - shared_funding_input: Some(prev_funding_input), - our_funding_inputs, - our_funding_outputs, - }; - - self.send_splice_init_internal(context, ChangeStrategy::LegacyUserProvided(change_script)) - } - fn send_splice_init_internal( - &mut self, context: FundingNegotiationContext, change_strategy: ChangeStrategy, + &mut self, context: FundingNegotiationContext, ) -> msgs::SpliceInit { debug_assert!(self.pending_splice.is_none()); // Rotate the funding pubkey using the prev_funding_txid as a tweak @@ -12364,11 +12218,8 @@ where let funding_contribution_satoshis = context.our_funding_contribution.to_sat(); let locktime = context.funding_tx_locktime.to_consensus_u32(); - let funding_negotiation = FundingNegotiation::AwaitingAck { - context, - change_strategy, - new_holder_funding_key: funding_pubkey, - }; + let funding_negotiation = + FundingNegotiation::AwaitingAck { context, new_holder_funding_key: funding_pubkey }; self.pending_splice = Some(PendingFunding { funding_negotiation: Some(funding_negotiation), negotiated_candidates: vec![], @@ -12572,7 +12423,7 @@ where pub(crate) fn splice_init( &mut self, msg: &msgs::SpliceInit, our_funding_contribution_satoshis: i64, - signer_provider: &SP, entropy_source: &ES, holder_node_id: &PublicKey, logger: &L, + entropy_source: &ES, holder_node_id: &PublicKey, logger: &L, ) -> Result { let our_funding_contribution = SignedAmount::from_sat(our_funding_contribution_satoshis); let splice_funding = self.validate_splice_init(msg, our_funding_contribution)?; @@ -12600,11 +12451,8 @@ where .into_interactive_tx_constructor( &self.context, &splice_funding, - signer_provider, entropy_source, holder_node_id.clone(), - // ChangeStrategy doesn't matter when no inputs are contributed - ChangeStrategy::FromCoinSelection, ) .map_err(|err| { ChannelError::WarnAndDisconnect(format!( @@ -12639,8 +12487,8 @@ where } pub(crate) fn splice_ack( - &mut self, msg: &msgs::SpliceAck, signer_provider: &SP, entropy_source: &ES, - holder_node_id: &PublicKey, logger: &L, + &mut self, msg: &msgs::SpliceAck, entropy_source: &ES, holder_node_id: &PublicKey, + logger: &L, ) -> Result, ChannelError> { let splice_funding = self.validate_splice_ack(msg)?; @@ -12655,11 +12503,11 @@ where let pending_splice = self.pending_splice.as_mut().expect("We should have returned an error earlier!"); // TODO: Good candidate for a let else statement once MSRV >= 1.65 - let (funding_negotiation_context, change_strategy) = - if let Some(FundingNegotiation::AwaitingAck { context, change_strategy, .. }) = + let funding_negotiation_context = + if let Some(FundingNegotiation::AwaitingAck { context, .. }) = pending_splice.funding_negotiation.take() { - (context, change_strategy) + context } else { panic!("We should have returned an error earlier!"); }; @@ -12668,10 +12516,8 @@ where .into_interactive_tx_constructor( &self.context, &splice_funding, - signer_provider, entropy_source, holder_node_id.clone(), - change_strategy, ) .map_err(|err| { ChannelError::WarnAndDisconnect(format!( @@ -13540,22 +13386,6 @@ where "Internal Error: Didn't have anything to do after reaching quiescence".to_owned() )); }, - Some(QuiescentAction::LegacySplice(instructions)) => { - if self.pending_splice.is_some() { - debug_assert!(false); - self.quiescent_action = Some(QuiescentAction::LegacySplice(instructions)); - - return Err(ChannelError::WarnAndDisconnect( - format!( - "Channel {} cannot be spliced as it already has a splice pending", - self.context.channel_id(), - ), - )); - } - - let splice_init = self.send_splice_init(instructions); - return Ok(Some(StfuResponse::SpliceInit(splice_init))); - }, Some(QuiescentAction::Splice { contribution, locktime }) => { // TODO(splicing): If the splice has been negotiated but has not been locked, we // can RBF here to add the contribution. @@ -13587,7 +13417,7 @@ where our_funding_outputs, }; - let splice_init = self.send_splice_init_internal(context, ChangeStrategy::FromCoinSelection); + let splice_init = self.send_splice_init_internal(context); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, #[cfg(any(test, fuzzing, feature = "_test_utils"))] @@ -13629,8 +13459,7 @@ where // We can't initiate another splice while ours is pending, so don't bother becoming // quiescent yet. // TODO(splicing): Allow the splice as an RBF once supported. - let has_splice_action = matches!(action, QuiescentAction::Splice { .. }) - || matches!(action, QuiescentAction::LegacySplice(_)); + let has_splice_action = matches!(action, QuiescentAction::Splice { .. }); if has_splice_action && self.pending_splice.is_some() { log_given_level!( logger, @@ -15222,7 +15051,7 @@ impl Writeable for FundedChannel { (61, fulfill_attribution_data, optional_vec), // Added in 0.2 (63, holder_commitment_point_current, option), // Added in 0.2 (64, pending_splice, option), // Added in 0.2 - (65, self.quiescent_action, option), // Added in 0.2 + // 65 was previously used for quiescent_action (67, pending_outbound_held_htlc_flags, optional_vec), // Added in 0.2 (69, holding_cell_held_htlc_flags, optional_vec), // Added in 0.2 (71, holder_commitment_point_previous_revoked, option), // Added in 0.3 @@ -15612,7 +15441,7 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> let mut minimum_depth_override: Option = None; let mut pending_splice: Option = None; - let mut quiescent_action = None; + let mut _quiescent_action: Option = None; let mut pending_outbound_held_htlc_flags_opt: Option>> = None; let mut holding_cell_held_htlc_flags_opt: Option>> = None; @@ -15666,7 +15495,7 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> (61, fulfill_attribution_data, optional_vec), // Added in 0.2 (63, holder_commitment_point_current_opt, option), // Added in 0.2 (64, pending_splice, option), // Added in 0.2 - (65, quiescent_action, upgradable_option), // Added in 0.2 + (65, _quiescent_action, upgradable_option), // Added in 0.2 (67, pending_outbound_held_htlc_flags_opt, optional_vec), // Added in 0.2 (69, holding_cell_held_htlc_flags_opt, optional_vec), // Added in 0.2 (71, holder_commitment_point_previous_revoked_opt, option), // Added in 0.3 @@ -16131,7 +15960,7 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> }, holder_commitment_point, pending_splice, - quiescent_action, + quiescent_action: None, }) } } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index d7c1b6000bf..525a764fd33 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12836,7 +12836,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let init_res = funded_channel.splice_init( msg, our_funding_contribution, - &self.signer_provider, &self.entropy_source, &self.get_our_node_id(), &self.logger, @@ -12880,7 +12879,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { let splice_ack_res = funded_channel.splice_ack( msg, - &self.signer_provider, &self.entropy_source, &self.get_our_node_id(), &self.logger, diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 7e7a9fb609c..17dab1918c0 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -12,7 +12,7 @@ use crate::io_extras::sink; use crate::prelude::*; use bitcoin::absolute::LockTime as AbsoluteLockTime; -use bitcoin::amount::{Amount, SignedAmount}; +use bitcoin::amount::Amount; use bitcoin::consensus::Encodable; use bitcoin::constants::WITNESS_SCALE_FACTOR; use bitcoin::ecdsa::Signature as BitcoinSignature; @@ -31,7 +31,7 @@ use crate::ln::chan_utils::{ BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT, SEGWIT_MARKER_FLAG_WEIGHT, }; -use crate::ln::channel::{FundingNegotiationContext, TOTAL_BITCOIN_SUPPLY_SATOSHIS}; +use crate::ln::channel::TOTAL_BITCOIN_SUPPLY_SATOSHIS; use crate::ln::funding::FundingTxInput; use crate::ln::msgs; use crate::ln::msgs::{MessageSendEvent, SerialId, TxSignatures}; @@ -2323,102 +2323,16 @@ impl InteractiveTxConstructor { } } -/// Determine whether a change output should be added, and if yes, of what size, considering our -/// given inputs and outputs, and intended contribution. Takes into account the fees and the dust -/// limit. -/// -/// Three outcomes are possible: -/// - Inputs are sufficient for intended contribution, fees, and a larger-than-dust change: -/// `Ok(Some(change_amount))` -/// - Inputs are sufficient for intended contribution and fees, and a change output isn't needed: -/// `Ok(None)` -/// - Inputs are not sufficient to cover contribution and fees: -/// `Err(AbortReason::InsufficientFees)` -/// -/// Parameters: -/// - `context` - Context of the funding negotiation, including non-shared inputs and feerate. -/// - `is_splice` - Whether we splicing an existing channel or dual-funding a new one. -/// - `shared_output_funding_script` - The script of the shared output. -/// - `funding_outputs` - Our funding outputs. -/// - `change_output_dust_limit` - The dust limit (in sats) to consider. -pub(super) fn calculate_change_output_value( - context: &FundingNegotiationContext, is_splice: bool, shared_output_funding_script: &ScriptBuf, - change_output_dust_limit: u64, -) -> Result, AbortReason> { - let mut total_input_value = Amount::ZERO; - let mut our_funding_inputs_weight = 0u64; - for FundingTxInput { utxo, .. } in context.our_funding_inputs.iter() { - total_input_value = total_input_value.checked_add(utxo.output.value).unwrap_or(Amount::MAX); - - let weight = BASE_INPUT_WEIGHT + utxo.satisfaction_weight; - our_funding_inputs_weight = our_funding_inputs_weight.saturating_add(weight); - } - - let funding_outputs = &context.our_funding_outputs; - let total_output_value = funding_outputs - .iter() - .fold(Amount::ZERO, |total, out| total.checked_add(out.value).unwrap_or(Amount::MAX)); - - let our_funding_outputs_weight = funding_outputs.iter().fold(0u64, |weight, out| { - weight.saturating_add(get_output_weight(&out.script_pubkey).to_wu()) - }); - let mut weight = our_funding_outputs_weight.saturating_add(our_funding_inputs_weight); - - // If we are the initiator, we must pay for the weight of the funding output and - // all common fields in the funding transaction. - if context.is_initiator { - weight = weight.saturating_add(get_output_weight(shared_output_funding_script).to_wu()); - weight = weight.saturating_add(TX_COMMON_FIELDS_WEIGHT); - if is_splice { - // TODO(taproot): Needs to consider different weights based on channel type - weight = weight.saturating_add(BASE_INPUT_WEIGHT); - weight = weight.saturating_add(EMPTY_SCRIPT_SIG_WEIGHT); - weight = weight.saturating_add(FUNDING_TRANSACTION_WITNESS_WEIGHT); - #[cfg(feature = "grind_signatures")] - { - // Guarantees a low R signature - weight -= 1; - } - } - } - - let contributed_fees = - Amount::from_sat(fee_for_weight(context.funding_feerate_sat_per_1000_weight, weight)); - - let contributed_input_value = - context.our_funding_contribution + total_output_value.to_signed().unwrap(); - assert!(contributed_input_value > SignedAmount::ZERO); - let contributed_input_value = contributed_input_value.unsigned_abs(); - - let total_input_value_less_fees = - total_input_value.checked_sub(contributed_fees).unwrap_or(Amount::ZERO); - if total_input_value_less_fees < contributed_input_value { - // Not enough to cover contribution plus fees - return Err(AbortReason::InsufficientFees); - } - - let remaining_value = total_input_value_less_fees - .checked_sub(contributed_input_value) - .expect("remaining_value should not be negative"); - if remaining_value.to_sat() < change_output_dust_limit { - // Enough to cover contribution plus fees, but leftover is below dust limit; no change - Ok(None) - } else { - // Enough to have over-dust change - Ok(Some(remaining_value)) - } -} - #[cfg(test)] mod tests { use crate::chain::chaininterface::{fee_for_weight, FEERATE_FLOOR_SATS_PER_KW}; - use crate::ln::channel::{FundingNegotiationContext, TOTAL_BITCOIN_SUPPLY_SATOSHIS}; + use crate::ln::channel::TOTAL_BITCOIN_SUPPLY_SATOSHIS; use crate::ln::funding::FundingTxInput; use crate::ln::interactivetxs::{ - calculate_change_output_value, generate_holder_serial_id, AbortReason, - HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, - InteractiveTxMessageSend, SharedOwnedInput, SharedOwnedOutput, MAX_INPUTS_OUTPUTS_COUNT, - MAX_RECEIVED_TX_ADD_INPUT_COUNT, MAX_RECEIVED_TX_ADD_OUTPUT_COUNT, + generate_holder_serial_id, AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, + InteractiveTxConstructorArgs, InteractiveTxMessageSend, SharedOwnedInput, + SharedOwnedOutput, MAX_INPUTS_OUTPUTS_COUNT, MAX_RECEIVED_TX_ADD_INPUT_COUNT, + MAX_RECEIVED_TX_ADD_OUTPUT_COUNT, }; use crate::ln::types::ChannelId; use crate::sign::EntropySource; @@ -2433,8 +2347,7 @@ mod tests { use bitcoin::transaction::Version; use bitcoin::{opcodes, WScriptHash, Weight, XOnlyPublicKey}; use bitcoin::{ - OutPoint, PubkeyHash, ScriptBuf, Sequence, SignedAmount, Transaction, TxIn, TxOut, - WPubkeyHash, + OutPoint, PubkeyHash, ScriptBuf, Sequence, Transaction, TxIn, TxOut, WPubkeyHash, }; use super::{ @@ -3398,118 +3311,6 @@ mod tests { assert_eq!(generate_holder_serial_id(&&entropy_source, false) % 2, 1) } - #[test] - fn test_calculate_change_output_value_open() { - let input_prevouts = [ - TxOut { - value: Amount::from_sat(70_000), - script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), - }, - TxOut { - value: Amount::from_sat(60_000), - script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), - }, - ]; - let inputs = input_prevouts - .iter() - .map(|txout| { - let prevtx = Transaction { - input: Vec::new(), - output: vec![(*txout).clone()], - lock_time: AbsoluteLockTime::ZERO, - version: Version::TWO, - }; - - FundingTxInput::new_p2wpkh(prevtx, 0).unwrap() - }) - .collect(); - let txout = TxOut { value: Amount::from_sat(10_000), script_pubkey: ScriptBuf::new() }; - let outputs = vec![txout]; - let funding_feerate_sat_per_1000_weight = 3000; - - let total_inputs: Amount = input_prevouts.iter().map(|o| o.value).sum(); - let total_outputs: Amount = outputs.iter().map(|o| o.value).sum(); - let fees = if cfg!(feature = "grind_signatures") { - Amount::from_sat(1734) - } else { - Amount::from_sat(1740) - }; - let common_fees = Amount::from_sat(234); - - // There is leftover for change - let context = FundingNegotiationContext { - is_initiator: true, - our_funding_contribution: SignedAmount::from_sat(110_000), - funding_tx_locktime: AbsoluteLockTime::ZERO, - funding_feerate_sat_per_1000_weight, - shared_funding_input: None, - our_funding_inputs: inputs, - our_funding_outputs: outputs, - }; - let gross_change = - total_inputs - total_outputs - context.our_funding_contribution.to_unsigned().unwrap(); - assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), - Ok(Some(gross_change - fees - common_fees)), - ); - - // There is leftover for change, without common fees - let context = FundingNegotiationContext { is_initiator: false, ..context }; - assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), - Ok(Some(gross_change - fees)), - ); - - // Insufficient inputs, no leftover - let context = FundingNegotiationContext { - is_initiator: false, - our_funding_contribution: SignedAmount::from_sat(130_000), - ..context - }; - assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), - Err(AbortReason::InsufficientFees), - ); - - // Very small leftover - let context = FundingNegotiationContext { - is_initiator: false, - our_funding_contribution: SignedAmount::from_sat(118_000), - ..context - }; - assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), - Ok(None), - ); - - // Small leftover, but not dust - let context = FundingNegotiationContext { - is_initiator: false, - our_funding_contribution: SignedAmount::from_sat(117_992), - ..context - }; - let gross_change = - total_inputs - total_outputs - context.our_funding_contribution.to_unsigned().unwrap(); - assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), 100), - Ok(Some(gross_change - fees)), - ); - - // Larger fee, smaller change - let context = FundingNegotiationContext { - is_initiator: true, - our_funding_contribution: SignedAmount::from_sat(110_000), - funding_feerate_sat_per_1000_weight: funding_feerate_sat_per_1000_weight * 3, - ..context - }; - let gross_change = - total_inputs - total_outputs - context.our_funding_contribution.to_unsigned().unwrap(); - assert_eq!( - calculate_change_output_value(&context, false, &ScriptBuf::new(), 300), - Ok(Some(gross_change - fees * 3 - common_fees * 3)), - ); - } - fn do_verify_tx_signatures( transaction: Transaction, prev_outputs: Vec, ) -> Result<(), String> { diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index ab890fdbab7..4e6aceb3b86 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -1663,28 +1663,23 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) { #[test] fn test_propose_splice_while_disconnected() { - do_test_propose_splice_while_disconnected(false, false); - do_test_propose_splice_while_disconnected(false, true); - do_test_propose_splice_while_disconnected(true, false); - do_test_propose_splice_while_disconnected(true, true); + do_test_propose_splice_while_disconnected(false); + do_test_propose_splice_while_disconnected(true); } #[cfg(test)] -fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { +fn do_test_propose_splice_while_disconnected(use_0conf: bool) { // Test that both nodes are able to propose a splice while the counterparty is disconnected, and // whoever doesn't go first due to the quiescence tie-breaker, will retry their splice after the // first one becomes locked. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - let (persister_0a, persister_0b, persister_1a, persister_1b); - let (chain_monitor_0a, chain_monitor_0b, chain_monitor_1a, chain_monitor_1b); let mut config = test_default_channel_config(); if use_0conf { config.channel_handshake_limits.trust_own_funding_0conf = true; } let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); - let (node_0a, node_0b, node_1a, node_1b); - let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + 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(); @@ -1722,15 +1717,8 @@ fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { value: Amount::from_sat(splice_out_sat), 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, feerate).unwrap(); - let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); let node_0_funding_contribution = - funding_template.splice_out_sync(node_0_outputs, &wallet).unwrap(); - nodes[0] - .node - .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) - .unwrap(); + initiate_splice_out(&nodes[0], &nodes[1], channel_id, node_0_outputs); assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); @@ -1738,38 +1726,11 @@ fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { value: Amount::from_sat(splice_out_sat), script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }]; - let funding_template = nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate).unwrap(); - let wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); let node_1_funding_contribution = - funding_template.splice_out_sync(node_1_outputs, &wallet).unwrap(); - nodes[1] - .node - .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) - .unwrap(); + initiate_splice_out(&nodes[1], &nodes[0], channel_id, node_1_outputs); assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); - if reload { - let encoded_monitor_0 = get_monitor!(nodes[0], channel_id).encode(); - reload_node!( - nodes[0], - nodes[0].node.encode(), - &[&encoded_monitor_0], - persister_0a, - chain_monitor_0a, - node_0a - ); - let encoded_monitor_1 = get_monitor!(nodes[1], channel_id).encode(); - reload_node!( - nodes[1], - nodes[1].node.encode(), - &[&encoded_monitor_1], - persister_1a, - chain_monitor_1a, - node_1a - ); - } - // Reconnect the nodes. Both nodes should attempt quiescence as the initiator, but only one will // be it via the tie-breaker. let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); @@ -1890,29 +1851,8 @@ fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { // Reconnect the nodes. This should trigger the node which lost the tie-breaker to resend `stfu` // for their splice attempt. - if reload { - let encoded_monitor_0 = get_monitor!(nodes[0], channel_id).encode(); - reload_node!( - nodes[0], - nodes[0].node.encode(), - &[&encoded_monitor_0], - persister_0b, - chain_monitor_0b, - node_0b - ); - let encoded_monitor_1 = get_monitor!(nodes[1], channel_id).encode(); - reload_node!( - nodes[1], - nodes[1].node.encode(), - &[&encoded_monitor_1], - persister_1b, - chain_monitor_1b, - node_1b - ); - } else { - nodes[0].node.peer_disconnected(node_id_1); - nodes[1].node.peer_disconnected(node_id_0); - } + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); if !use_0conf { reconnect_args.send_announcement_sigs = (true, true); From ea7efe05b452446cf9d1fef67ad0bdf9a9c40972 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 25 Feb 2026 09:51:04 -0600 Subject: [PATCH 02/26] f - remove QuiescentAction reading --- lightning/src/ln/channel.rs | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 13bc26768b8..c38e2082c47 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3049,22 +3049,6 @@ pub(crate) enum StfuResponse { SpliceInit(msgs::SpliceInit), } -#[cfg(any(test, fuzzing, feature = "_test_utils"))] -impl_writeable_tlv_based_enum_upgradable!(QuiescentAction, - (0, DoNothing) => {}, - (2, Splice) => { - (0, contribution, required), - (1, locktime, required), - }, -); -#[cfg(not(any(test, fuzzing, feature = "_test_utils")))] -impl_writeable_tlv_based_enum_upgradable!(QuiescentAction, - (2, Splice) => { - (0, contribution, required), - (1, locktime, required), - }, -); - /// Wrapper around a [`Transaction`] useful for caching the result of [`Transaction::compute_txid`]. struct ConfirmedTransaction<'a> { tx: &'a Transaction, @@ -15441,7 +15425,6 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> let mut minimum_depth_override: Option = None; let mut pending_splice: Option = None; - let mut _quiescent_action: Option = None; let mut pending_outbound_held_htlc_flags_opt: Option>> = None; let mut holding_cell_held_htlc_flags_opt: Option>> = None; @@ -15495,7 +15478,7 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> (61, fulfill_attribution_data, optional_vec), // Added in 0.2 (63, holder_commitment_point_current_opt, option), // Added in 0.2 (64, pending_splice, option), // Added in 0.2 - (65, _quiescent_action, upgradable_option), // Added in 0.2 + // 65 quiescent_action: Added in 0.2; removed in 0.3 (67, pending_outbound_held_htlc_flags_opt, optional_vec), // Added in 0.2 (69, holding_cell_held_htlc_flags_opt, optional_vec), // Added in 0.2 (71, holder_commitment_point_previous_revoked_opt, option), // Added in 0.3 From 383bbc8320e2980321b3cff8eee2147931602f91 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 25 Feb 2026 09:53:07 -0600 Subject: [PATCH 03/26] f - rename send_splice_init_internal --- lightning/src/ln/channel.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index c38e2082c47..4fe05c41280 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12180,9 +12180,7 @@ where self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }) } - fn send_splice_init_internal( - &mut self, context: FundingNegotiationContext, - ) -> msgs::SpliceInit { + fn send_splice_init(&mut self, context: FundingNegotiationContext) -> msgs::SpliceInit { debug_assert!(self.pending_splice.is_none()); // Rotate the funding pubkey using the prev_funding_txid as a tweak let prev_funding_txid = self.funding.get_funding_txid(); @@ -13401,7 +13399,7 @@ where our_funding_outputs, }; - let splice_init = self.send_splice_init_internal(context); + let splice_init = self.send_splice_init(context); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, #[cfg(any(test, fuzzing, feature = "_test_utils"))] From ce35601f51124895c601d1adb4b0896a322b8e87 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 11 Feb 2026 22:44:01 -0600 Subject: [PATCH 04/26] Split InteractiveTxConstructor::new into outbound/inbound variants Replace the single public InteractiveTxConstructor::new() with separate new_for_outbound() and new_for_inbound() constructors. This moves the initiator's first message preparation out of the core constructor, making it infallible and removing is_initiator from the args struct. Callers no longer need to handle constructor errors, which avoids having to generate SpliceFailed/DiscardFunding events after the QuiescentAction has already been consumed during splice_init/splice_ack handling. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 36 ++---- lightning/src/ln/interactivetxs.rs | 183 ++++++++++++++--------------- 2 files changed, 96 insertions(+), 123 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 4fe05c41280..eae860b9669 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -58,8 +58,7 @@ use crate::ln::channelmanager::{ use crate::ln::funding::{FundingContribution, FundingTemplate, FundingTxInput}; use crate::ln::interactivetxs::{ AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, - InteractiveTxMessageSend, InteractiveTxSigningSession, NegotiationError, SharedOwnedInput, - SharedOwnedOutput, + InteractiveTxMessageSend, InteractiveTxSigningSession, SharedOwnedInput, SharedOwnedOutput, }; use crate::ln::msgs; use crate::ln::msgs::{ClosingSigned, ClosingSignedFeeRange, DecodeError, OnionErrorPacket}; @@ -6690,7 +6689,7 @@ impl FundingNegotiationContext { fn into_interactive_tx_constructor( self, context: &ChannelContext, funding: &FundingScope, entropy_source: &ES, holder_node_id: PublicKey, - ) -> Result { + ) -> InteractiveTxConstructor { debug_assert_eq!( self.shared_funding_input.is_some(), funding.channel_transaction_parameters.splice_parent_funding_txid.is_some(), @@ -6713,7 +6712,6 @@ impl FundingNegotiationContext { counterparty_node_id: context.counterparty_node_id, channel_id: context.channel_id(), feerate_sat_per_kw: self.funding_feerate_sat_per_1000_weight, - is_initiator: self.is_initiator, funding_tx_locktime: self.funding_tx_locktime, inputs_to_contribute: self.our_funding_inputs, shared_funding_input: self.shared_funding_input, @@ -6723,7 +6721,11 @@ impl FundingNegotiationContext { ), outputs_to_contribute: self.our_funding_outputs, }; - InteractiveTxConstructor::new(constructor_args) + if self.is_initiator { + InteractiveTxConstructor::new_for_outbound(constructor_args) + } else { + InteractiveTxConstructor::new_for_inbound(constructor_args) + } } fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { @@ -12435,13 +12437,7 @@ where &splice_funding, entropy_source, holder_node_id.clone(), - ) - .map_err(|err| { - ChannelError::WarnAndDisconnect(format!( - "Failed to start interactive transaction construction, {:?}", - err - )) - })?; + ); debug_assert!(interactive_tx_constructor.take_initiator_first_message().is_none()); // TODO(splicing): if quiescent_action is set, integrate what the user wants to do into the @@ -12500,13 +12496,7 @@ where &splice_funding, entropy_source, holder_node_id.clone(), - ) - .map_err(|err| { - ChannelError::WarnAndDisconnect(format!( - "Failed to start interactive transaction construction, {:?}", - err - )) - })?; + ); let tx_msg_opt = interactive_tx_constructor.take_initiator_first_message(); debug_assert!(self.context.interactive_tx_signing_session.is_none()); @@ -14367,7 +14357,7 @@ impl PendingV2Channel { script_pubkey: funding.get_funding_redeemscript().to_p2wsh(), }; - let interactive_tx_constructor = Some(InteractiveTxConstructor::new( + let interactive_tx_constructor = Some(InteractiveTxConstructor::new_for_inbound( InteractiveTxConstructorArgs { entropy_source, holder_node_id, @@ -14375,16 +14365,12 @@ impl PendingV2Channel { channel_id: context.channel_id, feerate_sat_per_kw: funding_negotiation_context.funding_feerate_sat_per_1000_weight, funding_tx_locktime: funding_negotiation_context.funding_tx_locktime, - is_initiator: false, inputs_to_contribute: our_funding_inputs, shared_funding_input: None, shared_funding_output: SharedOwnedOutput::new(shared_funding_output, our_funding_contribution_sats), outputs_to_contribute: funding_negotiation_context.our_funding_outputs.clone(), } - ).map_err(|err| { - let reason = ClosureReason::ProcessingError { err: err.reason.to_string() }; - ChannelError::Close((err.reason.to_string(), reason)) - })?); + )); let unfunded_context = UnfundedChannelContext { unfunded_channel_age_ticks: 0, diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 17dab1918c0..9f9a9ce487e 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -2020,7 +2020,6 @@ pub(super) struct InteractiveTxConstructorArgs<'a, ES: EntropySource> { pub counterparty_node_id: PublicKey, pub channel_id: ChannelId, pub feerate_sat_per_kw: u32, - pub is_initiator: bool, pub funding_tx_locktime: AbsoluteLockTime, pub inputs_to_contribute: Vec, pub shared_funding_input: Option, @@ -2031,18 +2030,15 @@ pub(super) struct InteractiveTxConstructorArgs<'a, ES: EntropySource> { impl InteractiveTxConstructor { /// Instantiates a new `InteractiveTxConstructor`. /// - /// If the holder is the initiator, they need to send the first message which is a `TxAddInput` - /// message. - pub fn new( - args: InteractiveTxConstructorArgs, - ) -> Result { + /// Use [`Self::new_for_outbound`] or [`Self::new_for_inbound`] instead to also prepare the + /// first message for the initiator. + fn new(args: InteractiveTxConstructorArgs, is_initiator: bool) -> Self { let InteractiveTxConstructorArgs { entropy_source, holder_node_id, counterparty_node_id, channel_id, feerate_sat_per_kw, - is_initiator, funding_tx_locktime, inputs_to_contribute, shared_funding_input, @@ -2112,7 +2108,7 @@ impl InteractiveTxConstructor { let next_input_index = (!inputs_to_contribute.is_empty()).then_some(0); let next_output_index = (!outputs_to_contribute.is_empty()).then_some(0); - let mut constructor = Self { + Self { state_machine, is_initiator, initiator_first_message: None, @@ -2121,19 +2117,32 @@ impl InteractiveTxConstructor { outputs_to_contribute, next_input_index, next_output_index, - }; - // We'll store the first message for the initiator. - if is_initiator { - match constructor.maybe_send_message() { - Ok(message) => { - constructor.initiator_first_message = Some(message); - }, - Err(reason) => { - return Err(constructor.into_negotiation_error(reason)); - }, - } } - Ok(constructor) + } + + /// Instantiates a new `InteractiveTxConstructor` for the initiator (outbound splice). + /// + /// The initiator always has the shared funding output added internally, so preparing the + /// first message should never fail. Debug asserts verify this invariant. + pub fn new_for_outbound(args: InteractiveTxConstructorArgs) -> Self { + let mut constructor = Self::new(args, true); + match constructor.maybe_send_message() { + Ok(message) => constructor.initiator_first_message = Some(message), + Err(reason) => { + debug_assert!( + false, + "Outbound constructor should always have inputs: {:?}", + reason + ); + }, + } + constructor + } + + /// Instantiates a new `InteractiveTxConstructor` for the non-initiator (inbound splice or + /// dual-funded channel acceptor). + pub fn new_for_inbound(args: InteractiveTxConstructorArgs) -> Self { + Self::new(args, false) } fn into_negotiation_error(self, reason: AbortReason) -> NegotiationError { @@ -2438,84 +2447,62 @@ mod tests { &SecretKey::from_slice(&[43; 32]).unwrap(), ); - let mut constructor_a = match InteractiveTxConstructor::new(InteractiveTxConstructorArgs { - entropy_source, - channel_id, - feerate_sat_per_kw: TEST_FEERATE_SATS_PER_KW, - holder_node_id, - counterparty_node_id, - is_initiator: true, - funding_tx_locktime, - inputs_to_contribute: session.inputs_a, - shared_funding_input: session.a_shared_input.map(|(op, prev_output, lo)| { - SharedOwnedInput::new( - TxIn { - previous_output: op, - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - ..Default::default() - }, - prev_output, - lo, - true, // holder_sig_first - generate_funding_script_pubkey(), // witness_script for test - ) - }), - shared_funding_output: SharedOwnedOutput::new( - session.shared_output_a.0, - session.shared_output_a.1, - ), - outputs_to_contribute: session.outputs_a, - }) { - Ok(r) => Some(r), - Err(e) => { - assert_eq!( - Some((e.reason, ErrorCulprit::NodeA)), - session.expect_error, - "Test: {}", - session.description - ); - return; - }, - }; - let mut constructor_b = match InteractiveTxConstructor::new(InteractiveTxConstructorArgs { - entropy_source, - holder_node_id, - counterparty_node_id, - channel_id, - feerate_sat_per_kw: TEST_FEERATE_SATS_PER_KW, - is_initiator: false, - funding_tx_locktime, - inputs_to_contribute: session.inputs_b, - shared_funding_input: session.b_shared_input.map(|(op, prev_output, lo)| { - SharedOwnedInput::new( - TxIn { - previous_output: op, - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - ..Default::default() - }, - prev_output, - lo, - false, // holder_sig_first - generate_funding_script_pubkey(), // witness_script for test - ) - }), - shared_funding_output: SharedOwnedOutput::new( - session.shared_output_b.0, - session.shared_output_b.1, - ), - outputs_to_contribute: session.outputs_b, - }) { - Ok(r) => Some(r), - Err(e) => { - assert_eq!( - Some((e.reason, ErrorCulprit::NodeB)), - session.expect_error, - "Test: {}", - session.description - ); - return; - }, - }; + let mut constructor_a = + Some(InteractiveTxConstructor::new_for_outbound(InteractiveTxConstructorArgs { + entropy_source, + channel_id, + feerate_sat_per_kw: TEST_FEERATE_SATS_PER_KW, + holder_node_id, + counterparty_node_id, + funding_tx_locktime, + inputs_to_contribute: session.inputs_a, + shared_funding_input: session.a_shared_input.map(|(op, prev_output, lo)| { + SharedOwnedInput::new( + TxIn { + previous_output: op, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..Default::default() + }, + prev_output, + lo, + true, // holder_sig_first + generate_funding_script_pubkey(), // witness_script for test + ) + }), + shared_funding_output: SharedOwnedOutput::new( + session.shared_output_a.0, + session.shared_output_a.1, + ), + outputs_to_contribute: session.outputs_a, + })); + let mut constructor_b = + Some(InteractiveTxConstructor::new_for_inbound(InteractiveTxConstructorArgs { + entropy_source, + holder_node_id, + counterparty_node_id, + channel_id, + feerate_sat_per_kw: TEST_FEERATE_SATS_PER_KW, + funding_tx_locktime, + inputs_to_contribute: session.inputs_b, + shared_funding_input: session.b_shared_input.map(|(op, prev_output, lo)| { + SharedOwnedInput::new( + TxIn { + previous_output: op, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..Default::default() + }, + prev_output, + lo, + false, // holder_sig_first + generate_funding_script_pubkey(), // witness_script for test + ) + }), + shared_funding_output: SharedOwnedOutput::new( + session.shared_output_b.0, + session.shared_output_b.1, + ), + outputs_to_contribute: session.outputs_b, + })); let handle_message_send = |msg: InteractiveTxMessageSend, for_constructor: &mut InteractiveTxConstructor| { From 079bd5dc809f26057413a142bf810474a24179c3 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 25 Feb 2026 10:25:41 -0600 Subject: [PATCH 05/26] f - remove initiator_first_message --- lightning/src/ln/channel.rs | 12 ++++++------ lightning/src/ln/interactivetxs.rs | 29 +++++++++++++---------------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index eae860b9669..9ff33f0f5e8 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -6689,7 +6689,7 @@ impl FundingNegotiationContext { fn into_interactive_tx_constructor( self, context: &ChannelContext, funding: &FundingScope, entropy_source: &ES, holder_node_id: PublicKey, - ) -> InteractiveTxConstructor { + ) -> (InteractiveTxConstructor, Option) { debug_assert_eq!( self.shared_funding_input.is_some(), funding.channel_transaction_parameters.splice_parent_funding_txid.is_some(), @@ -6724,7 +6724,7 @@ impl FundingNegotiationContext { if self.is_initiator { InteractiveTxConstructor::new_for_outbound(constructor_args) } else { - InteractiveTxConstructor::new_for_inbound(constructor_args) + (InteractiveTxConstructor::new_for_inbound(constructor_args), None) } } @@ -12431,14 +12431,14 @@ where our_funding_outputs: Vec::new(), }; - let mut interactive_tx_constructor = funding_negotiation_context + let (interactive_tx_constructor, first_message) = funding_negotiation_context .into_interactive_tx_constructor( &self.context, &splice_funding, entropy_source, holder_node_id.clone(), ); - debug_assert!(interactive_tx_constructor.take_initiator_first_message().is_none()); + debug_assert!(first_message.is_none()); // TODO(splicing): if quiescent_action is set, integrate what the user wants to do into the // counterparty-initiated splice. For always-on nodes this probably isn't a useful @@ -12490,14 +12490,14 @@ where panic!("We should have returned an error earlier!"); }; - let mut interactive_tx_constructor = funding_negotiation_context + let (interactive_tx_constructor, tx_msg_opt) = funding_negotiation_context .into_interactive_tx_constructor( &self.context, &splice_funding, entropy_source, holder_node_id.clone(), ); - let tx_msg_opt = interactive_tx_constructor.take_initiator_first_message(); + debug_assert!(tx_msg_opt.is_some()); debug_assert!(self.context.interactive_tx_signing_session.is_none()); diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 9f9a9ce487e..f7e0ce34346 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -1951,7 +1951,6 @@ impl InteractiveTxInput { pub(super) struct InteractiveTxConstructor { state_machine: StateMachine, is_initiator: bool, - initiator_first_message: Option, channel_id: ChannelId, inputs_to_contribute: Vec<(SerialId, InputOwned)>, outputs_to_contribute: Vec<(SerialId, OutputOwned)>, @@ -2111,7 +2110,6 @@ impl InteractiveTxConstructor { Self { state_machine, is_initiator, - initiator_first_message: None, channel_id, inputs_to_contribute, outputs_to_contribute, @@ -2124,19 +2122,22 @@ impl InteractiveTxConstructor { /// /// The initiator always has the shared funding output added internally, so preparing the /// first message should never fail. Debug asserts verify this invariant. - pub fn new_for_outbound(args: InteractiveTxConstructorArgs) -> Self { + pub fn new_for_outbound( + args: InteractiveTxConstructorArgs, + ) -> (Self, Option) { let mut constructor = Self::new(args, true); - match constructor.maybe_send_message() { - Ok(message) => constructor.initiator_first_message = Some(message), + let message = match constructor.maybe_send_message() { + Ok(message) => Some(message), Err(reason) => { debug_assert!( false, "Outbound constructor should always have inputs: {:?}", reason ); + None }, - } - constructor + }; + (constructor, message) } /// Instantiates a new `InteractiveTxConstructor` for the non-initiator (inbound splice or @@ -2188,10 +2189,6 @@ impl InteractiveTxConstructor { self.is_initiator } - pub fn take_initiator_first_message(&mut self) -> Option { - self.initiator_first_message.take() - } - fn maybe_send_message(&mut self) -> Result { let channel_id = self.channel_id; @@ -2447,8 +2444,8 @@ mod tests { &SecretKey::from_slice(&[43; 32]).unwrap(), ); - let mut constructor_a = - Some(InteractiveTxConstructor::new_for_outbound(InteractiveTxConstructorArgs { + let (constructor_a, mut message_send_a) = + InteractiveTxConstructor::new_for_outbound(InteractiveTxConstructorArgs { entropy_source, channel_id, feerate_sat_per_kw: TEST_FEERATE_SATS_PER_KW, @@ -2474,7 +2471,8 @@ mod tests { session.shared_output_a.1, ), outputs_to_contribute: session.outputs_a, - })); + }); + let mut constructor_a = Some(constructor_a); let mut constructor_b = Some(InteractiveTxConstructor::new_for_inbound(InteractiveTxConstructorArgs { entropy_source, @@ -2503,6 +2501,7 @@ mod tests { ), outputs_to_contribute: session.outputs_b, })); + let mut message_send_b = None; let handle_message_send = |msg: InteractiveTxMessageSend, for_constructor: &mut InteractiveTxConstructor| { @@ -2526,8 +2525,6 @@ mod tests { } }; - let mut message_send_a = constructor_a.as_mut().unwrap().take_initiator_first_message(); - let mut message_send_b = None; let mut final_tx_a = None; let mut final_tx_b = None; while constructor_a.is_some() || constructor_b.is_some() { From f786b8168b73add3d115b98a7a1730dba6163474 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 19 Feb 2026 15:19:12 -0600 Subject: [PATCH 06/26] Adjust FundingContribution for acceptor When constructing a FundingContribution, it's always assumed the estimated_fee is for when used as the initiator, who pays for the common fields and shared inputs / outputs. However, when the contribution is used as the acceptor, we'd be overpaying fees. Provide a method on FundingContribution that adjusts the fees and the change output, if possible. --- lightning/src/ln/funding.rs | 449 +++++++++++++++++++++++++++++++++++- 1 file changed, 447 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index dc29b23b1e3..9cda06ced1d 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -11,7 +11,9 @@ use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; -use bitcoin::{Amount, FeeRate, OutPoint, ScriptBuf, SignedAmount, TxOut, WScriptHash, Weight}; +use bitcoin::{ + Amount, FeeRate, OutPoint, ScriptBuf, SignedAmount, TxOut, WPubkeyHash, WScriptHash, Weight, +}; use crate::ln::chan_utils::{ make_funding_redeemscript, BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT, @@ -313,6 +315,14 @@ impl FundingContribution { self.outputs.iter().chain(self.change_output.iter()) } + /// Returns the change output included in this contribution, if any. + /// + /// When coin selection provides more value than needed for the funding contribution and fees, + /// the surplus is returned to the wallet via this change output. + pub fn change_output(&self) -> Option<&TxOut> { + self.change_output.as_ref() + } + pub(super) fn into_tx_parts(self) -> (Vec, Vec) { let FundingContribution { inputs, mut outputs, change_output, .. } = self; @@ -405,11 +415,144 @@ impl FundingContribution { Ok(()) } + /// Computes the feerate adjustment as a pure `&self` operation, returning the new estimated + /// fee and optionally the new change output value. + /// + /// Returns `Ok((new_estimated_fee, new_change_value))` or `Err`: + /// - `(fee, Some(change))` — inputs with change: both should be updated + /// - `(fee, None)` — inputs without change (or change removed), or splice-out: fee updated + /// only + fn compute_feerate_adjustment( + &self, target_feerate: FeeRate, + ) -> Result<(Amount, Option), String> { + let is_splice = self.is_splice; + + if !self.inputs.is_empty() { + let budget = self.estimated_fee; + + if let Some(ref change_output) = self.change_output { + let old_change_value = change_output.value; + let dust_limit = change_output.script_pubkey.minimal_non_dust(); + + // Fair fee including the change output's weight. + let all_outputs: Vec = + self.outputs.iter().chain(self.change_output.iter()).cloned().collect(); + let fair_fee = estimate_transaction_fee( + &self.inputs, + &all_outputs, + false, + is_splice, + target_feerate, + ); + + let available = budget + .checked_add(old_change_value) + .ok_or("Budget plus change value overflow".to_string())?; + + match available.checked_sub(fair_fee) { + Some(new_change_value) if new_change_value >= dust_limit => { + Ok((fair_fee, Some(new_change_value))) + }, + _ => { + // Change would be below dust or negative. Try without change. + let fair_fee_no_change = estimate_transaction_fee( + &self.inputs, + &self.outputs, + false, + is_splice, + target_feerate, + ); + if available >= fair_fee_no_change { + Ok((fair_fee_no_change, None)) + } else { + Err(format!( + "Feerate too high: available fee budget {} insufficient for required fee {}", + available, fair_fee_no_change, + )) + } + }, + } + } else { + // No change output. + let fair_fee = estimate_transaction_fee( + &self.inputs, + &self.outputs, + false, + is_splice, + target_feerate, + ); + if budget < fair_fee { + return Err(format!( + "Feerate too high: fee budget {} insufficient for required fee {}", + budget, fair_fee, + )); + } + let surplus = budget - fair_fee; + let dust_limit = + ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()).minimal_non_dust(); + if surplus >= dust_limit { + return Err(format!( + "Fee surplus {} exceeds dust limit {}; cannot burn without change output", + surplus, dust_limit, + )); + } + Ok((fair_fee, None)) + } + } else { + // No inputs (splice-out): fees paid from channel balance. + let fair_fee = + estimate_transaction_fee(&[], &self.outputs, false, is_splice, target_feerate); + if self.estimated_fee < fair_fee { + return Err(format!( + "Feerate too high: estimated fee {} insufficient for required fee {}", + self.estimated_fee, fair_fee, + )); + } + // Surplus goes back to the channel balance. + Ok((fair_fee, None)) + } + } + + /// 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 fair share at the target + /// feerate. + pub(super) fn for_acceptor_at_feerate(mut self, feerate: FeeRate) -> Result { + let (new_estimated_fee, new_change) = self.compute_feerate_adjustment(feerate)?; + match new_change { + Some(value) => self.change_output.as_mut().unwrap().value = value, + None => self.change_output = None, + } + self.estimated_fee = new_estimated_fee; + self.feerate = feerate; + Ok(self) + } + + /// 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, + ) -> Result { + let (new_estimated_fee, _) = self.compute_feerate_adjustment(target_feerate)?; + Ok(self.net_value_with_fee(new_estimated_fee)) + } + /// The net value contributed to a channel by the splice. If negative, more value will be /// spliced out than spliced in. Fees will be deducted from the expected splice-out amount /// if no inputs were included. pub fn net_value(&self) -> SignedAmount { - let unpaid_fees = if self.inputs.is_empty() { self.estimated_fee } else { Amount::ZERO } + self.net_value_with_fee(self.estimated_fee) + } + + /// Computes the net value using the given `estimated_fee` for the splice-out (no inputs) + /// case. For splice-in, fees are paid by inputs so `estimated_fee` is not deducted. + fn net_value_with_fee(&self, estimated_fee: Amount) -> SignedAmount { + let unpaid_fees = if self.inputs.is_empty() { estimated_fee } else { Amount::ZERO } .to_signed() .expect("estimated_fee is validated to not exceed Amount::MAX_MONEY"); let value_added = self @@ -749,4 +892,306 @@ mod tests { .is_err()); } } + + #[test] + fn test_for_acceptor_at_feerate_higher_change_adjusted() { + // Splice-in: higher target feerate reduces the change output. + // The budget (is_initiator=true) overestimates by including common TX fields, + // shared output, and shared input weight. So we need a sufficiently high target + // feerate for the acceptor's fair fee to exceed the budget, causing the change + // to decrease. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(5000); + let input = funding_input_sats(100_000); + let change = funding_output_sats(10_000); + + // Budget computed as initiator (overestimate, without change output weight). + let budget = estimate_transaction_fee(&[input.clone()], &[], true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee: budget, + inputs: vec![input.clone()], + outputs: vec![], + change_output: Some(change.clone()), + feerate: original_feerate, + is_splice: true, + }; + + let net_value_before = contribution.net_value(); + let contribution = contribution.for_acceptor_at_feerate(target_feerate).unwrap(); + + // Fair fee at target feerate for acceptor (is_initiator=false), including change weight. + let expected_fair_fee = + estimate_transaction_fee(&[input], &[change], false, true, target_feerate); + let expected_change = budget + Amount::from_sat(10_000) - expected_fair_fee; + + assert_eq!(contribution.estimated_fee, expected_fair_fee); + assert!(contribution.change_output.is_some()); + assert_eq!(contribution.change_output.as_ref().unwrap().value, expected_change); + assert!(expected_change < Amount::from_sat(10_000)); // Change reduced + assert_eq!(contribution.net_value(), net_value_before); + } + + #[test] + fn test_for_acceptor_at_feerate_lower_change_increased() { + // Splice-in: lower target feerate increases the change output. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(1000); + let input = funding_input_sats(100_000); + let change = funding_output_sats(10_000); + + let budget = estimate_transaction_fee(&[input.clone()], &[], true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee: budget, + inputs: vec![input.clone()], + outputs: vec![], + change_output: Some(change.clone()), + feerate: original_feerate, + is_splice: true, + }; + + let net_value_before = contribution.net_value(); + let contribution = contribution.for_acceptor_at_feerate(target_feerate).unwrap(); + + let expected_fair_fee = + estimate_transaction_fee(&[input], &[change], false, true, target_feerate); + let expected_change = budget + Amount::from_sat(10_000) - expected_fair_fee; + + assert_eq!(contribution.estimated_fee, expected_fair_fee); + assert!(contribution.change_output.is_some()); + assert_eq!(contribution.change_output.as_ref().unwrap().value, expected_change); + assert!(expected_change > Amount::from_sat(10_000)); // Change increased + assert_eq!(contribution.net_value(), net_value_before); + } + + #[test] + fn test_for_acceptor_at_feerate_change_removed() { + // Splice-in: feerate high enough that change drops below dust and is removed, + // but budget + change still covers the fee without the change output. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(7000); + let input = funding_input_sats(100_000); + let change = funding_output_sats(500); + + let budget = estimate_transaction_fee(&[input.clone()], &[], true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee: budget, + inputs: vec![input.clone()], + outputs: vec![], + change_output: Some(change), + feerate: original_feerate, + is_splice: true, + }; + + let net_value_before = contribution.net_value(); + let contribution = contribution.for_acceptor_at_feerate(target_feerate).unwrap(); + + // Change should be removed; estimated_fee updated to no-change fair fee. + assert!(contribution.change_output.is_none()); + let expected_fee_no_change = + estimate_transaction_fee(&[input], &[], false, true, target_feerate); + assert_eq!(contribution.estimated_fee, expected_fee_no_change); + assert_eq!(contribution.net_value(), net_value_before); + } + + #[test] + fn test_for_acceptor_at_feerate_too_high_rejected() { + // Splice-in: feerate so high that even without change, the fee can't be covered. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(100_000); + let input = funding_input_sats(100_000); + let change = funding_output_sats(500); + + let budget = estimate_transaction_fee(&[input.clone()], &[], true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee: budget, + inputs: vec![input], + outputs: vec![], + change_output: Some(change), + feerate: original_feerate, + is_splice: true, + }; + + let result = contribution.for_acceptor_at_feerate(target_feerate); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Feerate too high")); + } + + #[test] + fn test_for_acceptor_at_feerate_splice_out_sufficient() { + // Splice-out (no inputs): budget from is_initiator=true overestimate covers the + // acceptor's fair fee at a moderately higher target feerate. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(3000); + let output = funding_output_sats(50_000); + + let budget = estimate_transaction_fee(&[], &[output.clone()], true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::ZERO, + estimated_fee: budget, + inputs: vec![], + outputs: vec![output.clone()], + change_output: None, + feerate: original_feerate, + is_splice: true, + }; + + let contribution = contribution.for_acceptor_at_feerate(target_feerate).unwrap(); + // estimated_fee is updated to the fair fee; surplus goes back to channel balance. + let expected_fair_fee = + estimate_transaction_fee(&[], &[output], false, true, target_feerate); + assert_eq!(contribution.estimated_fee, expected_fair_fee); + assert!(expected_fair_fee <= budget); + } + + #[test] + fn test_for_acceptor_at_feerate_splice_out_insufficient() { + // Splice-out: target feerate too high for the is_initiator=true budget. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(50_000); + let output = funding_output_sats(50_000); + + let budget = estimate_transaction_fee(&[], &[output.clone()], true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::ZERO, + estimated_fee: budget, + inputs: vec![], + outputs: vec![output], + change_output: None, + feerate: original_feerate, + is_splice: true, + }; + + let result = contribution.for_acceptor_at_feerate(target_feerate); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Feerate too high")); + } + + #[test] + fn test_net_value_for_acceptor_at_feerate_splice_in() { + // Splice-in: net_value_for_acceptor_at_feerate returns the same value as net_value() since + // splice-in fees are paid by inputs, not from channel balance. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(3000); + let input = funding_input_sats(100_000); + let change = funding_output_sats(10_000); + + let budget = estimate_transaction_fee(&[input.clone()], &[], true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee: budget, + inputs: vec![input], + outputs: vec![], + change_output: Some(change), + feerate: original_feerate, + is_splice: true, + }; + + // For splice-in, unpaid_fees is zero so net_value_for_acceptor_at_feerate equals net_value. + let net_at_feerate = + contribution.net_value_for_acceptor_at_feerate(target_feerate).unwrap(); + assert_eq!(net_at_feerate, contribution.net_value()); + assert_eq!(net_at_feerate, Amount::from_sat(50_000).to_signed().unwrap()); + } + + #[test] + fn test_net_value_for_acceptor_at_feerate_splice_out() { + // Splice-out: net_value_for_acceptor_at_feerate returns the adjusted value using the fair fee + // at the target feerate. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(3000); + let output = funding_output_sats(50_000); + + let budget = estimate_transaction_fee(&[], &[output.clone()], true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::ZERO, + estimated_fee: budget, + inputs: vec![], + outputs: vec![output.clone()], + change_output: None, + feerate: original_feerate, + is_splice: true, + }; + + let net_at_feerate = + contribution.net_value_for_acceptor_at_feerate(target_feerate).unwrap(); + + // The fair fee at target feerate should be less than the initiator's budget. + let fair_fee = estimate_transaction_fee(&[], &[output], false, true, target_feerate); + let expected_net = SignedAmount::ZERO + - Amount::from_sat(50_000).to_signed().unwrap() + - fair_fee.to_signed().unwrap(); + assert_eq!(net_at_feerate, expected_net); + + // Should be less negative than net_value() which uses the higher budget. + assert!(net_at_feerate > contribution.net_value()); + } + + #[test] + fn test_net_value_for_acceptor_at_feerate_does_not_mutate() { + // Verify net_value_for_acceptor_at_feerate does not modify the contribution. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(5000); + let input = funding_input_sats(100_000); + let change = funding_output_sats(10_000); + + let budget = estimate_transaction_fee(&[input.clone()], &[], true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee: budget, + inputs: vec![input], + outputs: vec![], + change_output: Some(change), + feerate: original_feerate, + is_splice: true, + }; + + let net_before = contribution.net_value(); + let fee_before = contribution.estimated_fee; + let change_before = contribution.change_output.as_ref().unwrap().value; + + let _ = contribution.net_value_for_acceptor_at_feerate(target_feerate); + + // Nothing should have changed. + assert_eq!(contribution.net_value(), net_before); + assert_eq!(contribution.estimated_fee, fee_before); + assert_eq!(contribution.change_output.as_ref().unwrap().value, change_before); + } + + #[test] + fn test_net_value_for_acceptor_at_feerate_too_high() { + // net_value_for_acceptor_at_feerate returns Err when feerate can't be accommodated. + let original_feerate = FeeRate::from_sat_per_kwu(2000); + let target_feerate = FeeRate::from_sat_per_kwu(100_000); + let input = funding_input_sats(100_000); + let change = funding_output_sats(500); + + let budget = estimate_transaction_fee(&[input.clone()], &[], true, true, original_feerate); + + let contribution = FundingContribution { + value_added: Amount::from_sat(50_000), + estimated_fee: budget, + inputs: vec![input], + outputs: vec![], + change_output: Some(change), + feerate: original_feerate, + is_splice: true, + }; + + let result = contribution.net_value_for_acceptor_at_feerate(target_feerate); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Feerate too high")); + } } From 353183987eae893e3cbd161f1d4486355a545de3 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 25 Feb 2026 11:21:27 -0600 Subject: [PATCH 07/26] f - rewrite docs --- lightning/src/ln/funding.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 9cda06ced1d..c57a6aeae90 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -415,13 +415,14 @@ impl FundingContribution { Ok(()) } - /// Computes the feerate adjustment as a pure `&self` operation, returning the new estimated - /// fee and optionally the new change output value. + /// 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. /// - /// Returns `Ok((new_estimated_fee, new_change_value))` or `Err`: - /// - `(fee, Some(change))` — inputs with change: both should be updated - /// - `(fee, None)` — inputs without change (or change removed), or splice-out: fee updated - /// only + /// On success, returns the new estimated fee and, if applicable, the new change output value: + /// - `Some(change)` — the adjusted change output value + /// - `None` — no change output (no inputs or change fell below dust) + /// + /// Returns `Err` if the contribution cannot accommodate the target feerate. fn compute_feerate_adjustment( &self, target_feerate: FeeRate, ) -> Result<(Amount, Option), String> { From f88b5672790dde090b5cf4b08cc5e164b8556d3b Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 23 Feb 2026 10:46:11 -0600 Subject: [PATCH 08/26] Include change output weight in estimate_transaction_fee Add a `change_output: Option<&TxOut>` parameter to `estimate_transaction_fee` so the initial fee estimate accounts for the change output's weight. Previously, the change output weight was omitted from `estimated_fee` in `FundingContribution`, causing the estimate to be slightly too low when a change output was present. This also eliminates an unnecessary `Vec` allocation in `compute_feerate_adjustment`, which previously cloned outputs into a temporary Vec just to include the change output for the fee estimate. A mock `TightBudgetWallet` is added to `splicing_tests` to demonstrate that `validate()` correctly rejects contributions where the input value is sufficient without the change output weight but insufficient with it. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/funding.rs | 160 ++++++++++++++++++++++------- lightning/src/ln/splicing_tests.rs | 79 +++++++++++++- 2 files changed, 200 insertions(+), 39 deletions(-) diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index c57a6aeae90..eb3648f1988 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -118,7 +118,7 @@ macro_rules! build_funding_contribution { // The caller creating a FundingContribution is always the initiator for fee estimation // purposes — this is conservative, overestimating rather than underestimating fees if // the node ends up as the acceptor. - let estimated_fee = estimate_transaction_fee(&inputs, &outputs, true, is_splice, feerate); + let estimated_fee = estimate_transaction_fee(&inputs, &outputs, change_output.as_ref(), true, is_splice, feerate); debug_assert!(estimated_fee <= Amount::MAX_MONEY); let contribution = FundingContribution { @@ -210,8 +210,8 @@ impl FundingTemplate { } fn estimate_transaction_fee( - inputs: &[FundingTxInput], outputs: &[TxOut], is_initiator: bool, is_splice: bool, - feerate: FeeRate, + inputs: &[FundingTxInput], outputs: &[TxOut], change_output: Option<&TxOut>, + is_initiator: bool, is_splice: bool, feerate: FeeRate, ) -> Amount { let input_weight: u64 = inputs .iter() @@ -220,6 +220,7 @@ fn estimate_transaction_fee( let output_weight: u64 = outputs .iter() + .chain(change_output.into_iter()) .map(|txout| txout.weight().to_wu()) .fold(0, |total_weight, output_weight| total_weight.saturating_add(output_weight)); @@ -392,11 +393,11 @@ impl FundingContribution { .ok_or("Sum of input values is greater than the total bitcoin supply")?; } - // If the inputs are enough to cover intended contribution amount, with fees even when - // there is a change output, we are fine. - // If the inputs are less, but enough to cover intended contribution amount, with - // (lower) fees with no change, we are also fine (change will not be generated). - // So it's enough to check considering the lower, no-change fees. + // If the inputs are enough to cover intended contribution amount plus fees (which + // include the change output weight when present), we are fine. + // If the inputs are less, but enough to cover intended contribution amount with + // (lower) fees without change, we are also fine (change will not be generated). + // Since estimated_fee includes change weight, this check is conservative. // // Note: dust limit is not relevant in this check. @@ -436,11 +437,10 @@ impl FundingContribution { let dust_limit = change_output.script_pubkey.minimal_non_dust(); // Fair fee including the change output's weight. - let all_outputs: Vec = - self.outputs.iter().chain(self.change_output.iter()).cloned().collect(); let fair_fee = estimate_transaction_fee( &self.inputs, - &all_outputs, + &self.outputs, + self.change_output.as_ref(), false, is_splice, target_feerate, @@ -459,6 +459,7 @@ impl FundingContribution { let fair_fee_no_change = estimate_transaction_fee( &self.inputs, &self.outputs, + None, false, is_splice, target_feerate, @@ -478,6 +479,7 @@ impl FundingContribution { let fair_fee = estimate_transaction_fee( &self.inputs, &self.outputs, + None, false, is_splice, target_feerate, @@ -501,8 +503,14 @@ impl FundingContribution { } } else { // No inputs (splice-out): fees paid from channel balance. - let fair_fee = - estimate_transaction_fee(&[], &self.outputs, false, is_splice, target_feerate); + let fair_fee = estimate_transaction_fee( + &[], + &self.outputs, + None, + false, + is_splice, + target_feerate, + ); if self.estimated_fee < fair_fee { return Err(format!( "Feerate too high: estimated fee {} insufficient for required fee {}", @@ -596,45 +604,71 @@ mod tests { // 2 inputs, initiator, 2000 sat/kw feerate assert_eq!( - estimate_transaction_fee(&two_inputs, &[], true, false, FeeRate::from_sat_per_kwu(2000)), + estimate_transaction_fee(&two_inputs, &[], None, true, false, FeeRate::from_sat_per_kwu(2000)), Amount::from_sat(if cfg!(feature = "grind_signatures") { 1512 } else { 1516 }), ); // higher feerate assert_eq!( - estimate_transaction_fee(&two_inputs, &[], true, false, FeeRate::from_sat_per_kwu(3000)), + estimate_transaction_fee(&two_inputs, &[], None, true, false, FeeRate::from_sat_per_kwu(3000)), Amount::from_sat(if cfg!(feature = "grind_signatures") { 2268 } else { 2274 }), ); // only 1 input assert_eq!( - estimate_transaction_fee(&one_input, &[], true, false, FeeRate::from_sat_per_kwu(2000)), + estimate_transaction_fee(&one_input, &[], None, true, false, FeeRate::from_sat_per_kwu(2000)), Amount::from_sat(if cfg!(feature = "grind_signatures") { 970 } else { 972 }), ); // 0 inputs assert_eq!( - estimate_transaction_fee(&[], &[], true, false, FeeRate::from_sat_per_kwu(2000)), + estimate_transaction_fee(&[], &[], None, true, false, FeeRate::from_sat_per_kwu(2000)), Amount::from_sat(428), ); // not initiator assert_eq!( - estimate_transaction_fee(&[], &[], false, false, FeeRate::from_sat_per_kwu(2000)), + estimate_transaction_fee(&[], &[], None, false, false, FeeRate::from_sat_per_kwu(2000)), Amount::from_sat(0), ); // splice initiator assert_eq!( - estimate_transaction_fee(&one_input, &[], true, true, FeeRate::from_sat_per_kwu(2000)), + estimate_transaction_fee(&one_input, &[], None, true, true, FeeRate::from_sat_per_kwu(2000)), Amount::from_sat(if cfg!(feature = "grind_signatures") { 1736 } else { 1740 }), ); // splice acceptor assert_eq!( - estimate_transaction_fee(&one_input, &[], false, true, FeeRate::from_sat_per_kwu(2000)), + estimate_transaction_fee(&one_input, &[], None, false, true, FeeRate::from_sat_per_kwu(2000)), Amount::from_sat(if cfg!(feature = "grind_signatures") { 542 } else { 544 }), ); + + // splice initiator, 1 input, 1 output + let output = funding_output_sats(500); + assert_eq!( + estimate_transaction_fee(&one_input, &[output.clone()], None, true, true, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 1984 } else { 1988 }), + ); + + // splice acceptor, 1 input, 1 output + assert_eq!( + estimate_transaction_fee(&one_input, &[output.clone()], None, false, true, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 790 } else { 792 }), + ); + + // splice initiator, 1 input, 1 output, 1 change via change_output parameter + let change = funding_output_sats(1_000); + assert_eq!( + estimate_transaction_fee(&one_input, &[output.clone()], Some(&change), true, true, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 2232 } else { 2236 }), + ); + + // splice acceptor, 1 input, 1 output, 1 change via change_output parameter + assert_eq!( + estimate_transaction_fee(&one_input, &[output.clone()], Some(&change), false, true, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 1038 } else { 1040 }), + ); } #[rustfmt::skip] @@ -902,12 +936,19 @@ mod tests { // feerate for the acceptor's fair fee to exceed the budget, causing the change // to decrease. let original_feerate = FeeRate::from_sat_per_kwu(2000); - let target_feerate = FeeRate::from_sat_per_kwu(5000); + let target_feerate = FeeRate::from_sat_per_kwu(6000); let input = funding_input_sats(100_000); let change = funding_output_sats(10_000); - // Budget computed as initiator (overestimate, without change output weight). - let budget = estimate_transaction_fee(&[input.clone()], &[], true, true, original_feerate); + // Budget computed as initiator (overestimate), including change output weight. + let budget = estimate_transaction_fee( + &[input.clone()], + &[], + Some(&change), + true, + true, + original_feerate, + ); let contribution = FundingContribution { value_added: Amount::from_sat(50_000), @@ -924,7 +965,7 @@ mod tests { // Fair fee at target feerate for acceptor (is_initiator=false), including change weight. let expected_fair_fee = - estimate_transaction_fee(&[input], &[change], false, true, target_feerate); + estimate_transaction_fee(&[input], &[], Some(&change), false, true, target_feerate); let expected_change = budget + Amount::from_sat(10_000) - expected_fair_fee; assert_eq!(contribution.estimated_fee, expected_fair_fee); @@ -942,7 +983,14 @@ mod tests { let input = funding_input_sats(100_000); let change = funding_output_sats(10_000); - let budget = estimate_transaction_fee(&[input.clone()], &[], true, true, original_feerate); + let budget = estimate_transaction_fee( + &[input.clone()], + &[], + Some(&change), + true, + true, + original_feerate, + ); let contribution = FundingContribution { value_added: Amount::from_sat(50_000), @@ -958,7 +1006,7 @@ mod tests { let contribution = contribution.for_acceptor_at_feerate(target_feerate).unwrap(); let expected_fair_fee = - estimate_transaction_fee(&[input], &[change], false, true, target_feerate); + estimate_transaction_fee(&[input], &[], Some(&change), false, true, target_feerate); let expected_change = budget + Amount::from_sat(10_000) - expected_fair_fee; assert_eq!(contribution.estimated_fee, expected_fair_fee); @@ -977,7 +1025,14 @@ mod tests { let input = funding_input_sats(100_000); let change = funding_output_sats(500); - let budget = estimate_transaction_fee(&[input.clone()], &[], true, true, original_feerate); + let budget = estimate_transaction_fee( + &[input.clone()], + &[], + Some(&change), + true, + true, + original_feerate, + ); let contribution = FundingContribution { value_added: Amount::from_sat(50_000), @@ -995,7 +1050,7 @@ mod tests { // Change should be removed; estimated_fee updated to no-change fair fee. assert!(contribution.change_output.is_none()); let expected_fee_no_change = - estimate_transaction_fee(&[input], &[], false, true, target_feerate); + estimate_transaction_fee(&[input], &[], None, false, true, target_feerate); assert_eq!(contribution.estimated_fee, expected_fee_no_change); assert_eq!(contribution.net_value(), net_value_before); } @@ -1008,7 +1063,14 @@ mod tests { let input = funding_input_sats(100_000); let change = funding_output_sats(500); - let budget = estimate_transaction_fee(&[input.clone()], &[], true, true, original_feerate); + let budget = estimate_transaction_fee( + &[input.clone()], + &[], + Some(&change), + true, + true, + original_feerate, + ); let contribution = FundingContribution { value_added: Amount::from_sat(50_000), @@ -1033,7 +1095,8 @@ mod tests { let target_feerate = FeeRate::from_sat_per_kwu(3000); let output = funding_output_sats(50_000); - let budget = estimate_transaction_fee(&[], &[output.clone()], true, true, original_feerate); + let budget = + estimate_transaction_fee(&[], &[output.clone()], None, true, true, original_feerate); let contribution = FundingContribution { value_added: Amount::ZERO, @@ -1048,7 +1111,7 @@ mod tests { let contribution = contribution.for_acceptor_at_feerate(target_feerate).unwrap(); // estimated_fee is updated to the fair fee; surplus goes back to channel balance. let expected_fair_fee = - estimate_transaction_fee(&[], &[output], false, true, target_feerate); + estimate_transaction_fee(&[], &[output], None, false, true, target_feerate); assert_eq!(contribution.estimated_fee, expected_fair_fee); assert!(expected_fair_fee <= budget); } @@ -1060,7 +1123,8 @@ mod tests { let target_feerate = FeeRate::from_sat_per_kwu(50_000); let output = funding_output_sats(50_000); - let budget = estimate_transaction_fee(&[], &[output.clone()], true, true, original_feerate); + let budget = + estimate_transaction_fee(&[], &[output.clone()], None, true, true, original_feerate); let contribution = FundingContribution { value_added: Amount::ZERO, @@ -1086,7 +1150,14 @@ mod tests { let input = funding_input_sats(100_000); let change = funding_output_sats(10_000); - let budget = estimate_transaction_fee(&[input.clone()], &[], true, true, original_feerate); + let budget = estimate_transaction_fee( + &[input.clone()], + &[], + Some(&change), + true, + true, + original_feerate, + ); let contribution = FundingContribution { value_added: Amount::from_sat(50_000), @@ -1113,7 +1184,8 @@ mod tests { let target_feerate = FeeRate::from_sat_per_kwu(3000); let output = funding_output_sats(50_000); - let budget = estimate_transaction_fee(&[], &[output.clone()], true, true, original_feerate); + let budget = + estimate_transaction_fee(&[], &[output.clone()], None, true, true, original_feerate); let contribution = FundingContribution { value_added: Amount::ZERO, @@ -1129,7 +1201,7 @@ mod tests { contribution.net_value_for_acceptor_at_feerate(target_feerate).unwrap(); // The fair fee at target feerate should be less than the initiator's budget. - let fair_fee = estimate_transaction_fee(&[], &[output], false, true, target_feerate); + let fair_fee = estimate_transaction_fee(&[], &[output], None, false, true, target_feerate); let expected_net = SignedAmount::ZERO - Amount::from_sat(50_000).to_signed().unwrap() - fair_fee.to_signed().unwrap(); @@ -1147,7 +1219,14 @@ mod tests { let input = funding_input_sats(100_000); let change = funding_output_sats(10_000); - let budget = estimate_transaction_fee(&[input.clone()], &[], true, true, original_feerate); + let budget = estimate_transaction_fee( + &[input.clone()], + &[], + Some(&change), + true, + true, + original_feerate, + ); let contribution = FundingContribution { value_added: Amount::from_sat(50_000), @@ -1179,7 +1258,14 @@ mod tests { let input = funding_input_sats(100_000); let change = funding_output_sats(500); - let budget = estimate_transaction_fee(&[input.clone()], &[], true, true, original_feerate); + let budget = estimate_transaction_fee( + &[input.clone()], + &[], + Some(&change), + true, + true, + original_feerate, + ); let contribution = FundingContribution { value_added: Amount::from_sat(50_000), diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 4e6aceb3b86..e007cc7ec26 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -25,15 +25,18 @@ use crate::ln::types::ChannelId; use crate::routing::router::{PaymentParameters, RouteParameters}; use crate::util::errors::APIError; use crate::util::ser::Writeable; -use crate::util::wallet_utils::{WalletSourceSync, WalletSync}; +use crate::util::wallet_utils::{ + CoinSelection, CoinSelectionSourceSync, ConfirmedUtxo, Input, WalletSourceSync, WalletSync, +}; use crate::sync::Arc; use bitcoin::hashes::Hash; use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use bitcoin::transaction::Version; use bitcoin::{ - Amount, FeeRate, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut, WPubkeyHash, + Amount, FeeRate, OutPoint as BitcoinOutPoint, Psbt, ScriptBuf, Transaction, TxOut, WPubkeyHash, }; #[test] @@ -112,6 +115,78 @@ fn test_v1_splice_in_negative_insufficient_inputs() { assert!(funding_template.splice_in_sync(splice_in_value, &wallet).is_err()); } +/// A mock wallet that returns a pre-configured [`CoinSelection`] with a single input and change +/// output. Used to test edge cases where the input value is tight relative to the fee estimate. +struct TightBudgetWallet { + utxo_value: Amount, + change_value: Amount, +} + +impl CoinSelectionSourceSync for TightBudgetWallet { + 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 { + let prevout = TxOut { + value: self.utxo_value, + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), + }; + let prevtx = Transaction { + input: vec![], + output: vec![prevout], + version: Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + }; + let utxo = ConfirmedUtxo::new_p2wpkh(prevtx, 0).unwrap(); + + let change_output = TxOut { + value: self.change_value, + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), + }; + + Ok(CoinSelection { confirmed_utxos: vec![utxo], change_output: Some(change_output) }) + } + + fn sign_psbt(&self, _psbt: Psbt) -> Result { + unreachable!("should not reach signing") + } +} + +#[test] +fn test_validate_accounts_for_change_output_weight() { + // Demonstrates that estimated_fee includes the change output's weight when building a + // FundingContribution. A mock wallet returns a single input whose value is between + // estimated_fee_without_change (1736/1740 sats) and estimated_fee_with_change (1984/1988 + // sats) above value_added. The validate() check correctly catches that the inputs are + // insufficient when the change output weight is included. Without accounting for the change + // output weight, the check would incorrectly pass. + 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 (_, _, channel_id, _) = + 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) + .unwrap(); + + // Input value = value_added + 1800: above 1736/1740 (fee without change), below 1984/1988 + // (fee with change). + let value_added = Amount::from_sat(20_000); + let wallet = TightBudgetWallet { + 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(); + + assert!(contribution.change_output().is_some()); + assert!(contribution.validate().is_err()); +} + pub fn negotiate_splice_tx<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, funding_contribution: FundingContribution, From 396f5c8c0147dc11800fb806ab007ad38de21e58 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 10 Feb 2026 16:02:31 -0600 Subject: [PATCH 09/26] Contribute to splice as acceptor When both nodes want to splice simultaneously, the quiescence tie-breaker designates one as the initiator. Previously, the losing node responded with zero contribution, requiring a second full splice session after the first splice locked. This is wasteful, especially for often-offline nodes that may connect and immediately want to splice. Instead, the losing node contributes to the winner's splice as the acceptor, merging both contributions into a single splice transaction. Since the FundingContribution was originally built with initiator fees (which include common fields and shared input/output weight), the fee is adjusted to the acceptor rate before contributing, with the surplus returned to the change output. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 67 +++- lightning/src/ln/channelmanager.rs | 4 - lightning/src/ln/splicing_tests.rs | 533 +++++++++++++++++++++++++---- 3 files changed, 515 insertions(+), 89 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 9ff33f0f5e8..14de1264a39 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12182,6 +12182,26 @@ where self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }) } + /// Returns a reference to the funding contribution queued by a pending [`QuiescentAction`], + /// if any. + fn queued_funding_contribution(&self) -> Option<&FundingContribution> { + match &self.quiescent_action { + Some(QuiescentAction::Splice { contribution, .. }) => Some(contribution), + _ => None, + } + } + + /// Consumes and returns the funding contribution from the pending [`QuiescentAction`], if any. + fn take_queued_funding_contribution(&mut self) -> Option { + match &self.quiescent_action { + Some(QuiescentAction::Splice { .. }) => match self.quiescent_action.take() { + Some(QuiescentAction::Splice { contribution, .. }) => Some(contribution), + _ => unreachable!(), + }, + _ => None, + } + } + fn send_splice_init(&mut self, context: FundingNegotiationContext) -> msgs::SpliceInit { debug_assert!(self.pending_splice.is_none()); // Rotate the funding pubkey using the prev_funding_txid as a tweak @@ -12279,10 +12299,6 @@ where )); } - // TODO(splicing): Once splice acceptor can contribute, check that inputs are sufficient, - // similarly to the check in `funding_contributed`. - debug_assert_eq!(our_funding_contribution, SignedAmount::ZERO); - let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); if their_funding_contribution == SignedAmount::ZERO { return Err(ChannelError::WarnAndDisconnect(format!( @@ -12406,11 +12422,37 @@ where } pub(crate) fn splice_init( - &mut self, msg: &msgs::SpliceInit, our_funding_contribution_satoshis: i64, - entropy_source: &ES, holder_node_id: &PublicKey, logger: &L, + &mut self, msg: &msgs::SpliceInit, entropy_source: &ES, holder_node_id: &PublicKey, + logger: &L, ) -> Result { - let our_funding_contribution = SignedAmount::from_sat(our_funding_contribution_satoshis); - let splice_funding = self.validate_splice_init(msg, our_funding_contribution)?; + let feerate = FeeRate::from_sat_per_kwu(msg.funding_feerate_per_kw as u64); + let our_funding_contribution = self.queued_funding_contribution().and_then(|c| { + c.net_value_for_acceptor_at_feerate(feerate) + .map_err(|e| { + log_info!( + logger, + "Cannot accommodate initiator's feerate for channel {}: {}; \ + proceeding without contribution", + self.context.channel_id(), + e, + ); + }) + .ok() + }); + + let splice_funding = + self.validate_splice_init(msg, our_funding_contribution.unwrap_or(SignedAmount::ZERO))?; + + let (our_funding_inputs, our_funding_outputs) = if our_funding_contribution.is_some() { + self.take_queued_funding_contribution() + .expect("queued_funding_contribution was Some") + .for_acceptor_at_feerate(feerate) + .expect("feerate compatibility already checked") + .into_tx_parts() + } else { + Default::default() + }; + let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO); log_info!( logger, @@ -12427,8 +12469,8 @@ where funding_tx_locktime: LockTime::from_consensus(msg.locktime), funding_feerate_sat_per_1000_weight: msg.funding_feerate_per_kw, shared_funding_input: Some(prev_funding_input), - our_funding_inputs: Vec::new(), - our_funding_outputs: Vec::new(), + our_funding_inputs, + our_funding_outputs, }; let (interactive_tx_constructor, first_message) = funding_negotiation_context @@ -12440,11 +12482,6 @@ where ); debug_assert!(first_message.is_none()); - // TODO(splicing): if quiescent_action is set, integrate what the user wants to do into the - // counterparty-initiated splice. For always-on nodes this probably isn't a useful - // optimization, but for often-offline nodes it may be, as we may connect and immediately - // go into splicing from both sides. - let new_funding_pubkey = splice_funding.get_holder_pubkeys().funding_pubkey; self.pending_splice = Some(PendingFunding { funding_negotiation: Some(FundingNegotiation::ConstructingTransaction { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 525a764fd33..627597d2c43 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12813,9 +12813,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let mut peer_state_lock = peer_state_mutex.lock().unwrap(); let peer_state = &mut *peer_state_lock; - // TODO(splicing): Currently not possible to contribute on the splicing-acceptor side - let our_funding_contribution = 0i64; - // Look for the channel match peer_state.channel_by_id.entry(msg.channel_id) { hash_map::Entry::Vacant(_) => { @@ -12835,7 +12832,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { let init_res = funded_channel.splice_init( msg, - our_funding_contribution, &self.entropy_source, &self.get_our_node_id(), &self.logger, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index e007cc7ec26..fa8fe39789a 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -296,6 +296,21 @@ pub fn complete_splice_handshake<'a, 'b, 'c, 'd>( pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, initiator_contribution: FundingContribution, new_funding_script: ScriptBuf, +) { + complete_interactive_funding_negotiation_for_both( + initiator, + acceptor, + channel_id, + initiator_contribution, + None, + new_funding_script, + ); +} + +pub fn complete_interactive_funding_negotiation_for_both<'a, 'b, 'c, 'd>( + initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, + initiator_contribution: FundingContribution, + acceptor_contribution: Option, new_funding_script: ScriptBuf, ) { let node_id_initiator = initiator.node.get_our_node_id(); let node_id_acceptor = acceptor.node.get_our_node_id(); @@ -321,8 +336,22 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( .chain(core::iter::once(new_funding_script)) .collect::>(); + let (mut expected_acceptor_inputs, mut expected_acceptor_scripts) = + if let Some(acceptor_contribution) = acceptor_contribution { + let (acceptor_inputs, acceptor_outputs) = acceptor_contribution.into_tx_parts(); + let expected_acceptor_inputs = + acceptor_inputs.iter().map(|input| input.utxo.outpoint).collect::>(); + let expected_acceptor_scripts = + acceptor_outputs.into_iter().map(|output| output.script_pubkey).collect::>(); + (expected_acceptor_inputs, expected_acceptor_scripts) + } else { + (Vec::new(), Vec::new()) + }; + let mut acceptor_sent_tx_complete = false; + let mut initiator_sent_tx_complete; loop { + // Initiator's turn: send TxAddInput, TxAddOutput, or TxComplete if !expected_initiator_inputs.is_empty() { let tx_add_input = get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor); @@ -339,6 +368,7 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( expected_initiator_inputs.iter().position(|input| *input == input_prevout).unwrap(), ); acceptor.node.handle_tx_add_input(node_id_initiator, &tx_add_input); + initiator_sent_tx_complete = false; } else if !expected_initiator_scripts.is_empty() { let tx_add_output = get_event_msg!(initiator, MessageSendEvent::SendTxAddOutput, node_id_acceptor); @@ -349,6 +379,7 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( .unwrap(), ); acceptor.node.handle_tx_add_output(node_id_initiator, &tx_add_output); + initiator_sent_tx_complete = false; } else { let msg_events = initiator.node.get_and_clear_pending_msg_events(); assert_eq!(msg_events.len(), 1, "{msg_events:?}"); @@ -357,24 +388,69 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( } else { panic!(); } + initiator_sent_tx_complete = true; if acceptor_sent_tx_complete { break; } } - let mut msg_events = acceptor.node.get_and_clear_pending_msg_events(); + // Acceptor's turn: send TxAddInput, TxAddOutput, or TxComplete + let msg_events = acceptor.node.get_and_clear_pending_msg_events(); assert_eq!(msg_events.len(), 1, "{msg_events:?}"); - if let MessageSendEvent::SendTxComplete { ref msg, .. } = msg_events.remove(0) { - initiator.node.handle_tx_complete(node_id_acceptor, msg); - } else { - panic!(); + match &msg_events[0] { + MessageSendEvent::SendTxAddInput { msg, .. } => { + let input_prevout = BitcoinOutPoint { + txid: msg + .prevtx + .as_ref() + .map(|prevtx| prevtx.compute_txid()) + .or(msg.shared_input_txid) + .unwrap(), + vout: msg.prevtx_out, + }; + expected_acceptor_inputs.remove( + expected_acceptor_inputs + .iter() + .position(|input| *input == input_prevout) + .unwrap(), + ); + initiator.node.handle_tx_add_input(node_id_acceptor, msg); + acceptor_sent_tx_complete = false; + }, + MessageSendEvent::SendTxAddOutput { msg, .. } => { + expected_acceptor_scripts.remove( + expected_acceptor_scripts + .iter() + .position(|script| *script == msg.script) + .unwrap(), + ); + initiator.node.handle_tx_add_output(node_id_acceptor, msg); + acceptor_sent_tx_complete = false; + }, + MessageSendEvent::SendTxComplete { msg, .. } => { + initiator.node.handle_tx_complete(node_id_acceptor, msg); + acceptor_sent_tx_complete = true; + if initiator_sent_tx_complete { + break; + } + }, + _ => panic!("Unexpected message event: {:?}", msg_events[0]), } - acceptor_sent_tx_complete = true; } + + assert!(expected_acceptor_inputs.is_empty(), "Not all acceptor inputs were sent"); + assert!(expected_acceptor_scripts.is_empty(), "Not all acceptor outputs were sent"); } pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, is_0conf: bool, +) -> (Transaction, Option<(msgs::SpliceLocked, PublicKey)>) { + sign_interactive_funding_tx_with_acceptor_contribution(initiator, acceptor, is_0conf, false) +} + +pub fn sign_interactive_funding_tx_with_acceptor_contribution<'a, 'b, 'c, 'd>( + initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, is_0conf: bool, + acceptor_has_contribution: bool, ) -> (Transaction, Option<(msgs::SpliceLocked, PublicKey)>) { let node_id_initiator = initiator.node.get_our_node_id(); let node_id_acceptor = acceptor.node.get_our_node_id(); @@ -408,6 +484,29 @@ pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>( }; acceptor.node.handle_commitment_signed(node_id_initiator, &initial_commit_sig_for_acceptor); + if acceptor_has_contribution { + // When the acceptor contributed inputs, it needs to sign as well. The counterparty's + // commitment_signed is buffered until the acceptor signs. + assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); + + let event = get_event!(acceptor, Event::FundingTransactionReadyForSigning); + if let Event::FundingTransactionReadyForSigning { + channel_id, + counterparty_node_id, + unsigned_transaction, + .. + } = event + { + let partially_signed_tx = acceptor.wallet_source.sign_tx(unsigned_transaction).unwrap(); + acceptor + .node + .funding_transaction_signed(&channel_id, &counterparty_node_id, partially_signed_tx) + .unwrap(); + } else { + panic!(); + } + } + let msg_events = acceptor.node.get_and_clear_pending_msg_events(); assert_eq!(msg_events.len(), 2, "{msg_events:?}"); if let MessageSendEvent::UpdateHTLCs { ref updates, .. } = &msg_events[0] { @@ -1270,6 +1369,351 @@ fn test_initiating_splice_holds_stfu_with_pending_splice() { ); } +#[test] +fn test_splice_both_contribute_tiebreak() { + // Same feerate: the acceptor's change increases because is_initiator=false has lower weight. + do_test_splice_both_contribute_tiebreak(None, None); +} + +#[test] +fn test_splice_tiebreak_higher_feerate() { + // Node 0 (winner) uses a higher feerate than node 1 (loser). Node 1's change output is + // adjusted (reduced) to accommodate the higher feerate. Negotiation succeeds. + let floor = FEERATE_FLOOR_SATS_PER_KW as u64; + do_test_splice_both_contribute_tiebreak( + Some(FeeRate::from_sat_per_kwu(floor * 3)), + Some(FeeRate::from_sat_per_kwu(floor)), + ); +} + +#[test] +fn test_splice_tiebreak_lower_feerate() { + // Node 0 (winner) uses a lower feerate than node 1 (loser). Node 1's change output increases + // because the acceptor's fair fee decreases. Negotiation succeeds. + let floor = FEERATE_FLOOR_SATS_PER_KW as u64; + do_test_splice_both_contribute_tiebreak( + Some(FeeRate::from_sat_per_kwu(floor)), + Some(FeeRate::from_sat_per_kwu(floor * 3)), + ); +} + +/// Runs the splice tie-breaker test with optional per-node feerates. +/// If `node_0_feerate` or `node_1_feerate` is None, both use the same default feerate. +#[cfg(test)] +fn do_test_splice_both_contribute_tiebreak( + node_0_feerate: Option, node_1_feerate: Option, +) { + // Both nodes call splice_channel + splice_in_sync + funding_contributed, both send STFU, + // one wins the quiescence tie-break (node 0, the outbound channel funder). The loser + // (node 1) becomes the acceptor and its stored QuiescentAction is consumed by the + // splice_init handler, contributing its inputs/outputs to the splice transaction. + 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)); + + let default_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let feerate_0 = node_0_feerate.unwrap_or(default_feerate); + let feerate_1 = node_1_feerate.unwrap_or(default_feerate); + + // Node 0 calls splice_channel + splice_in_sync + funding_contributed at feerate_0. + let funding_template_0 = + nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate_0).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(added_value, &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + // Node 1 calls splice_channel + splice_in_sync + funding_contributed at feerate_1. + let funding_template_1 = + nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate_1).unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Capture change output values before the tiebreak. + let node_0_change = node_0_funding_contribution + .change_output() + .expect("splice-in should have a change output") + .clone(); + let node_1_change = node_1_funding_contribution + .change_output() + .expect("splice-in should have a change output") + .clone(); + + // Both nodes emit STFU. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + assert!(stfu_0.initiator); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + assert!(stfu_1.initiator); + + // Tie-break: node 1 handles node 0's STFU first — node 1 loses (not the outbound funder). + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + // Node 0 handles node 1's STFU — node 0 wins (outbound funder), sends SpliceInit. + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + + // Node 1 handles SpliceInit — its contribution is adjusted for node 0's feerate as acceptor, + // then sends SpliceAck with its contribution. + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert_ne!( + splice_ack.funding_contribution_satoshis, 0, + "Acceptor should contribute to the splice" + ); + + // Node 0 handles SpliceAck — starts interactive tx construction. + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + // Compute the new funding script from the splice pubkeys. + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + // Complete interactive funding negotiation with both parties' inputs/outputs. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + Some(node_1_funding_contribution), + new_funding_script, + ); + + // Sign (acceptor has contribution) and broadcast. + let (tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + assert!(splice_locked.is_none()); + + // The initiator's change output should remain unchanged (no feerate adjustment). + let initiator_change_in_tx = tx + .output + .iter() + .find(|o| o.script_pubkey == node_0_change.script_pubkey) + .expect("Initiator's change output should be in the splice transaction"); + assert_eq!( + initiator_change_in_tx.value, node_0_change.value, + "Initiator's change output should remain unchanged", + ); + + // The acceptor's change output should be adjusted based on the feerate difference. + let acceptor_change_in_tx = tx + .output + .iter() + .find(|o| o.script_pubkey == node_1_change.script_pubkey) + .expect("Acceptor's change output should be in the splice transaction"); + if feerate_0 <= feerate_1 { + // Initiator's feerate <= acceptor's original: the acceptor's change increases because + // is_initiator=false has lower weight, and the feerate is the same or lower. + assert!( + acceptor_change_in_tx.value > node_1_change.value, + "Acceptor's change should increase when initiator feerate ({}) <= acceptor feerate \ + ({}): adjusted {} vs original {}", + feerate_0.to_sat_per_kwu(), + feerate_1.to_sat_per_kwu(), + acceptor_change_in_tx.value, + node_1_change.value, + ); + } else { + // Initiator's feerate > acceptor's original: the higher feerate more than compensates + // for the lower weight, so the acceptor's change decreases. + assert!( + acceptor_change_in_tx.value < node_1_change.value, + "Acceptor's change should decrease when initiator feerate ({}) > acceptor feerate \ + ({}): adjusted {} vs original {}", + feerate_0.to_sat_per_kwu(), + feerate_1.to_sat_per_kwu(), + acceptor_change_in_tx.value, + node_1_change.value, + ); + } + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + mine_transaction(&nodes[0], &tx); + mine_transaction(&nodes[1], &tx); + + lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); +} + +#[test] +fn test_splice_tiebreak_feerate_too_high() { + // Node 0 (winner) uses a feerate high enough that node 1's (loser) contribution cannot + // cover the fees. Node 1 proceeds without its contribution (QuiescentAction is preserved + // for a future splice). The splice completes with only node 0's inputs/outputs. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + // Node 0 uses a high feerate (20,000 sat/kwu). Node 1 uses the floor feerate but + // splices in a large amount (95,000 sats from a 100,000 sat UTXO), leaving very little + // change/fee budget. Node 1's budget (~5,000 sats) can't cover the acceptor's fair fee + // at 20,000 sat/kwu, so adjust_for_feerate fails. + let high_feerate = FeeRate::from_sat_per_kwu(20_000); + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let node_0_added_value = Amount::from_sat(50_000); + let node_1_added_value = Amount::from_sat(95_000); + + // Node 0: high feerate, moderate splice-in. + let funding_template_0 = + nodes[0].node.splice_channel(&channel_id, &node_id_1, high_feerate).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(node_0_added_value, &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + // Node 1: floor feerate, tight budget (95,000 from 100,000 sat UTXO). + let funding_template_1 = + nodes[1].node.splice_channel(&channel_id, &node_id_0, floor_feerate).unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(node_1_added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Both emit STFU. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + // Tie-break: node 0 wins. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Node 0 sends SpliceInit at 20,000 sat/kwu. + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + + // Node 1 handles SpliceInit — adjust_for_feerate fails because node 1's contribution + // can't cover fees at 20,000 sat/kwu. Node 1 proceeds without its contribution. + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert_eq!( + splice_ack.funding_contribution_satoshis, 0, + "Acceptor should not contribute when feerate adjustment fails" + ); + + // Node 0 handles SpliceAck — starts interactive tx construction. + 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 with only node 0's contribution. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + None, + new_funding_script, + ); + + // Sign (no acceptor contribution) and broadcast. + let (tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, false); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + mine_transaction(&nodes[0], &tx); + mine_transaction(&nodes[1], &tx); + + // After splice_locked, node 1's preserved QuiescentAction triggers STFU. + let node_1_stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = node_1_stfu { + assert!(msg.initiator); + msg + } else { + panic!("Expected SendStfu from node 1 after splice_locked"); + }; + + // === Part 2: Node 1 retries as initiator === + + // Node 0 receives node 1's STFU and responds with its own STFU. + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + + // Node 1 receives STFU → quiescence established → node 1 is the initiator → sends SpliceInit. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + let splice_init = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceInit, node_id_0); + + // Node 0 handles SpliceInit → sends SpliceAck. + nodes[0].node.handle_splice_init(node_id_1, &splice_init); + let splice_ack = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceAck, node_id_1); + + // Node 1 handles SpliceAck → starts interactive tx construction. + nodes[1].node.handle_splice_ack(node_id_0, &splice_ack); + + let new_funding_script_2 = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + // Complete interactive funding negotiation with node 1 as initiator (only node 1 contributes). + complete_interactive_funding_negotiation( + &nodes[1], + &nodes[0], + channel_id, + node_1_funding_contribution, + new_funding_script_2, + ); + + // Sign (no acceptor contribution) and broadcast. + let (new_splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[1], &nodes[0], false); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[1], &node_id_0); + expect_splice_pending_event(&nodes[0], &node_id_1); + + mine_transaction(&nodes[1], &new_splice_tx); + mine_transaction(&nodes[0], &new_splice_tx); + + lock_splice_after_blocks(&nodes[1], &nodes[0], ANTI_REORG_DELAY - 1); +} + #[cfg(test)] #[derive(PartialEq)] enum SpliceStatus { @@ -1745,8 +2189,8 @@ fn test_propose_splice_while_disconnected() { #[cfg(test)] fn do_test_propose_splice_while_disconnected(use_0conf: bool) { // Test that both nodes are able to propose a splice while the counterparty is disconnected, and - // whoever doesn't go first due to the quiescence tie-breaker, will retry their splice after the - // first one becomes locked. + // whoever doesn't go first due to the quiescence tie-breaker, will have their contribution + // merged into the counterparty-initiated splice. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let mut config = test_default_channel_config(); @@ -1826,23 +2270,28 @@ fn do_test_propose_splice_while_disconnected(use_0conf: bool) { .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) .unwrap(); - // Negotiate the first splice to completion. + // Negotiate the splice to completion. Node 1's quiescent action should be consumed by + // splice_init, so both contributions are merged into a single splice. nodes[1].node.handle_splice_init(node_id_0, &splice_init); let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert_ne!(splice_ack.funding_contribution_satoshis, 0); nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); let new_funding_script = chan_utils::make_funding_redeemscript( &splice_init.funding_pubkey, &splice_ack.funding_pubkey, ) .to_p2wsh(); - complete_interactive_funding_negotiation( + complete_interactive_funding_negotiation_for_both( &nodes[0], &nodes[1], channel_id, node_0_funding_contribution, + Some(node_1_funding_contribution), new_funding_script, ); - let (splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], use_0conf); + let (splice_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( + &nodes[0], &nodes[1], use_0conf, true, + ); expect_splice_pending_event(&nodes[0], &node_id_1); expect_splice_pending_event(&nodes[1], &node_id_0); @@ -1856,7 +2305,7 @@ fn do_test_propose_splice_while_disconnected(use_0conf: bool) { mine_transaction(&nodes[0], &splice_tx); mine_transaction(&nodes[1], &splice_tx); - // Mine enough blocks for the first splice to become locked. + // Mine enough blocks for the splice to become locked. connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1); @@ -1864,10 +2313,9 @@ fn do_test_propose_splice_while_disconnected(use_0conf: bool) { }; nodes[1].node.handle_splice_locked(node_id_0, &splice_locked); - // We should see the node which lost the tie-breaker attempt their splice now by first - // negotiating quiescence, but their `stfu` won't be sent until after another reconnection. + // Node 1's quiescent action was consumed, so it should NOT send stfu. let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), if use_0conf { 2 } else { 3 }, "{msg_events:?}"); + assert_eq!(msg_events.len(), if use_0conf { 1 } else { 2 }, "{msg_events:?}"); if let MessageSendEvent::SendSpliceLocked { ref msg, .. } = &msg_events[0] { nodes[0].node.handle_splice_locked(node_id_1, msg); if use_0conf { @@ -1888,10 +2336,6 @@ fn do_test_propose_splice_while_disconnected(use_0conf: bool) { panic!("Unexpected event {:?}", &msg_events[1]); } } - assert!(matches!( - &msg_events[if use_0conf { 1 } else { 2 }], - MessageSendEvent::SendStfu { .. } - )); let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); assert_eq!(msg_events.len(), if use_0conf { 0 } else { 2 }, "{msg_events:?}"); @@ -1924,57 +2368,6 @@ fn do_test_propose_splice_while_disconnected(use_0conf: bool) { .chain_source .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script); - // Reconnect the nodes. This should trigger the node which lost the tie-breaker to resend `stfu` - // for their splice attempt. - nodes[0].node.peer_disconnected(node_id_1); - nodes[1].node.peer_disconnected(node_id_0); - let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); - if !use_0conf { - reconnect_args.send_announcement_sigs = (true, true); - } - reconnect_args.send_stfu = (true, false); - reconnect_nodes(reconnect_args); - - // Drive the second splice to completion. - let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1, "{msg_events:?}"); - if let MessageSendEvent::SendStfu { ref msg, .. } = msg_events[0] { - nodes[1].node.handle_stfu(node_id_0, msg); - } else { - panic!("Unexpected event {:?}", &msg_events[0]); - } - - 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); - nodes[1].node.handle_splice_ack(node_id_0, &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( - &nodes[1], - &nodes[0], - channel_id, - node_1_funding_contribution, - new_funding_script, - ); - let (splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[1], &nodes[0], use_0conf); - expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); - - if use_0conf { - let (splice_locked, for_node_id) = splice_locked.unwrap(); - assert_eq!(for_node_id, node_id_0); - lock_splice(&nodes[1], &nodes[0], &splice_locked, true); - } else { - assert!(splice_locked.is_none()); - mine_transaction(&nodes[0], &splice_tx); - mine_transaction(&nodes[1], &splice_tx); - lock_splice_after_blocks(&nodes[1], &nodes[0], ANTI_REORG_DELAY - 1); - } - // Sanity check that we can still make a test payment. send_payment(&nodes[0], &[&nodes[1]], 1_000_000); } From af0f73e5eb518cd3a6897f3fbade6da9c9f90f31 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 25 Feb 2026 10:43:05 -0600 Subject: [PATCH 10/26] f - log feerate --- lightning/src/ln/channel.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 14de1264a39..731e16637e1 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12431,8 +12431,9 @@ where .map_err(|e| { log_info!( logger, - "Cannot accommodate initiator's feerate for channel {}: {}; \ + "Cannot accommodate initiator's feerate ({}) for channel {}: {}; \ proceeding without contribution", + feerate, self.context.channel_id(), e, ); From f8789050dd1717c5d1b648b086c7b71ae5a72ead Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 25 Feb 2026 10:51:04 -0600 Subject: [PATCH 11/26] Refactor complete_interactive_funding_negotiation_for_both Use a single get_and_clear_pending_msg_events() + match pattern for the initiator's turn, matching the existing acceptor code path. Also add assertions that all expected initiator inputs and outputs were sent. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/splicing_tests.rs | 82 +++++++++++++++--------------- 1 file changed, 42 insertions(+), 40 deletions(-) diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index fa8fe39789a..104934d332e 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -348,50 +348,50 @@ pub fn complete_interactive_funding_negotiation_for_both<'a, 'b, 'c, 'd>( (Vec::new(), Vec::new()) }; - let mut acceptor_sent_tx_complete = false; let mut initiator_sent_tx_complete; + let mut acceptor_sent_tx_complete = false; loop { // Initiator's turn: send TxAddInput, TxAddOutput, or TxComplete - if !expected_initiator_inputs.is_empty() { - let tx_add_input = - get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor); - let input_prevout = BitcoinOutPoint { - txid: tx_add_input - .prevtx - .as_ref() - .map(|prevtx| prevtx.compute_txid()) - .or(tx_add_input.shared_input_txid) - .unwrap(), - vout: tx_add_input.prevtx_out, - }; - expected_initiator_inputs.remove( - expected_initiator_inputs.iter().position(|input| *input == input_prevout).unwrap(), - ); - acceptor.node.handle_tx_add_input(node_id_initiator, &tx_add_input); - initiator_sent_tx_complete = false; - } else if !expected_initiator_scripts.is_empty() { - let tx_add_output = - get_event_msg!(initiator, MessageSendEvent::SendTxAddOutput, node_id_acceptor); - expected_initiator_scripts.remove( - expected_initiator_scripts - .iter() - .position(|script| *script == tx_add_output.script) - .unwrap(), - ); - acceptor.node.handle_tx_add_output(node_id_initiator, &tx_add_output); - initiator_sent_tx_complete = false; - } else { - let msg_events = initiator.node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1, "{msg_events:?}"); - if let MessageSendEvent::SendTxComplete { ref msg, .. } = &msg_events[0] { + let msg_events = initiator.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + match &msg_events[0] { + MessageSendEvent::SendTxAddInput { msg, .. } => { + let input_prevout = BitcoinOutPoint { + txid: msg + .prevtx + .as_ref() + .map(|prevtx| prevtx.compute_txid()) + .or(msg.shared_input_txid) + .unwrap(), + vout: msg.prevtx_out, + }; + expected_initiator_inputs.remove( + expected_initiator_inputs + .iter() + .position(|input| *input == input_prevout) + .unwrap(), + ); + acceptor.node.handle_tx_add_input(node_id_initiator, msg); + initiator_sent_tx_complete = false; + }, + MessageSendEvent::SendTxAddOutput { msg, .. } => { + expected_initiator_scripts.remove( + expected_initiator_scripts + .iter() + .position(|script| *script == msg.script) + .unwrap(), + ); + acceptor.node.handle_tx_add_output(node_id_initiator, msg); + initiator_sent_tx_complete = false; + }, + MessageSendEvent::SendTxComplete { msg, .. } => { acceptor.node.handle_tx_complete(node_id_initiator, msg); - } else { - panic!(); - } - initiator_sent_tx_complete = true; - if acceptor_sent_tx_complete { - break; - } + initiator_sent_tx_complete = true; + if acceptor_sent_tx_complete { + break; + } + }, + _ => panic!("Unexpected message event: {:?}", msg_events[0]), } // Acceptor's turn: send TxAddInput, TxAddOutput, or TxComplete @@ -438,6 +438,8 @@ pub fn complete_interactive_funding_negotiation_for_both<'a, 'b, 'c, 'd>( } } + assert!(expected_initiator_inputs.is_empty(), "Not all initiator inputs were sent"); + assert!(expected_initiator_scripts.is_empty(), "Not all initiator outputs were sent"); assert!(expected_acceptor_inputs.is_empty(), "Not all acceptor inputs were sent"); assert!(expected_acceptor_scripts.is_empty(), "Not all acceptor outputs were sent"); } From f17bfbbfe400cadd3020ab7365984691319f8aa7 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 13:37:44 -0600 Subject: [PATCH 12/26] Accept tx_init_rbf for pending splice transactions When a splice funding transaction has been negotiated but not yet confirmed, either party may initiate RBF to bump the feerate. This enables the acceptor to handle such requests, allowing continued progress toward on-chain confirmation of splices in rising fee environments. Only the acceptor side is implemented; the acceptor does not contribute funds beyond the shared funding input. The initiator side (sending tx_init_rbf and handling tx_ack_rbf) is left for a follow-up. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 170 ++++++++++++ lightning/src/ln/channelmanager.rs | 63 ++++- lightning/src/ln/splicing_tests.rs | 411 +++++++++++++++++++++++++++++ 3 files changed, 639 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 731e16637e1..7241ca12503 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2383,6 +2383,7 @@ where holder_commitment_point, pending_splice: None, quiescent_action: None, + holder_is_quiescence_initiator: false, }; let res = funded_channel.initial_commitment_signed_v2(msg, best_block, signer_provider, logger) .map(|monitor| (Some(monitor), None)) @@ -2910,6 +2911,10 @@ struct PendingFunding { /// The funding txid used in the `splice_locked` received from the counterparty. received_funding_txid: Option, + + /// The feerate used in the last successfully negotiated funding transaction. + /// Used for validating the 25/24 feerate increase rule on RBF attempts. + last_funding_feerate_sat_per_1000_weight: Option, } impl_writeable_tlv_based!(PendingFunding, { @@ -2917,6 +2922,7 @@ impl_writeable_tlv_based!(PendingFunding, { (3, negotiated_candidates, required_vec), (5, sent_funding_txid, option), (7, received_funding_txid, option), + (9, last_funding_feerate_sat_per_1000_weight, option), }); #[derive(Debug)] @@ -6764,6 +6770,10 @@ pub(super) struct FundedChannel { /// initiator we may be able to merge this action into what the counterparty wanted to do (e.g. /// in the case of splicing). quiescent_action: Option, + + /// Whether we (the holder) initiated the current quiescence session. + /// Set when quiescence is established, cleared when quiescence ends. + holder_is_quiescence_initiator: bool, } #[cfg(any(test, fuzzing))] @@ -12229,6 +12239,7 @@ where negotiated_candidates: vec![], sent_funding_txid: None, received_funding_txid: None, + last_funding_feerate_sat_per_1000_weight: Some(funding_feerate_per_kw), }); msgs::SpliceInit { @@ -12492,6 +12503,7 @@ where negotiated_candidates: Vec::new(), received_funding_txid: None, sent_funding_txid: None, + last_funding_feerate_sat_per_1000_weight: Some(msg.funding_feerate_per_kw), }); Ok(msgs::SpliceAck { @@ -12502,6 +12514,159 @@ where }) } + /// Checks during handling tx_init_rbf for an existing splice + fn validate_tx_init_rbf( + &self, msg: &msgs::TxInitRbf, our_funding_contribution: SignedAmount, + fee_estimator: &LowerBoundedFeeEstimator, + ) -> Result { + if self.holder_commitment_point.current_point().is_none() { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} commitment point needs to be advanced once before RBF", + self.context.channel_id(), + ))); + } + + if !self.context.channel_state.is_quiescent() { + return Err(ChannelError::WarnAndDisconnect("Quiescence needed for RBF".to_owned())); + } + + if self.holder_is_quiescence_initiator { + return Err(ChannelError::WarnAndDisconnect( + "Counterparty sent tx_init_rbf but is not the quiescence initiator".to_owned(), + )); + } + + if self.context.minimum_depth(&self.funding) == Some(0) { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} has option_zeroconf, cannot RBF splice", + self.context.channel_id(), + ))); + } + + let pending_splice = match &self.pending_splice { + Some(pending_splice) => pending_splice, + None => { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} has no pending splice to RBF", + self.context.channel_id(), + ))); + }, + }; + + if pending_splice.funding_negotiation.is_some() { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} already has a funding negotiation in progress", + self.context.channel_id(), + ))); + } + + if pending_splice.received_funding_txid.is_some() { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} counterparty already sent splice_locked, cannot RBF", + self.context.channel_id(), + ))); + } + + let first_candidate = match pending_splice.negotiated_candidates.first() { + Some(candidate) => candidate, + None => { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} has no negotiated splice candidates to RBF", + self.context.channel_id(), + ))); + }, + }; + + // Check the 25/24 feerate increase rule + let prev_feerate = + pending_splice.last_funding_feerate_sat_per_1000_weight.unwrap_or_else(|| { + fee_estimator.bounded_sat_per_1000_weight(ConfirmationTarget::UrgentOnChainSweep) + }); + let new_feerate = msg.feerate_sat_per_1000_weight; + if (new_feerate as u64) * 24 < (prev_feerate as u64) * 25 { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", + self.context.channel_id(), + new_feerate, + prev_feerate, + ))); + } + + let their_funding_contribution = match msg.funding_output_contribution { + Some(value) => SignedAmount::from_sat(value), + None => SignedAmount::ZERO, + }; + + self.validate_splice_contributions(our_funding_contribution, their_funding_contribution) + .map_err(|e| ChannelError::WarnAndDisconnect(e))?; + + // Reuse funding pubkeys from the first negotiated candidate since all RBF candidates + // for the same splice share the same funding output script. + let holder_pubkeys = first_candidate.get_holder_pubkeys().clone(); + let counterparty_funding_pubkey = *first_candidate.counterparty_funding_pubkey(); + + Ok(FundingScope::for_splice( + &self.funding, + &self.context, + our_funding_contribution, + their_funding_contribution, + counterparty_funding_pubkey, + holder_pubkeys, + )) + } + + pub(crate) fn tx_init_rbf( + &mut self, msg: &msgs::TxInitRbf, entropy_source: &ES, holder_node_id: &PublicKey, + fee_estimator: &LowerBoundedFeeEstimator, logger: &L, + ) -> Result { + let our_funding_contribution = SignedAmount::ZERO; + let rbf_funding = + self.validate_tx_init_rbf(msg, our_funding_contribution, fee_estimator)?; + + log_info!( + logger, + "Starting RBF funding negotiation for channel {} after receiving tx_init_rbf; channel value: {} sats", + self.context.channel_id, + rbf_funding.get_value_satoshis(), + ); + + let prev_funding_input = self.funding.to_splice_funding_input(); + let funding_negotiation_context = FundingNegotiationContext { + is_initiator: false, + our_funding_contribution, + funding_tx_locktime: LockTime::from_consensus(msg.locktime), + funding_feerate_sat_per_1000_weight: msg.feerate_sat_per_1000_weight, + shared_funding_input: Some(prev_funding_input), + our_funding_inputs: Vec::new(), + our_funding_outputs: Vec::new(), + }; + + let (interactive_tx_constructor, first_message) = funding_negotiation_context + .into_interactive_tx_constructor( + &self.context, + &rbf_funding, + entropy_source, + holder_node_id.clone(), + ); + debug_assert!(first_message.is_none()); + + let pending_splice = self + .pending_splice + .as_mut() + .expect("We validated pending_splice exists in validate_tx_init_rbf"); + pending_splice.funding_negotiation = Some(FundingNegotiation::ConstructingTransaction { + funding: rbf_funding, + interactive_tx_constructor, + }); + pending_splice.last_funding_feerate_sat_per_1000_weight = + Some(msg.feerate_sat_per_1000_weight); + + Ok(msgs::TxAckRbf { + channel_id: self.context.channel_id, + funding_output_contribution: None, + }) + } + pub(crate) fn splice_ack( &mut self, msg: &msgs::SpliceAck, entropy_source: &ES, holder_node_id: &PublicKey, logger: &L, @@ -13381,6 +13546,7 @@ where self.context.channel_state.clear_local_stfu_sent(); self.context.channel_state.set_quiescent(); + self.holder_is_quiescence_initiator = is_holder_quiescence_initiator; log_debug!( logger, @@ -13496,6 +13662,7 @@ where // initiated first, we'll retry after we're no longer quiescent. self.context.channel_state.clear_remote_stfu_sent(); self.context.channel_state.set_quiescent(); + self.holder_is_quiescence_initiator = false; false } else { log_debug!(logger, "Sending stfu as quiescence initiator"); @@ -13839,6 +14006,7 @@ impl OutboundV1Channel { holder_commitment_point, pending_splice: None, quiescent_action: None, + holder_is_quiescence_initiator: false, }; let need_channel_ready = channel.check_get_channel_ready(0, logger).is_some() @@ -14139,6 +14307,7 @@ impl InboundV1Channel { holder_commitment_point, pending_splice: None, quiescent_action: None, + holder_is_quiescence_initiator: false, }; let need_channel_ready = channel.check_get_channel_ready(0, logger).is_some() || channel.context.signer_pending_channel_ready; @@ -15966,6 +16135,7 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> holder_commitment_point, pending_splice, quiescent_action: None, + holder_is_quiescence_initiator: false, }) } } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 627597d2c43..2902db96be4 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12854,6 +12854,54 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } + /// Handle incoming tx_init_rbf, start a new round of interactive transaction construction. + fn internal_tx_init_rbf( + &self, counterparty_node_id: &PublicKey, msg: &msgs::TxInitRbf, + ) -> Result<(), MsgHandleErrInternal> { + let per_peer_state = self.per_peer_state.read().unwrap(); + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + debug_assert!(false); + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) + })?; + let mut peer_state_lock = peer_state_mutex.lock().unwrap(); + let peer_state = &mut *peer_state_lock; + + match peer_state.channel_by_id.entry(msg.channel_id) { + hash_map::Entry::Vacant(_) => { + return Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )) + }, + hash_map::Entry::Occupied(mut chan_entry) => { + if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { + let init_res = funded_channel.tx_init_rbf( + msg, + &self.entropy_source, + &self.get_our_node_id(), + &self.fee_estimator, + &self.logger, + ); + let tx_ack_rbf_msg = try_channel_entry!(self, peer_state, init_res, chan_entry); + peer_state.pending_msg_events.push(MessageSendEvent::SendTxAckRbf { + node_id: *counterparty_node_id, + msg: tx_ack_rbf_msg, + }); + Ok(()) + } else { + try_channel_entry!( + self, + peer_state, + Err( + ChannelError::close("Channel is not funded, cannot RBF splice".into(),) + ), + chan_entry + ) + } + }, + } + } + /// Handle incoming splice request ack, transition channel to splice-pending (unless some check fails). fn internal_splice_ack( &self, counterparty_node_id: &PublicKey, msg: &msgs::SpliceAck, @@ -16307,11 +16355,16 @@ impl< } fn handle_tx_init_rbf(&self, counterparty_node_id: PublicKey, msg: &msgs::TxInitRbf) { - let err = Err(MsgHandleErrInternal::send_err_msg_no_close( - "Dual-funded channels not supported".to_owned(), - msg.channel_id.clone(), - )); - let _: Result<(), _> = self.handle_error(err, counterparty_node_id); + let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || { + let res = self.internal_tx_init_rbf(&counterparty_node_id, msg); + let persist = match &res { + Err(e) if e.closes_channel() => NotifyOption::DoPersist, + Err(_) => NotifyOption::SkipPersistHandleEvents, + Ok(()) => NotifyOption::SkipPersistHandleEvents, + }; + let _ = self.handle_error(res, counterparty_node_id); + persist + }); } fn handle_tx_ack_rbf(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAckRbf) { diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 104934d332e..b3b10cc0b37 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -3760,3 +3760,414 @@ fn test_funding_contributed_unfunded_channel() { expect_discard_funding_event(&nodes[0], &unfunded_channel_id, funding_contribution); } + +// Helper to re-enter quiescence between two nodes where node_a is the initiator. +// Returns after both sides are quiescent (no splice_init is generated since we use DoNothing). +fn reenter_quiescence<'a, 'b, 'c>( + node_a: &Node<'a, 'b, 'c>, node_b: &Node<'a, 'b, 'c>, channel_id: &ChannelId, +) { + let node_id_a = node_a.node.get_our_node_id(); + let node_id_b = node_b.node.get_our_node_id(); + + node_a.node.maybe_propose_quiescence(&node_id_b, channel_id).unwrap(); + let stfu_a = get_event_msg!(node_a, MessageSendEvent::SendStfu, node_id_b); + node_b.node.handle_stfu(node_id_a, &stfu_a); + let stfu_b = get_event_msg!(node_b, MessageSendEvent::SendStfu, node_id_a); + node_a.node.handle_stfu(node_id_b, &stfu_b); +} + +#[test] +fn test_splice_rbf_acceptor_basic() { + // Test the happy path for accepting an RBF of a pending splice transaction. + // After completing a splice-in, re-enter quiescence and process tx_init_rbf + // from the counterparty, responding with tx_ack_rbf. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Re-enter quiescence for RBF (node 0 initiates). + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + // Node 0 sends tx_init_rbf with feerate satisfying the 25/24 rule. + // Original feerate was FEERATE_FLOOR_SATS_PER_KW (253). 253 * 25 / 24 = 263.54, so 264 works. + let rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; // ceil(253*25/24) = 264 + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: rbf_feerate as u32, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); + + assert_eq!(tx_ack_rbf.channel_id, channel_id); + // Acceptor doesn't contribute funds in the RBF. + assert_eq!(tx_ack_rbf.funding_output_contribution, None); +} + +#[test] +fn test_splice_rbf_insufficient_feerate() { + // Test that tx_init_rbf with an insufficient feerate (less than 25/24 of previous) 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 initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice-in. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Re-enter quiescence. + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + // Send tx_init_rbf with feerate that does NOT satisfy the 25/24 rule. + // Original feerate was 253. Using exactly 253 should fail since 253 * 24 < 253 * 25. + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: FEERATE_FLOOR_SATS_PER_KW, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + // Should get an error, not a TxAckRbf. + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!( + "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", + channel_id, FEERATE_FLOOR_SATS_PER_KW, FEERATE_FLOOR_SATS_PER_KW, + ), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } +} + +#[test] +fn test_splice_rbf_no_pending_splice() { + // Test that tx_init_rbf is rejected when there is no pending splice to RBF. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + // Re-enter quiescence without having done a splice. + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(50_000), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!("Channel {} has no pending splice to RBF", channel_id), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } +} + +#[test] +fn test_splice_rbf_active_negotiation() { + // Test that tx_init_rbf is rejected when a funding negotiation is already in progress. + // Start a splice but don't complete interactive TX construction, then send tx_init_rbf. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Initiate a splice but only complete the handshake (STFU + splice_init/ack), + // leaving interactive TX construction in progress. + let _funding_contribution = + do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let _new_funding_script = complete_splice_handshake(&nodes[0], &nodes[1]); + + // Now the acceptor (node 1) has a funding_negotiation in progress (ConstructingTransaction). + // Sending tx_init_rbf should be rejected. + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!( + "Channel {} already has a funding negotiation in progress", + channel_id, + ), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } + + // Clear the initiator's pending interactive TX messages from the incomplete splice handshake. + nodes[0].node.get_and_clear_pending_msg_events(); +} + +#[test] +fn test_splice_rbf_not_quiescence_initiator() { + // Test that tx_init_rbf is rejected when the sender is not the quiescence initiator. + // Node 1 initiates quiescence, so only node 1 should be allowed to send tx_init_rbf. + // Node 0 sending tx_init_rbf should be 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); + + // Complete a splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Re-enter quiescence with node 1 as the initiator (not node 0). + nodes[1].node.maybe_propose_quiescence(&node_id_0, &channel_id).unwrap(); + let stfu_b = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_b); + let stfu_a = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_a); + + // Node 0 sends tx_init_rbf, but node 1 is the quiescence initiator, so node 0 should be + // rejected. + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: "Counterparty sent tx_init_rbf but is not the quiescence initiator" + .to_owned(), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } +} + +#[test] +fn test_splice_rbf_after_splice_locked() { + // Test that tx_init_rbf is rejected when the counterparty has already sent splice_locked. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Mine the splice tx on both nodes. + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + + // Connect enough blocks on node 0 only so it sends splice_locked. + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + + let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + + // Deliver splice_locked to node 1. Since node 1 hasn't confirmed enough blocks, + // it won't send its own splice_locked back, but it will set received_funding_txid. + nodes[1].node.handle_splice_locked(node_id_0, &splice_locked); + + // Node 1 shouldn't have any messages to send (no splice_locked since it hasn't confirmed). + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert!(msg_events.is_empty(), "Expected no messages, got {:?}", msg_events); + + // Re-enter quiescence (node 0 initiates). + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + // Node 0 sends tx_init_rbf, but node 0 already sent splice_locked, so it should be rejected. + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!( + "Channel {} counterparty already sent splice_locked, cannot RBF", + channel_id, + ), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } +} + +#[test] +fn test_splice_rbf_zeroconf_rejected() { + // Test that tx_init_rbf is rejected when option_zeroconf is negotiated. + // The zero-conf check happens before the pending_splice check, so we don't need to complete + // a splice — just enter quiescence and send tx_init_rbf. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_limits.trust_own_funding_0conf = true; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (funding_tx, channel_id) = + open_zero_conf_channel_with_value(&nodes[0], &nodes[1], None, initial_channel_value_sat, 0); + mine_transaction(&nodes[0], &funding_tx); + mine_transaction(&nodes[1], &funding_tx); + + // Enter quiescence (node 0 initiates). + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + // Node 0 sends tx_init_rbf, but the channel has option_zeroconf, so it should be rejected. + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(50_000), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!( + "Channel {} has option_zeroconf, cannot RBF splice", + channel_id, + ), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } +} From 76555a8af7e4b2abddc1e2bc7b9eb4af7381ed09 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 20:32:54 -0600 Subject: [PATCH 13/26] Allow multiple RBF splice candidates in channel monitor The channel monitor previously rejected any new pending funding when one already existed. This prevented adding RBF candidates for a pending splice since each candidate needs its own pending funding entry. Relax the check to only reject new pending funding when its splice parent differs from existing entries, allowing multiple RBF candidates that compete to confirm the same splice. Co-Authored-By: Claude Opus 4.6 --- lightning/src/chain/channelmonitor.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index a8d055a9c5b..d07d86c26e3 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -4039,9 +4039,14 @@ impl ChannelMonitorImpl { } if let Some(parent_funding_txid) = channel_parameters.splice_parent_funding_txid.as_ref() { - // Only one splice can be negotiated at a time after we've exchanged `channel_ready` - // (implying our funding is confirmed) that spends our currently locked funding. - if !self.pending_funding.is_empty() { + // Multiple RBF candidates for the same splice are allowed (they share the same + // parent funding txid). A new splice with a different parent while one is pending + // is not allowed. + let has_different_parent = self.pending_funding.iter().any(|funding| { + funding.channel_parameters.splice_parent_funding_txid.as_ref() + != Some(parent_funding_txid) + }); + if has_different_parent { log_error!( logger, "Negotiated splice while channel is pending channel_ready/splice_locked" From 94719fb32e21288caa74929ba0ddcd2a8a2c7333 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 20:34:56 -0600 Subject: [PATCH 14/26] Add rbf_channel API for initiating splice RBF Expose ChannelManager::rbf_channel as the entry point for bumping the feerate of a pending splice funding transaction. Like splice_channel, it returns a FundingTemplate to be completed and passed to funding_contributed. Validates that a pending splice exists with at least one negotiated candidate, no active funding negotiation, and that the new feerate satisfies the 25/24 increase rule required by the spec. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 104 +++++++++++++++++++++++++++++ lightning/src/ln/channelmanager.rs | 52 +++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 7241ca12503..843d027ddc7 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12116,6 +12116,110 @@ where Ok(FundingTemplate::new(Some(shared_input), feerate)) } + /// Initiate an RBF of a pending splice transaction. + pub fn rbf_channel(&self, feerate: FeeRate) -> Result { + if self.holder_commitment_point.current_point().is_none() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot RBF until a payment is routed", + self.context.channel_id(), + ), + }); + } + + if self.quiescent_action.is_some() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot RBF as one is waiting to be negotiated", + self.context.channel_id(), + ), + }); + } + + if !self.context.is_usable() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot RBF as it is either pending open/close", + self.context.channel_id() + ), + }); + } + + if self.context.minimum_depth(&self.funding) == Some(0) { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} has option_zeroconf, cannot RBF splice", + self.context.channel_id(), + ), + }); + } + + let pending_splice = match &self.pending_splice { + Some(pending_splice) => pending_splice, + None => { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} has no pending splice to RBF", + self.context.channel_id(), + ), + }); + }, + }; + + if pending_splice.funding_negotiation.is_some() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot RBF as a funding negotiation is already in progress", + self.context.channel_id(), + ), + }); + } + + if pending_splice.sent_funding_txid.is_some() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} already sent splice_locked, cannot RBF", + self.context.channel_id(), + ), + }); + } + + if pending_splice.negotiated_candidates.is_empty() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} has no negotiated splice candidates to RBF", + self.context.channel_id(), + ), + }); + } + + // Check the 25/24 feerate increase rule + let new_feerate = feerate.to_sat_per_kwu() as u32; + if let Some(prev_feerate) = pending_splice.last_funding_feerate_sat_per_1000_weight { + if (new_feerate as u64) * 24 < (prev_feerate as u64) * 25 { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", + self.context.channel_id(), + new_feerate, + prev_feerate, + ), + }); + } + } + + 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), feerate)) + } + pub fn funding_contributed( &mut self, contribution: FundingContribution, locktime: LockTime, logger: &L, ) -> Result, QuiescentError> { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2902db96be4..10632b74129 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4714,6 +4714,58 @@ 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. + /// + /// Returns a [`FundingTemplate`] that must be completed with inputs/outputs and then + /// passed to [`Self::funding_contributed`]. + pub fn rbf_channel( + &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, 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(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, From aca94c514ea881a378fc1ab33b73261d9dd789bc Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 24 Feb 2026 21:15:06 -0600 Subject: [PATCH 15/26] f - allow stfu for splice RBF --- lightning/src/ln/channel.rs | 103 +++++++++++++++++------------------- 1 file changed, 50 insertions(+), 53 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 843d027ddc7..d788064313f 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12154,70 +12154,66 @@ where }); } + self.can_initiate_rbf(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), feerate)) + } + + fn can_initiate_rbf(&self, feerate: FeeRate) -> Result<(), String> { let pending_splice = match &self.pending_splice { Some(pending_splice) => pending_splice, None => { - return Err(APIError::APIMisuseError { - err: format!( - "Channel {} has no pending splice to RBF", - self.context.channel_id(), - ), - }); + return Err(format!( + "Channel {} has no pending splice to RBF", + self.context.channel_id(), + )); }, }; if pending_splice.funding_negotiation.is_some() { - return Err(APIError::APIMisuseError { - err: format!( - "Channel {} cannot RBF as a funding negotiation is already in progress", - self.context.channel_id(), - ), - }); + return Err(format!( + "Channel {} cannot RBF as a funding negotiation is already in progress", + self.context.channel_id(), + )); } if pending_splice.sent_funding_txid.is_some() { - return Err(APIError::APIMisuseError { - err: format!( - "Channel {} already sent splice_locked, cannot RBF", - self.context.channel_id(), - ), - }); + return Err(format!( + "Channel {} already sent splice_locked, cannot RBF", + self.context.channel_id(), + )); } if pending_splice.negotiated_candidates.is_empty() { - return Err(APIError::APIMisuseError { - err: format!( - "Channel {} has no negotiated splice candidates to RBF", - self.context.channel_id(), - ), - }); + return Err(format!( + "Channel {} has no negotiated splice candidates to RBF", + self.context.channel_id(), + )); } // Check the 25/24 feerate increase rule let new_feerate = feerate.to_sat_per_kwu() as u32; if let Some(prev_feerate) = pending_splice.last_funding_feerate_sat_per_1000_weight { if (new_feerate as u64) * 24 < (prev_feerate as u64) * 25 { - return Err(APIError::APIMisuseError { - err: format!( - "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", - self.context.channel_id(), - new_feerate, - prev_feerate, - ), - }); + return Err(format!( + "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", + self.context.channel_id(), + new_feerate, + prev_feerate, + )); } } - 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), feerate)) + Ok(()) } pub fn funding_contributed( @@ -13736,17 +13732,18 @@ where } if let Some(action) = self.quiescent_action.as_ref() { - // We can't initiate another splice while ours is pending, so don't bother becoming - // quiescent yet. - // TODO(splicing): Allow the splice as an RBF once supported. - let has_splice_action = matches!(action, QuiescentAction::Splice { .. }); - if has_splice_action && self.pending_splice.is_some() { - log_given_level!( - logger, - logger_level, - "Waiting for pending splice to lock before sending stfu for new splice" - ); - return None; + #[allow(irrefutable_let_patterns)] + if let QuiescentAction::Splice { contribution, .. } = action { + if self.pending_splice.is_some() { + if let Err(msg) = self.can_initiate_rbf(contribution.feerate()) { + log_given_level!( + logger, + logger_level, + "Waiting on sending stfu for splice RBF: {msg}" + ); + return None; + } + } } } From 69deba8c0dbe48c005677c144f6f829d2446e8c1 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 20:36:33 -0600 Subject: [PATCH 16/26] Send tx_init_rbf instead of splice_init when a splice is pending When the quiescence initiator has a pending splice and enters the stfu handler with a QuiescentAction::Splice, send tx_init_rbf to bump the existing splice's feerate rather than starting a new splice_init. This reuses the same QuiescentAction::Splice variant for both initial splices and RBF attempts -- the stfu handler distinguishes them by checking whether pending_splice already exists. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 49 +++++++++++++++++++++--------- lightning/src/ln/channelmanager.rs | 7 +++++ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index d788064313f..6645b4149a4 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3052,6 +3052,7 @@ impl From for QuiescentError { pub(crate) enum StfuResponse { Stfu(msgs::Stfu), SpliceInit(msgs::SpliceInit), + TxInitRbf(msgs::TxInitRbf), } /// Wrapper around a [`Transaction`] useful for caching the result of [`Transaction::compute_txid`]. @@ -12352,6 +12353,34 @@ where } } + fn send_tx_init_rbf_internal(&mut self, context: FundingNegotiationContext) -> msgs::TxInitRbf { + let pending_splice = + self.pending_splice.as_mut().expect("pending_splice should exist for RBF"); + debug_assert!(!pending_splice.negotiated_candidates.is_empty()); + + let new_holder_funding_key = pending_splice + .negotiated_candidates + .first() + .unwrap() + .get_holder_pubkeys() + .funding_pubkey; + + let funding_feerate_per_kw = context.funding_feerate_sat_per_1000_weight; + let funding_contribution_satoshis = context.our_funding_contribution.to_sat(); + let locktime = context.funding_tx_locktime.to_consensus_u32(); + + pending_splice.funding_negotiation = + Some(FundingNegotiation::AwaitingAck { context, new_holder_funding_key }); + pending_splice.last_funding_feerate_sat_per_1000_weight = Some(funding_feerate_per_kw); + + msgs::TxInitRbf { + channel_id: self.context.channel_id, + locktime, + feerate_sat_per_1000_weight: funding_feerate_per_kw, + funding_output_contribution: Some(funding_contribution_satoshis), + } + } + #[cfg(test)] pub fn abandon_splice( &mut self, @@ -13663,21 +13692,6 @@ where )); }, Some(QuiescentAction::Splice { contribution, locktime }) => { - // TODO(splicing): If the splice has been negotiated but has not been locked, we - // can RBF here to add the contribution. - if self.pending_splice.is_some() { - debug_assert!(false); - self.quiescent_action = - Some(QuiescentAction::Splice { contribution, locktime }); - - return Err(ChannelError::WarnAndDisconnect( - format!( - "Channel {} cannot be spliced as it already has a splice pending", - self.context.channel_id(), - ), - )); - } - let prev_funding_input = self.funding.to_splice_funding_input(); let our_funding_contribution = contribution.net_value(); let funding_feerate_per_kw = contribution.feerate().to_sat_per_kwu() as u32; @@ -13693,6 +13707,11 @@ where our_funding_outputs, }; + if self.pending_splice.is_some() { + let tx_init_rbf = self.send_tx_init_rbf_internal(context); + return Ok(Some(StfuResponse::TxInitRbf(tx_init_rbf))); + } + let splice_init = self.send_splice_init(context); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 10632b74129..64582400d8b 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12632,6 +12632,13 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }); Ok(true) }, + Some(StfuResponse::TxInitRbf(msg)) => { + peer_state.pending_msg_events.push(MessageSendEvent::SendTxInitRbf { + node_id: *counterparty_node_id, + msg, + }); + Ok(true) + }, } } else { let msg = "Peer sent `stfu` for an unfunded channel"; From e29eb29ba5dce6f9ed447c2fb7b3d9fddd299b15 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 20:38:06 -0600 Subject: [PATCH 17/26] Handle tx_ack_rbf on the initiator side After sending tx_init_rbf, the initiator receives tx_ack_rbf from the acceptor. Implement the handler to validate the response and begin interactive transaction construction for the RBF funding transaction. Validation is split into a separate validate_tx_ack_rbf method (taking &self) to prevent state modifications during validation, following the same pattern as validate_splice_ack. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 86 ++++++++++++++++++++++++++++++ lightning/src/ln/channelmanager.rs | 60 +++++++++++++++++++-- 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 6645b4149a4..9f30e3f9a75 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12796,6 +12796,92 @@ where }) } + fn validate_tx_ack_rbf(&self, msg: &msgs::TxAckRbf) -> Result { + let pending_splice = self + .pending_splice + .as_ref() + .ok_or_else(|| ChannelError::Ignore("Channel is not in pending splice".to_owned()))?; + + let funding_negotiation_context = match &pending_splice.funding_negotiation { + Some(FundingNegotiation::AwaitingAck { context, .. }) => context, + Some(FundingNegotiation::ConstructingTransaction { .. }) + | Some(FundingNegotiation::AwaitingSignatures { .. }) => { + return Err(ChannelError::WarnAndDisconnect( + "Got unexpected tx_ack_rbf; funding negotiation already in progress".to_owned(), + )); + }, + None => { + return Err(ChannelError::Ignore( + "Got unexpected tx_ack_rbf; no funding negotiation in progress".to_owned(), + )); + }, + }; + + let our_funding_contribution = funding_negotiation_context.our_funding_contribution; + let their_funding_contribution = match msg.funding_output_contribution { + Some(value) => SignedAmount::from_sat(value), + None => SignedAmount::ZERO, + }; + self.validate_splice_contributions(our_funding_contribution, their_funding_contribution) + .map_err(|e| ChannelError::WarnAndDisconnect(e))?; + + let first_candidate = pending_splice.negotiated_candidates.first().ok_or_else(|| { + ChannelError::WarnAndDisconnect("No negotiated splice candidates for RBF".to_owned()) + })?; + let holder_pubkeys = first_candidate.get_holder_pubkeys().clone(); + let counterparty_funding_pubkey = *first_candidate.counterparty_funding_pubkey(); + + Ok(FundingScope::for_splice( + &self.funding, + &self.context, + our_funding_contribution, + their_funding_contribution, + counterparty_funding_pubkey, + holder_pubkeys, + )) + } + + pub(crate) fn tx_ack_rbf( + &mut self, msg: &msgs::TxAckRbf, entropy_source: &ES, holder_node_id: &PublicKey, + logger: &L, + ) -> Result, ChannelError> { + let rbf_funding = self.validate_tx_ack_rbf(msg)?; + + log_info!( + logger, + "Starting RBF funding negotiation for channel {} after receiving tx_ack_rbf; channel value: {} sats", + self.context.channel_id, + rbf_funding.get_value_satoshis(), + ); + + let pending_splice = + self.pending_splice.as_mut().expect("We should have returned an error earlier!"); + let funding_negotiation_context = + if let Some(FundingNegotiation::AwaitingAck { context, .. }) = + pending_splice.funding_negotiation.take() + { + context + } else { + panic!("We should have returned an error earlier!"); + }; + + let (interactive_tx_constructor, tx_msg_opt) = funding_negotiation_context + .into_interactive_tx_constructor( + &self.context, + &rbf_funding, + entropy_source, + holder_node_id.clone(), + ); + debug_assert!(tx_msg_opt.is_some()); + + pending_splice.funding_negotiation = Some(FundingNegotiation::ConstructingTransaction { + funding: rbf_funding, + interactive_tx_constructor, + }); + + Ok(tx_msg_opt) + } + pub(crate) fn splice_ack( &mut self, msg: &msgs::SpliceAck, entropy_source: &ES, holder_node_id: &PublicKey, logger: &L, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 64582400d8b..1b8beef2bee 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13006,6 +13006,51 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } + fn internal_tx_ack_rbf( + &self, counterparty_node_id: &PublicKey, msg: &msgs::TxAckRbf, + ) -> Result<(), MsgHandleErrInternal> { + let per_peer_state = self.per_peer_state.read().unwrap(); + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + debug_assert!(false); + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) + })?; + let mut peer_state_lock = peer_state_mutex.lock().unwrap(); + let peer_state = &mut *peer_state_lock; + + // Look for the channel + match peer_state.channel_by_id.entry(msg.channel_id) { + hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )), + hash_map::Entry::Occupied(mut chan_entry) => { + if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { + let tx_ack_rbf_res = funded_channel.tx_ack_rbf( + msg, + &self.entropy_source, + &self.get_our_node_id(), + &self.logger, + ); + let tx_msg_opt = + try_channel_entry!(self, peer_state, tx_ack_rbf_res, chan_entry); + if let Some(tx_msg) = tx_msg_opt { + peer_state + .pending_msg_events + .push(tx_msg.into_msg_send_event(counterparty_node_id.clone())); + } + Ok(()) + } else { + try_channel_entry!( + self, + peer_state, + Err(ChannelError::close("Channel is not funded, cannot RBF splice".into())), + chan_entry + ) + } + }, + } + } + fn internal_splice_locked( &self, counterparty_node_id: &PublicKey, msg: &msgs::SpliceLocked, ) -> Result<(), MsgHandleErrInternal> { @@ -16427,11 +16472,16 @@ impl< } fn handle_tx_ack_rbf(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAckRbf) { - let err = Err(MsgHandleErrInternal::send_err_msg_no_close( - "Dual-funded channels not supported".to_owned(), - msg.channel_id.clone(), - )); - let _: Result<(), _> = self.handle_error(err, counterparty_node_id); + let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || { + let res = self.internal_tx_ack_rbf(&counterparty_node_id, msg); + let persist = match &res { + Err(e) if e.closes_channel() => NotifyOption::DoPersist, + Err(_) => NotifyOption::SkipPersistHandleEvents, + Ok(()) => NotifyOption::SkipPersistHandleEvents, + }; + let _ = self.handle_error(res, counterparty_node_id); + persist + }); } fn handle_tx_abort(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAbort) { From b6f81af4e7404ba6f834743404ae0e15b404dd53 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 25 Feb 2026 09:31:01 -0600 Subject: [PATCH 18/26] fixup! Handle tx_ack_rbf on the initiator side Only clear the interactive signing session in `reset_pending_splice_state` when the current funding negotiation is in `AwaitingSignatures`. When an earlier round completed signing and a later RBF round is in `AwaitingAck` or `ConstructingTransaction`, the session belongs to the prior round and must be preserved. Otherwise, disconnecting mid-RBF would destroy the completed prior round's signing session and fire a false debug assertion. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 9f30e3f9a75..88a38fb53ac 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -7098,15 +7098,27 @@ where fn reset_pending_splice_state(&mut self) -> Option { debug_assert!(self.should_reset_pending_splice_state(true)); - debug_assert!( - self.context.interactive_tx_signing_session.is_none() - || !self - .context - .interactive_tx_signing_session - .as_ref() - .expect("We have a pending splice awaiting signatures") - .has_received_commitment_signed() - ); + + // Only clear the signing session if the current round is mid-signing. When an earlier + // round completed signing and a later RBF round is in AwaitingAck or + // ConstructingTransaction, the session belongs to the prior round and must be preserved. + let current_is_awaiting_signatures = self + .pending_splice + .as_ref() + .and_then(|ps| ps.funding_negotiation.as_ref()) + .map(|fn_| matches!(fn_, FundingNegotiation::AwaitingSignatures { .. })) + .unwrap_or(false); + if current_is_awaiting_signatures { + debug_assert!( + self.context.interactive_tx_signing_session.is_none() + || !self + .context + .interactive_tx_signing_session + .as_ref() + .expect("We have a pending splice awaiting signatures") + .has_received_commitment_signed() + ); + } let splice_funding_failed = maybe_create_splice_funding_failed!( self, @@ -7120,7 +7132,9 @@ where } self.context.channel_state.clear_quiescent(); - self.context.interactive_tx_signing_session.take(); + if current_is_awaiting_signatures { + self.context.interactive_tx_signing_session.take(); + } splice_funding_failed } From 49e1730b7a82d47f39150a598349f75ce2375657 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 20:38:52 -0600 Subject: [PATCH 19/26] Test end-to-end RBF splice initiator flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update test_splice_rbf_acceptor_basic to exercise the full initiator flow: rbf_channel → funding_contributed → STFU exchange → tx_init_rbf → tx_ack_rbf → interactive TX → signing → mining → splice_locked. This replaces the previous test that manually constructed tx_init_rbf. Also update test_splice_rbf_insufficient_feerate to verify the 25/24 feerate rule is enforced on both sides: the rbf_channel API rejects insufficient feerates before any messages are sent, and the acceptor rejects them when handling tx_init_rbf from a misbehaving peer. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/splicing_tests.rs | 171 +++++++++++++++++++++++++---- 1 file changed, 148 insertions(+), 23 deletions(-) diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index b3b10cc0b37..bfdd95502cc 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -3778,15 +3778,17 @@ fn reenter_quiescence<'a, 'b, 'c>( #[test] fn test_splice_rbf_acceptor_basic() { - // Test the happy path for accepting an RBF of a pending splice transaction. - // After completing a splice-in, re-enter quiescence and process tx_init_rbf - // from the counterparty, responding with tx_ack_rbf. + // Test the full end-to-end flow for RBF of a pending splice transaction. + // Complete a splice-in, then use rbf_channel API to initiate an RBF attempt + // with a higher feerate, going through the full tx_init_rbf → tx_ack_rbf → + // interactive TX → signing → mining → splice_locked flow. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); let initial_channel_value_sat = 100_000; let (_, _, channel_id, _) = @@ -3795,41 +3797,153 @@ fn test_splice_rbf_acceptor_basic() { let added_value = Amount::from_sat(50_000); provide_utxo_reserves(&nodes, 2, added_value * 2); - // Complete a splice-in from node 0. + // Step 1: Complete a splice-in from node 0. let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); - let (_splice_tx, _new_funding_script) = + // Save the pre-splice funding outpoint before splice_channel modifies the monitor. + let original_funding_outpoint = nodes[0] + .chain_monitor + .chain_monitor + .get_monitor(channel_id) + .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) + .unwrap(); + + let (first_splice_tx, new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); - // Re-enter quiescence for RBF (node 0 initiates). - reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + // Step 2: Provide more UTXO reserves for the RBF attempt. + provide_utxo_reserves(&nodes, 2, added_value * 2); - // Node 0 sends tx_init_rbf with feerate satisfying the 25/24 rule. + // Step 3: Use rbf_channel API to initiate the RBF. // Original feerate was FEERATE_FLOOR_SATS_PER_KW (253). 253 * 25 / 24 = 263.54, so 264 works. - let rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; // ceil(253*25/24) = 264 - let tx_init_rbf = msgs::TxInitRbf { - channel_id, - locktime: 0, - feerate_sat_per_1000_weight: rbf_feerate as u32, - funding_output_contribution: Some(added_value.to_sat() as i64), - }; + let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; // ceil(253*25/24) = 264 + let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); + let funding_template = nodes[0].node.rbf_channel(&channel_id, &node_id_1, rbf_feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template.splice_in_sync(added_value, &wallet).unwrap(); + + // Step 4: funding_contributed stores QuiescentAction::Splice and proposes quiescence. + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, funding_contribution.clone(), None) + .unwrap(); + + // Step 5: STFU exchange. + let stfu_a = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_a); + let stfu_b = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_b); + + // Step 6: Node 0 sends tx_init_rbf (not splice_init, since pending_splice exists). + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.channel_id, channel_id); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, rbf_feerate_sat_per_kwu as u32); + // Step 7: Node 1 handles tx_init_rbf → responds with tx_ack_rbf. nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); - assert_eq!(tx_ack_rbf.channel_id, channel_id); - // Acceptor doesn't contribute funds in the RBF. - assert_eq!(tx_ack_rbf.funding_output_contribution, None); + + // Step 8: Node 0 handles tx_ack_rbf → starts interactive TX construction. + nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf); + + // Step 9: Complete interactive funding negotiation. + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + funding_contribution, + new_funding_script.clone(), + ); + + // Step 10: Sign and broadcast. + let (rbf_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Step 11: Mine and lock. + mine_transaction(&nodes[0], &rbf_tx); + mine_transaction(&nodes[1], &rbf_tx); + + // Lock the RBF splice. We can't use lock_splice_after_blocks directly because the splice + // promotion generates DiscardFunding events for the old (replaced) splice candidate. + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1); + + let splice_locked_b = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + nodes[1].node.handle_splice_locked(node_id_0, &splice_locked_b); + + let mut msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + let splice_locked_a = + if let MessageSendEvent::SendSpliceLocked { msg, .. } = msg_events.remove(0) { + msg + } else { + panic!("Expected SendSpliceLocked, got {:?}", msg_events[0]); + }; + let announcement_sigs_b = + if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) { + msg + } else { + panic!("Expected SendAnnouncementSignatures"); + }; + nodes[0].node.handle_splice_locked(node_id_1, &splice_locked_a); + nodes[0].node.handle_announcement_signatures(node_id_1, &announcement_sigs_b); + + // Expect ChannelReady + DiscardFunding for the old splice candidate on both nodes. + let events_a = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events_a.len(), 2, "{events_a:?}"); + assert!(matches!(events_a[0], Event::ChannelReady { .. })); + assert!(matches!(events_a[1], Event::DiscardFunding { .. })); + check_added_monitors(&nodes[0], 1); + + let events_b = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events_b.len(), 2, "{events_b:?}"); + assert!(matches!(events_b[0], Event::ChannelReady { .. })); + assert!(matches!(events_b[1], Event::DiscardFunding { .. })); + check_added_monitors(&nodes[1], 1); + + // Complete the announcement exchange. + let mut msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) { + nodes[1].node.handle_announcement_signatures(node_id_0, &msg); + } else { + panic!("Expected SendAnnouncementSignatures"); + } + assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); + + let mut msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); + + // Clean up old watched outpoints from the chain source. + // The original channel's funding outpoint and the first (replaced) splice's funding outpoint + // are still being watched but are no longer tracked by the deserialized monitor. + let (orig_outpoint, orig_script) = original_funding_outpoint; + let first_splice_funding_idx = + first_splice_tx.output.iter().position(|o| o.script_pubkey == new_funding_script).unwrap(); + let first_splice_outpoint = + OutPoint { txid: first_splice_tx.compute_txid(), index: first_splice_funding_idx as u16 }; + for node in &nodes { + node.chain_source.remove_watched_txn_and_outputs(orig_outpoint, orig_script.clone()); + node.chain_source + .remove_watched_txn_and_outputs(first_splice_outpoint, new_funding_script.clone()); + } } #[test] fn test_splice_rbf_insufficient_feerate() { - // Test that tx_init_rbf with an insufficient feerate (less than 25/24 of previous) is rejected. + // Test that rbf_channel rejects a feerate that doesn't satisfy the 25/24 rule, and that the + // acceptor also rejects tx_init_rbf with an insufficient feerate from a misbehaving peer. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); let initial_channel_value_sat = 100_000; let (_, _, channel_id, _) = @@ -3843,11 +3957,23 @@ fn test_splice_rbf_insufficient_feerate() { let (_splice_tx, _new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); - // Re-enter quiescence. + // Initiator-side: rbf_channel rejects an insufficient feerate. + // Original feerate was 253. Using exactly 253 should fail since 253 * 24 < 253 * 25. + let same_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let err = nodes[0].node.rbf_channel(&channel_id, &node_id_1, same_feerate).unwrap_err(); + assert_eq!( + err, + APIError::APIMisuseError { + err: format!( + "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", + channel_id, FEERATE_FLOOR_SATS_PER_KW, FEERATE_FLOOR_SATS_PER_KW, + ), + } + ); + + // Acceptor-side: tx_init_rbf with an insufficient feerate is also rejected. reenter_quiescence(&nodes[0], &nodes[1], &channel_id); - // Send tx_init_rbf with feerate that does NOT satisfy the 25/24 rule. - // Original feerate was 253. Using exactly 253 should fail since 253 * 24 < 253 * 25. let tx_init_rbf = msgs::TxInitRbf { channel_id, locktime: 0, @@ -3857,7 +3983,6 @@ fn test_splice_rbf_insufficient_feerate() { nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); - // Should get an error, not a TxAckRbf. let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); assert_eq!(msg_events.len(), 1); match &msg_events[0] { From 5eb112dc61ebc33cdc4bf412909755fc7a9e3e97 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 21:51:37 -0600 Subject: [PATCH 20/26] Allow acceptor contribution to RBF splice via tx_init_rbf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the tx_init_rbf acceptor always contributed zero to the RBF transaction. This is incorrect when both parties try to RBF simultaneously and one loses the quiescence tie-breaker — the loser becomes the acceptor but still has a pending QuiescentAction::Splice with inputs/outputs that should be included in the RBF transaction. Consume the acceptor's QuiescentAction in the tx_init_rbf handler, just as is already done in the splice_init handler, and report the contribution in the TxAckRbf response. --- lightning/src/ln/channel.rs | 44 ++- lightning/src/ln/splicing_tests.rs | 499 +++++++++++++++++++++++++++++ 2 files changed, 537 insertions(+), 6 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 88a38fb53ac..79fccfe49f5 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12762,9 +12762,37 @@ where &mut self, msg: &msgs::TxInitRbf, entropy_source: &ES, holder_node_id: &PublicKey, fee_estimator: &LowerBoundedFeeEstimator, logger: &L, ) -> Result { - let our_funding_contribution = SignedAmount::ZERO; - let rbf_funding = - self.validate_tx_init_rbf(msg, our_funding_contribution, fee_estimator)?; + let feerate = FeeRate::from_sat_per_kwu(msg.feerate_sat_per_1000_weight as u64); + let our_funding_contribution = self.queued_funding_contribution().and_then(|c| { + c.net_value_for_acceptor_at_feerate(feerate) + .map_err(|e| { + log_info!( + logger, + "Cannot accommodate initiator's feerate for channel {}: {}; \ + proceeding without contribution", + self.context.channel_id(), + e, + ); + }) + .ok() + }); + + let rbf_funding = self.validate_tx_init_rbf( + msg, + our_funding_contribution.unwrap_or(SignedAmount::ZERO), + fee_estimator, + )?; + + let (our_funding_inputs, our_funding_outputs) = if our_funding_contribution.is_some() { + self.take_queued_funding_contribution() + .expect("queued_funding_contribution was Some") + .for_acceptor_at_feerate(feerate) + .expect("feerate compatibility already checked") + .into_tx_parts() + } else { + Default::default() + }; + let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO); log_info!( logger, @@ -12780,8 +12808,8 @@ where funding_tx_locktime: LockTime::from_consensus(msg.locktime), funding_feerate_sat_per_1000_weight: msg.feerate_sat_per_1000_weight, shared_funding_input: Some(prev_funding_input), - our_funding_inputs: Vec::new(), - our_funding_outputs: Vec::new(), + our_funding_inputs, + our_funding_outputs, }; let (interactive_tx_constructor, first_message) = funding_negotiation_context @@ -12806,7 +12834,11 @@ where Ok(msgs::TxAckRbf { channel_id: self.context.channel_id, - funding_output_contribution: None, + funding_output_contribution: if our_funding_contribution != SignedAmount::ZERO { + Some(our_funding_contribution.to_sat()) + } else { + None + }, }) } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index bfdd95502cc..d59cdb855aa 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -4296,3 +4296,502 @@ fn test_splice_rbf_zeroconf_rejected() { _ => panic!("Expected HandleError, got {:?}", msg_events[0]), } } + +#[test] +fn test_splice_rbf_both_contribute_tiebreak() { + do_test_splice_rbf_both_contribute_tiebreak(None, None); +} + +#[test] +fn test_splice_rbf_tiebreak_higher_feerate() { + // Node 0 (winner) uses a higher feerate than node 1 (loser). Node 1's change output is + // adjusted (reduced) to accommodate the higher feerate. Negotiation succeeds. + let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; + do_test_splice_rbf_both_contribute_tiebreak( + Some(FeeRate::from_sat_per_kwu(min_rbf_feerate * 3)), + Some(FeeRate::from_sat_per_kwu(min_rbf_feerate)), + ); +} + +#[test] +fn test_splice_rbf_tiebreak_lower_feerate() { + // Node 0 (winner) uses a lower feerate than node 1 (loser). Node 1's change output increases + // because the acceptor's fair fee decreases. Negotiation succeeds. + let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; + do_test_splice_rbf_both_contribute_tiebreak( + Some(FeeRate::from_sat_per_kwu(min_rbf_feerate)), + Some(FeeRate::from_sat_per_kwu(min_rbf_feerate * 3)), + ); +} + +/// Runs the tie-breaker test with optional per-node feerates. +/// If `node_0_feerate` or `node_1_feerate` is None, both use the same default RBF feerate. +fn do_test_splice_rbf_both_contribute_tiebreak( + node_0_feerate: Option, node_1_feerate: Option, +) { + // Test where both parties call rbf_channel + funding_contributed, both send STFU, one wins + // the quiescence tie-break (node 0, the outbound channel funder). The loser (node 1) becomes + // the acceptor and its stored QuiescentAction::Splice is consumed by the tx_init_rbf handler, + // contributing its inputs/outputs to the RBF transaction. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Step 1: Complete an initial splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let original_funding_outpoint = nodes[0] + .chain_monitor + .chain_monitor + .get_monitor(channel_id) + .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) + .unwrap(); + let (first_splice_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Step 2: Provide more UTXOs for both nodes' RBF attempts. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Step 3: Both nodes initiate RBF, possibly at different feerates. + let default_rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; + let default_rbf_feerate = FeeRate::from_sat_per_kwu(default_rbf_feerate_sat_per_kwu); + let rbf_feerate_0 = node_0_feerate.unwrap_or(default_rbf_feerate); + let rbf_feerate_1 = node_1_feerate.unwrap_or(default_rbf_feerate); + + // Node 0 calls rbf_channel + funding_contributed. + let funding_template_0 = + nodes[0].node.rbf_channel(&channel_id, &node_id_1, rbf_feerate_0).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(added_value, &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + // Node 1 calls rbf_channel + funding_contributed. + let funding_template_1 = + nodes[1].node.rbf_channel(&channel_id, &node_id_0, rbf_feerate_1).unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Capture change output values before the tiebreak. + let node_0_change = node_0_funding_contribution + .change_output() + .expect("splice-in should have a change output") + .clone(); + let node_1_change = node_1_funding_contribution + .change_output() + .expect("splice-in should have a change output") + .clone(); + + // Step 4: Both nodes sent STFU (both have awaiting_quiescence set). + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + assert!(stfu_0.initiator); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + assert!(stfu_1.initiator); + + // Step 5: Exchange STFUs. Node 0 is the outbound channel funder and wins the tie-break. + // Node 1 handles node 0's STFU first — it already sent its own STFU (local_stfu_sent is set), + // so this goes through the tie-break path. Node 1 loses (is_outbound = false) and becomes the + // acceptor. Its quiescent_action is preserved for the tx_init_rbf handler. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + // Node 0 handles node 1's STFU — it already sent its own STFU, so tie-break again. + // Node 0 wins (is_outbound = true), consumes its quiescent_action, and sends tx_init_rbf. + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Step 6: Node 0 sends tx_init_rbf. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.channel_id, channel_id); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, rbf_feerate_0.to_sat_per_kwu() as u32); + + // Step 7: Node 1 handles tx_init_rbf — its quiescent_action is consumed, providing its + // inputs/outputs (adjusted for node 0's feerate). Responds with tx_ack_rbf. + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); + assert_eq!(tx_ack_rbf.channel_id, channel_id); + assert!( + tx_ack_rbf.funding_output_contribution.is_some(), + "Acceptor should contribute to the RBF splice" + ); + + // Step 8: Node 0 handles tx_ack_rbf. + nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf); + + // Step 9: Complete interactive funding negotiation with both parties' inputs/outputs. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + Some(node_1_funding_contribution), + new_funding_script.clone(), + ); + + // Step 10: Sign (acceptor has contribution) and broadcast. + let (rbf_tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + assert!(splice_locked.is_none()); + + // The initiator's change output should remain unchanged (no feerate adjustment). + let initiator_change_in_tx = rbf_tx + .output + .iter() + .find(|o| o.script_pubkey == node_0_change.script_pubkey) + .expect("Initiator's change output should be in the RBF transaction"); + assert_eq!( + initiator_change_in_tx.value, node_0_change.value, + "Initiator's change output should remain unchanged", + ); + + // The acceptor's change output should be adjusted based on the feerate difference. + let acceptor_change_in_tx = rbf_tx + .output + .iter() + .find(|o| o.script_pubkey == node_1_change.script_pubkey) + .expect("Acceptor's change output should be in the RBF transaction"); + if rbf_feerate_0 <= rbf_feerate_1 { + // Initiator's feerate <= acceptor's original: the acceptor's change increases because + // is_initiator=false has lower weight, and the feerate is the same or lower. + assert!( + acceptor_change_in_tx.value > node_1_change.value, + "Acceptor's change should increase when initiator feerate ({}) <= acceptor feerate \ + ({}): adjusted {} vs original {}", + rbf_feerate_0.to_sat_per_kwu(), + rbf_feerate_1.to_sat_per_kwu(), + acceptor_change_in_tx.value, + node_1_change.value, + ); + } else { + // Initiator's feerate > acceptor's original: the higher feerate more than compensates + // for the lower weight, so the acceptor's change decreases. + assert!( + acceptor_change_in_tx.value < node_1_change.value, + "Acceptor's change should decrease when initiator feerate ({}) > acceptor feerate \ + ({}): adjusted {} vs original {}", + rbf_feerate_0.to_sat_per_kwu(), + rbf_feerate_1.to_sat_per_kwu(), + acceptor_change_in_tx.value, + node_1_change.value, + ); + } + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Step 11: Mine and lock. + mine_transaction(&nodes[0], &rbf_tx); + mine_transaction(&nodes[1], &rbf_tx); + + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1); + + let splice_locked_b = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + nodes[1].node.handle_splice_locked(node_id_0, &splice_locked_b); + + let mut msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + let splice_locked_a = + if let MessageSendEvent::SendSpliceLocked { msg, .. } = msg_events.remove(0) { + msg + } else { + panic!("Expected SendSpliceLocked, got {:?}", msg_events[0]); + }; + let announcement_sigs_b = + if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) { + msg + } else { + panic!("Expected SendAnnouncementSignatures"); + }; + nodes[0].node.handle_splice_locked(node_id_1, &splice_locked_a); + nodes[0].node.handle_announcement_signatures(node_id_1, &announcement_sigs_b); + + // Expect ChannelReady + DiscardFunding for the old splice candidate on both nodes. + let events_a = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events_a.len(), 2, "{events_a:?}"); + assert!(matches!(events_a[0], Event::ChannelReady { .. })); + assert!(matches!(events_a[1], Event::DiscardFunding { .. })); + check_added_monitors(&nodes[0], 1); + + let events_b = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events_b.len(), 2, "{events_b:?}"); + assert!(matches!(events_b[0], Event::ChannelReady { .. })); + assert!(matches!(events_b[1], Event::DiscardFunding { .. })); + check_added_monitors(&nodes[1], 1); + + // Complete the announcement exchange. + let mut msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) { + nodes[1].node.handle_announcement_signatures(node_id_0, &msg); + } else { + panic!("Expected SendAnnouncementSignatures"); + } + assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); + + let mut msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); + + // Clean up old watched outpoints from the chain source. + let (orig_outpoint, orig_script) = original_funding_outpoint; + let first_splice_funding_idx = + first_splice_tx.output.iter().position(|o| o.script_pubkey == new_funding_script).unwrap(); + let first_splice_outpoint = + OutPoint { txid: first_splice_tx.compute_txid(), index: first_splice_funding_idx as u16 }; + for node in &nodes { + node.chain_source.remove_watched_txn_and_outputs(orig_outpoint, orig_script.clone()); + node.chain_source + .remove_watched_txn_and_outputs(first_splice_outpoint, new_funding_script.clone()); + } +} + +#[test] +fn test_splice_rbf_tiebreak_feerate_too_high() { + // Node 0 (winner) uses a feerate high enough that node 1's (loser) contribution cannot + // cover the fees. Node 1 proceeds without its contribution (QuiescentAction is preserved + // for a future splice). The RBF completes with only node 0's inputs/outputs. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete an initial splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let original_funding_outpoint = nodes[0] + .chain_monitor + .chain_monitor + .get_monitor(channel_id) + .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) + .unwrap(); + let (first_splice_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Provide more UTXOs for both nodes' RBF attempts. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Node 0 uses a high feerate (20,000 sat/kwu). Node 1 uses the minimum RBF feerate but + // splices in a large amount (95,000 sats from a 100,000 sat UTXO), leaving very little + // change/fee budget. Node 1's budget (~5,000 sats) can't cover the acceptor's fair fee + // at 20,000 sat/kwu (~5,440 sats without change output), so adjust_for_feerate fails. + let high_feerate = FeeRate::from_sat_per_kwu(20_000); + let min_rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; + let min_rbf_feerate = FeeRate::from_sat_per_kwu(min_rbf_feerate_sat_per_kwu); + + let node_1_added_value = Amount::from_sat(95_000); + + let funding_template_0 = + nodes[0].node.rbf_channel(&channel_id, &node_id_1, high_feerate).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(added_value, &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + let funding_template_1 = + nodes[1].node.rbf_channel(&channel_id, &node_id_0, min_rbf_feerate).unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(node_1_added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Both sent STFU. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + // Tie-break: node 0 wins. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Node 0 sends tx_init_rbf at 20,000 sat/kwu. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, high_feerate.to_sat_per_kwu() as u32); + + // Node 1 handles tx_init_rbf — adjust_for_feerate fails because node 1's contribution + // can't cover fees at 20,000 sat/kwu. Node 1 proceeds without its contribution. + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); + assert_eq!(tx_ack_rbf.channel_id, channel_id); + assert!( + tx_ack_rbf.funding_output_contribution.is_none(), + "Acceptor should not contribute when feerate adjustment fails" + ); + + // Node 0 handles tx_ack_rbf. + nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf); + + // Complete interactive funding negotiation with only node 0's contribution. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + None, + new_funding_script.clone(), + ); + + // Sign (acceptor has no contribution) and broadcast. + let (rbf_tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, false); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Mine and lock. + mine_transaction(&nodes[0], &rbf_tx); + mine_transaction(&nodes[1], &rbf_tx); + + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1); + + let splice_locked_b = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + nodes[1].node.handle_splice_locked(node_id_0, &splice_locked_b); + + // Node 1's QuiescentAction was preserved, so after splice_locked it re-initiates + // quiescence to retry its contribution in a future splice. + let mut msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 3, "{msg_events:?}"); + let splice_locked_a = + if let MessageSendEvent::SendSpliceLocked { msg, .. } = msg_events.remove(0) { + msg + } else { + panic!("Expected SendSpliceLocked, got {:?}", msg_events[0]); + }; + let announcement_sigs_b = + if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) { + msg + } else { + panic!("Expected SendAnnouncementSignatures"); + }; + let stfu_1 = if let MessageSendEvent::SendStfu { msg, .. } = msg_events.remove(0) { + msg + } else { + panic!("Expected SendStfu, got {:?}", msg_events[0]); + }; + assert!(stfu_1.initiator); + + nodes[0].node.handle_splice_locked(node_id_1, &splice_locked_a); + nodes[0].node.handle_announcement_signatures(node_id_1, &announcement_sigs_b); + + // Expect ChannelReady + DiscardFunding for the old splice candidate on both nodes. + let events_a = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events_a.len(), 2, "{events_a:?}"); + assert!(matches!(events_a[0], Event::ChannelReady { .. })); + assert!(matches!(events_a[1], Event::DiscardFunding { .. })); + check_added_monitors(&nodes[0], 1); + + let events_b = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events_b.len(), 2, "{events_b:?}"); + assert!(matches!(events_b[0], Event::ChannelReady { .. })); + assert!(matches!(events_b[1], Event::DiscardFunding { .. })); + check_added_monitors(&nodes[1], 1); + + // Complete the announcement exchange. + let mut msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) { + nodes[1].node.handle_announcement_signatures(node_id_0, &msg); + } else { + panic!("Expected SendAnnouncementSignatures"); + } + assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); + + let mut msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); + + // Clean up old watched outpoints. + let (orig_outpoint, orig_script) = original_funding_outpoint; + let first_splice_funding_idx = + first_splice_tx.output.iter().position(|o| o.script_pubkey == new_funding_script).unwrap(); + let first_splice_outpoint = + OutPoint { txid: first_splice_tx.compute_txid(), index: first_splice_funding_idx as u16 }; + for node in &nodes { + node.chain_source.remove_watched_txn_and_outputs(orig_outpoint, orig_script.clone()); + node.chain_source + .remove_watched_txn_and_outputs(first_splice_outpoint, new_funding_script.clone()); + } + + // === Part 2: Node 1's preserved QuiescentAction leads to a new splice === + // + // After splice_locked, pending_splice is None. So when stfu() consumes the QuiescentAction, + // it sends SpliceInit (not TxInitRbf), starting a brand new splice. + + // Node 0 receives node 1's STFU and responds with its own STFU. + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + + // Node 1 receives STFU → quiescence established → node 1 is the initiator → sends SpliceInit. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + let splice_init = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceInit, node_id_0); + + // Node 0 handles SpliceInit → sends SpliceAck. + nodes[0].node.handle_splice_init(node_id_1, &splice_init); + let splice_ack = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceAck, node_id_1); + + // Node 1 handles SpliceAck → starts interactive tx construction. + nodes[1].node.handle_splice_ack(node_id_0, &splice_ack); + + // Compute the new funding script from the splice pubkeys. + let new_funding_script_2 = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + // Complete interactive funding negotiation with node 1 as initiator (only node 1 contributes). + complete_interactive_funding_negotiation( + &nodes[1], + &nodes[0], + channel_id, + node_1_funding_contribution, + new_funding_script_2, + ); + + // Sign (no acceptor contribution) and broadcast. + let (new_splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[1], &nodes[0], false); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[1], &node_id_0); + expect_splice_pending_event(&nodes[0], &node_id_1); + + // Mine and lock. + mine_transaction(&nodes[1], &new_splice_tx); + mine_transaction(&nodes[0], &new_splice_tx); + + lock_splice_after_blocks(&nodes[1], &nodes[0], ANTI_REORG_DELAY - 1); +} From 68bef73b748052dc7fecae099522f8965e5520d4 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 23 Feb 2026 15:17:25 -0600 Subject: [PATCH 21/26] Preserve our funding contribution across counterparty RBF attempts When the counterparty initiates an RBF and we have no new contribution queued via QuiescentAction, we must re-use our prior contribution so that our splice is not lost. Track contributions in a new field on PendingFunding so the last entry can be re-used in this scenario. Each entry stores the feerate-adjusted version because that reflects what was actually negotiated and allows correct feerate re-adjustment on subsequent RBFs. Only explicitly provided contributions (from a QuiescentAction) append to the vec. Re-used contributions are replaced in-place with the version adjusted for the new feerate so they remain accurate for further RBF rounds, without growing the vec. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 102 +++++++++++++++++++++++++++++++----- 1 file changed, 88 insertions(+), 14 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 79fccfe49f5..219aac2fe4d 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2915,6 +2915,13 @@ struct PendingFunding { /// The feerate used in the last successfully negotiated funding transaction. /// Used for validating the 25/24 feerate increase rule on RBF attempts. last_funding_feerate_sat_per_1000_weight: Option, + + /// The funding contributions from all explicit splice/RBF attempts on this channel. + /// Each entry reflects the feerate-adjusted contribution that was actually used in that + /// negotiation. The last entry is re-used when the counterparty initiates an RBF and we + /// have no pending `QuiescentAction`. When re-used as acceptor, the last entry is replaced + /// with the version adjusted for the new feerate. + contributions: Vec, } impl_writeable_tlv_based!(PendingFunding, { @@ -2923,6 +2930,7 @@ impl_writeable_tlv_based!(PendingFunding, { (5, sent_funding_txid, option), (7, received_funding_txid, option), (9, last_funding_feerate_sat_per_1000_weight, option), + (11, contributions, optional_vec), }); #[derive(Debug)] @@ -12355,6 +12363,7 @@ where sent_funding_txid: None, received_funding_txid: None, last_funding_feerate_sat_per_1000_weight: Some(funding_feerate_per_kw), + contributions: vec![], }); msgs::SpliceInit { @@ -12598,15 +12607,19 @@ where let splice_funding = self.validate_splice_init(msg, our_funding_contribution.unwrap_or(SignedAmount::ZERO))?; - let (our_funding_inputs, our_funding_outputs) = if our_funding_contribution.is_some() { - self.take_queued_funding_contribution() - .expect("queued_funding_contribution was Some") - .for_acceptor_at_feerate(feerate) - .expect("feerate compatibility already checked") - .into_tx_parts() - } else { - Default::default() - }; + // Adjust for the feerate and clone so we can store it for future RBF re-use. + let (adjusted_contribution, our_funding_inputs, our_funding_outputs) = + if our_funding_contribution.is_some() { + let adjusted_contribution = self + .take_queued_funding_contribution() + .expect("queued_funding_contribution was Some") + .for_acceptor_at_feerate(feerate) + .expect("feerate compatibility already checked"); + let (inputs, outputs) = adjusted_contribution.clone().into_tx_parts(); + (Some(adjusted_contribution), inputs, outputs) + } else { + (None, Default::default(), Default::default()) + }; let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO); log_info!( @@ -12647,6 +12660,7 @@ where received_funding_txid: None, sent_funding_txid: None, last_funding_feerate_sat_per_1000_weight: Some(msg.funding_feerate_per_kw), + contributions: adjusted_contribution.into_iter().collect(), }); Ok(msgs::SpliceAck { @@ -12763,7 +12777,9 @@ where fee_estimator: &LowerBoundedFeeEstimator, logger: &L, ) -> Result { let feerate = FeeRate::from_sat_per_kwu(msg.feerate_sat_per_1000_weight as u64); - let our_funding_contribution = self.queued_funding_contribution().and_then(|c| { + + // Try queued contribution from QuiescentAction (tiebreak scenario). + let queued_net_value = self.queued_funding_contribution().and_then(|c| { c.net_value_for_acceptor_at_feerate(feerate) .map_err(|e| { log_info!( @@ -12777,21 +12793,72 @@ where .ok() }); + // If no queued contribution, try prior contribution from previous negotiation. + // Failing here means the RBF would erase our splice — reject it. + let prior_net_value = if queued_net_value.is_none() { + match self + .pending_splice + .as_ref() + .and_then(|pending_splice| pending_splice.contributions.last()) + { + Some(prior) => { + Some(prior.net_value_for_acceptor_at_feerate(feerate).map_err(|e| { + ChannelError::WarnAndDisconnect(format!( + "Channel {} cannot accommodate RBF feerate for our prior \ + contribution: {}", + self.context.channel_id(), + e + )) + })?) + }, + None => None, + } + } else { + None + }; + + let our_funding_contribution = queued_net_value.or(prior_net_value); + let rbf_funding = self.validate_tx_init_rbf( msg, our_funding_contribution.unwrap_or(SignedAmount::ZERO), fee_estimator, )?; - let (our_funding_inputs, our_funding_outputs) = if our_funding_contribution.is_some() { - self.take_queued_funding_contribution() + // Consume the appropriate contribution source. + let (our_funding_inputs, our_funding_outputs) = if queued_net_value.is_some() { + let adjusted_contribution = self + .take_queued_funding_contribution() .expect("queued_funding_contribution was Some") .for_acceptor_at_feerate(feerate) - .expect("feerate compatibility already checked") - .into_tx_parts() + .expect("feerate compatibility already checked"); + self.pending_splice + .as_mut() + .expect("pending_splice is Some") + .contributions + .push(adjusted_contribution.clone()); + adjusted_contribution.into_tx_parts() + } else if prior_net_value.is_some() { + let prior_contribution = self + .pending_splice + .as_mut() + .expect("pending_splice is Some") + .contributions + .pop() + .expect("prior_net_value was Some"); + let adjusted_contribution = prior_contribution + .for_acceptor_at_feerate(feerate) + .expect("feerate compatibility already checked"); + self.pending_splice + .as_mut() + .expect("pending_splice is Some") + .contributions + .push(adjusted_contribution.clone()); + adjusted_contribution.into_tx_parts() } else { Default::default() }; + let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO); log_info!( @@ -13824,6 +13891,7 @@ where )); }, Some(QuiescentAction::Splice { contribution, locktime }) => { + let prior_contribution = contribution.clone(); let prev_funding_input = self.funding.to_splice_funding_input(); let our_funding_contribution = contribution.net_value(); let funding_feerate_per_kw = contribution.feerate().to_sat_per_kwu() as u32; @@ -13841,10 +13909,16 @@ where if self.pending_splice.is_some() { let tx_init_rbf = self.send_tx_init_rbf_internal(context); + debug_assert!(self.pending_splice.is_some()); + self.pending_splice.as_mut().unwrap() + .contributions.push(prior_contribution); return Ok(Some(StfuResponse::TxInitRbf(tx_init_rbf))); } let splice_init = self.send_splice_init(context); + debug_assert!(self.pending_splice.is_some()); + self.pending_splice.as_mut().unwrap() + .contributions.push(prior_contribution); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, #[cfg(any(test, fuzzing, feature = "_test_utils"))] From 3374bab96149835bae46c72a384ccde14d2c2ae0 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 23 Feb 2026 16:08:45 -0600 Subject: [PATCH 22/26] Add tests for re-using prior contribution on counterparty RBF Add test_splice_rbf_acceptor_recontributes to verify that when the counterparty initiates an RBF and we have no new QuiescentAction queued, our prior contribution is automatically re-used so the splice is preserved. Add test_splice_rbf_recontributes_feerate_too_high to verify that when the counterparty RBFs at a feerate too high for our prior contribution to cover, the RBF is rejected rather than proceeding without our contribution. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/splicing_tests.rs | 336 +++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index d59cdb855aa..7d92cf51f63 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -4795,3 +4795,339 @@ fn test_splice_rbf_tiebreak_feerate_too_high() { lock_splice_after_blocks(&nodes[1], &nodes[0], ANTI_REORG_DELAY - 1); } + +#[test] +fn test_splice_rbf_acceptor_recontributes() { + // When the counterparty RBFs a splice and we have no pending QuiescentAction, + // our prior contribution should be automatically re-used. This tests the scenario: + // 1. Both nodes contribute to a splice (tiebreak: node 0 wins). + // 2. Only node 0 initiates an RBF — node 1 has no QuiescentAction. + // 3. Node 1 should re-contribute its prior inputs/outputs via our_prior_contribution. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + // Step 1: Both nodes initiate a splice at floor feerate. + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + + let funding_template_0 = + nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(added_value, &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + let funding_template_1 = + nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate).unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Step 2: Both send STFU; tiebreak: node 0 wins. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Step 3: Node 0 sends SpliceInit, node 1 handles as acceptor (QuiescentAction consumed). + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert_ne!(splice_ack.funding_contribution_satoshis, 0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + // Complete interactive funding with both contributions. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + Some(node_1_funding_contribution.clone()), + new_funding_script.clone(), + ); + + let (first_splice_tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + assert!(splice_locked.is_none()); + + let original_funding_outpoint = nodes[0] + .chain_monitor + .chain_monitor + .get_monitor(channel_id) + .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) + .unwrap(); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Step 4: Provide new UTXOs for node 0's RBF (node 1 does NOT initiate RBF). + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Step 5: Only node 0 calls rbf_channel + funding_contributed. + let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; + let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); + let funding_template = nodes[0].node.rbf_channel(&channel_id, &node_id_1, rbf_feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let rbf_funding_contribution = funding_template.splice_in_sync(added_value, &wallet).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, rbf_funding_contribution.clone(), None) + .unwrap(); + + // Step 6: STFU exchange — node 0 initiates, node 1 responds (no QuiescentAction). + let stfu_a = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_a); + let stfu_b = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_b); + + // Step 7: Node 0 sends tx_init_rbf. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.channel_id, channel_id); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, rbf_feerate_sat_per_kwu as u32); + + // Step 8: Node 1 handles tx_init_rbf — should use our_prior_contribution. + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); + assert_eq!(tx_ack_rbf.channel_id, channel_id); + assert!( + tx_ack_rbf.funding_output_contribution.is_some(), + "Acceptor should re-contribute via our_prior_contribution" + ); + + // Step 9: Node 0 handles tx_ack_rbf. + nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf); + + // Step 10: Complete interactive funding with both contributions. + // Node 1's prior contribution is re-used — pass a clone for matching. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + rbf_funding_contribution, + Some(node_1_funding_contribution), + new_funding_script.clone(), + ); + + // Step 11: Sign (acceptor has contribution) and broadcast. + let (rbf_tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Step 12: Mine and lock. + mine_transaction(&nodes[0], &rbf_tx); + mine_transaction(&nodes[1], &rbf_tx); + + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1); + + let splice_locked_b = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + nodes[1].node.handle_splice_locked(node_id_0, &splice_locked_b); + + let mut msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + let splice_locked_a = + if let MessageSendEvent::SendSpliceLocked { msg, .. } = msg_events.remove(0) { + msg + } else { + panic!("Expected SendSpliceLocked, got {:?}", msg_events[0]); + }; + let announcement_sigs_b = + if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) { + msg + } else { + panic!("Expected SendAnnouncementSignatures"); + }; + nodes[0].node.handle_splice_locked(node_id_1, &splice_locked_a); + nodes[0].node.handle_announcement_signatures(node_id_1, &announcement_sigs_b); + + let events_a = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events_a.len(), 2, "{events_a:?}"); + assert!(matches!(events_a[0], Event::ChannelReady { .. })); + assert!(matches!(events_a[1], Event::DiscardFunding { .. })); + check_added_monitors(&nodes[0], 1); + + let events_b = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events_b.len(), 2, "{events_b:?}"); + assert!(matches!(events_b[0], Event::ChannelReady { .. })); + assert!(matches!(events_b[1], Event::DiscardFunding { .. })); + check_added_monitors(&nodes[1], 1); + + let mut msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) { + nodes[1].node.handle_announcement_signatures(node_id_0, &msg); + } else { + panic!("Expected SendAnnouncementSignatures"); + } + assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); + + let mut msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); + + // Clean up old watched outpoints. + let (orig_outpoint, orig_script) = original_funding_outpoint; + let first_splice_funding_idx = + first_splice_tx.output.iter().position(|o| o.script_pubkey == new_funding_script).unwrap(); + let first_splice_outpoint = + OutPoint { txid: first_splice_tx.compute_txid(), index: first_splice_funding_idx as u16 }; + for node in &nodes { + node.chain_source.remove_watched_txn_and_outputs(orig_outpoint, orig_script.clone()); + node.chain_source + .remove_watched_txn_and_outputs(first_splice_outpoint, new_funding_script.clone()); + } +} + +#[test] +fn test_splice_rbf_recontributes_feerate_too_high() { + // When the counterparty RBFs at a feerate too high for our prior contribution, + // we should reject the RBF rather than proceeding without our contribution. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + // Step 1: Both nodes initiate a splice. Node 0 at floor feerate, node 1 splices in 95k + // from a 100k UTXO (tight budget: ~5k for change/fees). + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + + let funding_template_0 = + nodes[0].node.splice_channel(&channel_id, &node_id_1, floor_feerate).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(Amount::from_sat(50_000), &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + let node_1_added_value = Amount::from_sat(95_000); + let funding_template_1 = + nodes[1].node.splice_channel(&channel_id, &node_id_0, floor_feerate).unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(node_1_added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Step 2: Both send STFU; tiebreak: node 0 wins. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Step 3: Complete the initial splice with both contributing. + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert_ne!(splice_ack.funding_contribution_satoshis, 0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + Some(node_1_funding_contribution), + new_funding_script.clone(), + ); + + let (_first_splice_tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Step 4: Provide new UTXOs. Node 0 initiates RBF at 20,000 sat/kwu. + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + let high_feerate = FeeRate::from_sat_per_kwu(20_000); + let funding_template = + nodes[0].node.rbf_channel(&channel_id, &node_id_1, high_feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let rbf_funding_contribution = + funding_template.splice_in_sync(Amount::from_sat(50_000), &wallet).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, rbf_funding_contribution.clone(), None) + .unwrap(); + + // Step 5: STFU exchange. + let stfu_a = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_a); + let stfu_b = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_b); + + // Step 6: Node 0 sends tx_init_rbf at 20,000 sat/kwu. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, high_feerate.to_sat_per_kwu() as u32); + + // Step 7: Node 1's prior contribution (95k from 100k UTXO) can't cover fees at 20k sat/kwu. + // Should reject with WarnAndDisconnect rather than proceeding without contribution. + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + match &msg_events[0] { + MessageSendEvent::HandleError { + action: msgs::ErrorAction::DisconnectPeerWithWarning { msg }, + .. + } => { + assert!( + msg.data.contains("cannot accommodate RBF feerate"), + "Unexpected warning: {}", + msg.data + ); + }, + other => panic!("Expected HandleError/DisconnectPeerWithWarning, got {:?}", other), + } +} From 70a727881516162ec6027e6db50d50c5d30c22c5 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 23 Feb 2026 16:43:01 -0600 Subject: [PATCH 23/26] Add test for sequential RBF splice attempts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test_splice_rbf_sequential that exercises three consecutive RBF rounds on the same splice (initial → RBF #1 → RBF #2) to verify: - Each round requires the 25/24 feerate increase (253 → 264 → 275) - DiscardFunding events reference the correct funding txid from each replaced candidate - The final RBF splice can be mined and splice_locked successfully Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/splicing_tests.rs | 213 +++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 7d92cf51f63..488915c9c95 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -5131,3 +5131,216 @@ fn test_splice_rbf_recontributes_feerate_too_high() { other => panic!("Expected HandleError/DisconnectPeerWithWarning, got {:?}", other), } } + +#[test] +fn test_splice_rbf_sequential() { + // Three consecutive RBF rounds on the same splice (initial → RBF #1 → RBF #2). + // Node 0 is the quiescence initiator; node 1 is the acceptor with no contribution. + // Verifies: + // - Each round satisfies the 25/24 feerate rule + // - DiscardFunding events reference the correct txids from previous rounds + // - The final RBF can be mined and splice_locked successfully + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Save the pre-splice funding outpoint. + let original_funding_outpoint = nodes[0] + .chain_monitor + .chain_monitor + .get_monitor(channel_id) + .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) + .unwrap(); + + // --- Round 0: Initial splice-in from node 0 at floor feerate (253). --- + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (splice_tx_0, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Feerate progression: 253 → ceil(253*25/24) = 264 → ceil(264*25/24) = 275 + let feerate_1_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; // 264 + let feerate_2_sat_per_kwu = (feerate_1_sat_per_kwu * 25 + 23) / 24; // 275 + + // --- Round 1: RBF #1 at feerate 264. --- + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let rbf_feerate_1 = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu); + let funding_template = + nodes[0].node.rbf_channel(&channel_id, &node_id_1, rbf_feerate_1).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution_1 = funding_template.splice_in_sync(added_value, &wallet).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, funding_contribution_1.clone(), None) + .unwrap(); + + let stfu_a = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_a); + let stfu_b = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_b); + + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, feerate_1_sat_per_kwu as u32); + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); + nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf); + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + funding_contribution_1, + new_funding_script.clone(), + ); + let (splice_tx_1, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // --- Round 2: RBF #2 at feerate 275. --- + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let rbf_feerate_2 = FeeRate::from_sat_per_kwu(feerate_2_sat_per_kwu); + let funding_template = + nodes[0].node.rbf_channel(&channel_id, &node_id_1, rbf_feerate_2).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution_2 = funding_template.splice_in_sync(added_value, &wallet).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, funding_contribution_2.clone(), None) + .unwrap(); + + let stfu_a = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_a); + let stfu_b = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_b); + + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, feerate_2_sat_per_kwu as u32); + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); + nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf); + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + funding_contribution_2, + new_funding_script.clone(), + ); + let (rbf_tx_final, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // --- Mine and lock the final RBF. --- + mine_transaction(&nodes[0], &rbf_tx_final); + mine_transaction(&nodes[1], &rbf_tx_final); + + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1); + + let splice_locked_b = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + nodes[1].node.handle_splice_locked(node_id_0, &splice_locked_b); + + let mut msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + let splice_locked_a = + if let MessageSendEvent::SendSpliceLocked { msg, .. } = msg_events.remove(0) { + msg + } else { + panic!("Expected SendSpliceLocked, got {:?}", msg_events[0]); + }; + let announcement_sigs_b = + if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) { + msg + } else { + panic!("Expected SendAnnouncementSignatures"); + }; + nodes[0].node.handle_splice_locked(node_id_1, &splice_locked_a); + nodes[0].node.handle_announcement_signatures(node_id_1, &announcement_sigs_b); + + // --- Verify DiscardFunding events for both replaced candidates. --- + let splice_tx_0_txid = splice_tx_0.compute_txid(); + let splice_tx_1_txid = splice_tx_1.compute_txid(); + + // Node 0 (initiator): ChannelReady + 2 DiscardFunding. + let events_a = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events_a.len(), 3, "{events_a:?}"); + assert!(matches!(events_a[0], Event::ChannelReady { .. })); + let discard_txids_a: Vec<_> = events_a[1..] + .iter() + .map(|e| match e { + Event::DiscardFunding { funding_info: FundingInfo::Tx { transaction }, .. } => { + transaction.compute_txid() + }, + Event::DiscardFunding { funding_info: FundingInfo::OutPoint { outpoint }, .. } => { + outpoint.txid + }, + other => panic!("Expected DiscardFunding, got {:?}", other), + }) + .collect(); + assert!(discard_txids_a.contains(&splice_tx_0_txid), "Missing discard for initial splice"); + assert!(discard_txids_a.contains(&splice_tx_1_txid), "Missing discard for RBF #1"); + check_added_monitors(&nodes[0], 1); + + // Node 1 (acceptor): ChannelReady + 2 DiscardFunding. + let events_b = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events_b.len(), 3, "{events_b:?}"); + assert!(matches!(events_b[0], Event::ChannelReady { .. })); + let discard_txids_b: Vec<_> = events_b[1..] + .iter() + .map(|e| match e { + Event::DiscardFunding { funding_info: FundingInfo::Tx { transaction }, .. } => { + transaction.compute_txid() + }, + Event::DiscardFunding { funding_info: FundingInfo::OutPoint { outpoint }, .. } => { + outpoint.txid + }, + other => panic!("Expected DiscardFunding, got {:?}", other), + }) + .collect(); + assert!(discard_txids_b.contains(&splice_tx_0_txid), "Missing discard for initial splice"); + assert!(discard_txids_b.contains(&splice_tx_1_txid), "Missing discard for RBF #1"); + check_added_monitors(&nodes[1], 1); + + // Complete the announcement exchange. + let mut msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) { + nodes[1].node.handle_announcement_signatures(node_id_0, &msg); + } else { + panic!("Expected SendAnnouncementSignatures"); + } + assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); + + let mut msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + assert!(matches!(msg_events.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); + + // Clean up old watched outpoints. + let (orig_outpoint, orig_script) = original_funding_outpoint; + let splice_funding_idx = |tx: &Transaction| { + tx.output.iter().position(|o| o.script_pubkey == new_funding_script).unwrap() + }; + let outpoint_0 = + OutPoint { txid: splice_tx_0_txid, index: splice_funding_idx(&splice_tx_0) as u16 }; + let outpoint_1 = + OutPoint { txid: splice_tx_1_txid, index: splice_funding_idx(&splice_tx_1) as u16 }; + for node in &nodes { + node.chain_source.remove_watched_txn_and_outputs(orig_outpoint, orig_script.clone()); + node.chain_source.remove_watched_txn_and_outputs(outpoint_0, new_funding_script.clone()); + node.chain_source.remove_watched_txn_and_outputs(outpoint_1, new_funding_script.clone()); + } +} From dce4b35f0481aa1e397bf089942307f7a4df4b89 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 23 Feb 2026 20:32:32 -0600 Subject: [PATCH 24/26] Consider prior contributions when filtering unique inputs/outputs When funding_contributed is called while a splice negotiation is already in progress, unique contributions are computed to determine what to return via FailSplice or DiscardFunding. Without considering negotiated candidates stored in PendingFunding::contributions, UTXOs locked in earlier candidates could be incorrectly returned as reclaimable. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 219aac2fe4d..c594178b50c 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2990,6 +2990,14 @@ impl FundingNegotiation { } impl PendingFunding { + fn contributed_inputs(&self) -> impl Iterator + '_ { + self.contributions.iter().flat_map(|c| c.contributed_inputs()) + } + + fn contributed_outputs(&self) -> impl Iterator + '_ { + self.contributions.iter().flat_map(|c| c.contributed_outputs()) + } + fn check_get_splice_locked( &mut self, context: &ChannelContext, confirmed_funding_index: usize, height: u32, ) -> Option { @@ -12246,9 +12254,16 @@ where if let Some(QuiescentAction::Splice { contribution: existing, .. }) = &self.quiescent_action { + let pending_splice = self.pending_splice.as_ref(); + let prior_inputs = pending_splice + .into_iter() + .flat_map(|pending_splice| pending_splice.contributed_inputs()); + let prior_outputs = pending_splice + .into_iter() + .flat_map(|pending_splice| pending_splice.contributed_outputs()); return match contribution.into_unique_contributions( - existing.contributed_inputs(), - existing.contributed_outputs(), + existing.contributed_inputs().chain(prior_inputs), + existing.contributed_outputs().chain(prior_outputs), ) { None => Err(QuiescentError::DoNothing), Some((inputs, outputs)) => Err(QuiescentError::DiscardFunding { inputs, outputs }), @@ -12262,17 +12277,21 @@ where .filter(|funding_negotiation| funding_negotiation.is_initiator()); if let Some(funding_negotiation) = initiated_funding_negotiation { + let pending_splice = + self.pending_splice.as_ref().expect("funding negotiation implies pending splice"); + let prior_inputs = pending_splice.contributed_inputs(); + let prior_outputs = pending_splice.contributed_outputs(); let unique_contributions = match funding_negotiation { FundingNegotiation::AwaitingAck { context, .. } => contribution .into_unique_contributions( - context.contributed_inputs(), - context.contributed_outputs(), + context.contributed_inputs().chain(prior_inputs), + context.contributed_outputs().chain(prior_outputs), ), FundingNegotiation::ConstructingTransaction { interactive_tx_constructor, .. } => contribution.into_unique_contributions( - interactive_tx_constructor.contributed_inputs(), - interactive_tx_constructor.contributed_outputs(), + interactive_tx_constructor.contributed_inputs().chain(prior_inputs), + interactive_tx_constructor.contributed_outputs().chain(prior_outputs), ), FundingNegotiation::AwaitingSignatures { .. } => { let session = self @@ -12281,8 +12300,8 @@ where .as_ref() .expect("pending splice awaiting signatures"); contribution.into_unique_contributions( - session.contributed_inputs(), - session.contributed_outputs(), + session.contributed_inputs().chain(prior_inputs), + session.contributed_outputs().chain(prior_outputs), ) }, }; From 47baf7cfa5ba206776fbe259c66532db895ef097 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 24 Feb 2026 22:32:35 -0600 Subject: [PATCH 25/26] Filter prior contributions from SpliceFundingFailed events SpliceFundingFailed events return contributed inputs and outputs to the user so they can unlock the associated UTXOs. When an RBF attempt is in progress, inputs/outputs already consumed by prior contributions must be excluded to avoid the user prematurely unlocking UTXOs that are still needed by the active funding negotiation. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 81 +++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index c594178b50c..a91d2dda347 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2998,6 +2998,16 @@ impl PendingFunding { self.contributions.iter().flat_map(|c| c.contributed_outputs()) } + fn prior_contributed_inputs(&self) -> impl Iterator + '_ { + let len = self.contributions.len(); + self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_inputs()) + } + + fn prior_contributed_outputs(&self) -> impl Iterator + '_ { + let len = self.contributions.len(); + self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_outputs()) + } + fn check_get_splice_locked( &mut self, context: &ChannelContext, confirmed_funding_index: usize, height: u32, ) -> Option { @@ -3046,25 +3056,6 @@ pub(super) enum QuiescentError { FailSplice(SpliceFundingFailed), } -impl From for QuiescentError { - fn from(action: QuiescentAction) -> Self { - match action { - QuiescentAction::Splice { contribution, .. } => { - let (contributed_inputs, contributed_outputs) = - contribution.into_contributed_inputs_and_outputs(); - return QuiescentError::FailSplice(SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs, - contributed_outputs, - }); - }, - #[cfg(any(test, fuzzing, feature = "_test_utils"))] - QuiescentAction::DoNothing => QuiescentError::DoNothing, - } - } -} - pub(crate) enum StfuResponse { Stfu(msgs::Stfu), SpliceInit(msgs::SpliceInit), @@ -6936,7 +6927,7 @@ pub struct SpliceFundingFailed { } macro_rules! maybe_create_splice_funding_failed { - ($funded_channel: expr, $pending_splice: expr, $get: ident, $contributed_inputs_and_outputs: ident) => {{ + ($funded_channel: expr, $pending_splice: expr, $pending_splice_ref: expr, $get: ident, $contributed_inputs_and_outputs: ident) => {{ $pending_splice .and_then(|pending_splice| pending_splice.funding_negotiation.$get()) .filter(|funding_negotiation| funding_negotiation.is_initiator()) @@ -6950,7 +6941,7 @@ macro_rules! maybe_create_splice_funding_failed { .as_funding() .map(|funding| funding.get_channel_type().clone()); - let (contributed_inputs, contributed_outputs) = match funding_negotiation { + let (mut contributed_inputs, mut contributed_outputs) = match funding_negotiation { FundingNegotiation::AwaitingAck { context, .. } => { context.$contributed_inputs_and_outputs() }, @@ -6966,6 +6957,15 @@ macro_rules! maybe_create_splice_funding_failed { .$contributed_inputs_and_outputs(), }; + if let Some(pending_splice) = $pending_splice_ref { + for input in pending_splice.prior_contributed_inputs() { + contributed_inputs.retain(|i| *i != input); + } + for output in pending_splice.prior_contributed_outputs() { + contributed_outputs.retain(|o| *o != *output); + } + } + SpliceFundingFailed { funding_txo, channel_type, @@ -6999,11 +6999,19 @@ where shutdown_result } - fn abandon_quiescent_action(&mut self) -> Option { - match self.quiescent_action.take() { - Some(QuiescentAction::Splice { contribution, .. }) => { - let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs(); - Some(SpliceFundingFailed { + fn quiescent_action_into_error(&self, action: QuiescentAction) -> QuiescentError { + match action { + QuiescentAction::Splice { contribution, .. } => { + let (mut inputs, mut outputs) = contribution.into_contributed_inputs_and_outputs(); + if let Some(ref pending_splice) = self.pending_splice { + for input in pending_splice.contributed_inputs() { + inputs.retain(|i| *i != input); + } + for output in pending_splice.contributed_outputs() { + outputs.retain(|o| *o != *output); + } + } + QuiescentError::FailSplice(SpliceFundingFailed { funding_txo: None, channel_type: None, contributed_inputs: inputs, @@ -7011,11 +7019,20 @@ where }) }, #[cfg(any(test, fuzzing, feature = "_test_utils"))] - Some(quiescent_action) => { - self.quiescent_action = Some(quiescent_action); + QuiescentAction::DoNothing => QuiescentError::DoNothing, + } + } + + fn abandon_quiescent_action(&mut self) -> Option { + let action = self.quiescent_action.take()?; + match self.quiescent_action_into_error(action) { + QuiescentError::FailSplice(failed) => Some(failed), + #[cfg(any(test, fuzzing, feature = "_test_utils"))] + QuiescentError::DoNothing => None, + _ => { + debug_assert!(false); None }, - None => None, } } @@ -7139,6 +7156,7 @@ where let splice_funding_failed = maybe_create_splice_funding_failed!( self, self.pending_splice.as_mut(), + self.pending_splice.as_ref(), take, into_contributed_inputs_and_outputs ); @@ -7163,6 +7181,7 @@ where maybe_create_splice_funding_failed!( self, self.pending_splice.as_ref(), + self.pending_splice.as_ref(), as_ref, to_contributed_inputs_and_outputs ) @@ -13811,14 +13830,14 @@ where if !self.context.is_usable() { log_debug!(logger, "Channel is not in a usable state to propose quiescence"); - return Err(action.into()); + return Err(self.quiescent_action_into_error(action)); } if self.quiescent_action.is_some() { log_debug!( logger, "Channel already has a pending quiescent action and cannot start another", ); - return Err(action.into()); + return Err(self.quiescent_action_into_error(action)); } // Since we don't have a pending quiescent action, we should never be in a state where we // sent `stfu` without already having become quiescent. From 97858df939e5170698963c106b9684822beb61fa Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 25 Feb 2026 09:32:23 -0600 Subject: [PATCH 26/26] Add tests for prior-contribution filtering in SpliceFundingFailed events Add test_splice_rbf_disconnect_filters_prior_contributions covering the reset_pending_splice_state macro path: when disconnecting during an RBF round that reuses the same UTXOs as a prior round, the DiscardFunding event should filter out inputs still committed to the prior round while keeping change outputs that differ due to the higher feerate. Extend do_abandon_splice_quiescent_action_on_shutdown with a pending_splice parameter covering the abandon_quiescent_action path: when shutdown occurs while a splice is queued and a prior splice is pending, the DiscardFunding event should similarly filter overlapping inputs. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/splicing_tests.rs | 144 +++++++++++++++++++++++++++-- 1 file changed, 138 insertions(+), 6 deletions(-) diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 488915c9c95..e0e436259d5 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2812,12 +2812,14 @@ fn fail_quiescent_action_on_channel_close() { #[test] fn abandon_splice_quiescent_action_on_shutdown() { - do_abandon_splice_quiescent_action_on_shutdown(true); - do_abandon_splice_quiescent_action_on_shutdown(false); + do_abandon_splice_quiescent_action_on_shutdown(true, false); + do_abandon_splice_quiescent_action_on_shutdown(false, false); + do_abandon_splice_quiescent_action_on_shutdown(true, true); + do_abandon_splice_quiescent_action_on_shutdown(false, true); } #[cfg(test)] -fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { +fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_splice: bool) { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); @@ -2831,6 +2833,19 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); + // When testing with a prior pending splice, complete splice A first so that + // `quiescent_action_into_error` filters against `pending_splice.contributed_inputs/outputs`. + if pending_splice { + let funding_contribution = do_initiate_splice_in( + &nodes[0], + &nodes[1], + channel_id, + Amount::from_sat(initial_channel_capacity / 2), + ); + let (_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + } + // Since we cannot close after having sent `stfu`, send an HTLC so that when we attempt to // splice, the `stfu` message is held back. let payment_amount = 1_000_000; @@ -2843,7 +2858,8 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { check_added_monitors(&nodes[0], 1); nodes[1].node.handle_update_add_htlc(node_id_0, &update.update_add_htlcs[0]); - nodes[1].node.handle_commitment_signed(node_id_0, &update.commitment_signed[0]); + // After a splice, commitment_signed messages are batched across funding scopes. + nodes[1].node.handle_commitment_signed_batch_test(node_id_0, &update.commitment_signed); check_added_monitors(&nodes[1], 1); let (revoke_and_ack, _) = get_revoke_commit_msgs(&nodes[1], &node_id_0); @@ -2851,9 +2867,13 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { check_added_monitors(&nodes[0], 1); // Attempt the splice. `stfu` should not go out yet as the state machine is pending. - let splice_in_amount = initial_channel_capacity / 2; + // Use a different amount when there's a prior splice so the change output differs. + let splice_in_amount = + if pending_splice { initial_channel_capacity / 4 } else { initial_channel_capacity / 2 }; let funding_contribution = initiate_splice_in(&nodes[0], &nodes[1], channel_id, Amount::from_sat(splice_in_amount)); + let splice_b_change_output = + if pending_splice { funding_contribution.change_output().cloned() } else { None }; assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); // Close the channel. We should see a `SpliceFailed` event for the pending splice @@ -2867,7 +2887,33 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { let shutdown = get_event_msg!(closer_node, MessageSendEvent::SendShutdown, closee_node_id); closee_node.node.handle_shutdown(closer_node_id, &shutdown); - expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + if pending_splice { + // With a prior pending splice, contributions are filtered against committed inputs/outputs. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => { + assert_eq!(*cid, channel_id); + }, + other => panic!("Expected SpliceFailed, got {:?}", other), + } + match &events[1] { + Event::DiscardFunding { + funding_info: FundingInfo::Contribution { inputs, outputs }, + .. + } => { + // The UTXO was filtered: it's still committed to the prior splice. + assert!(inputs.is_empty(), "Expected empty inputs (filtered), got {:?}", inputs); + // The change output was NOT filtered: different splice-in amount produces a + // different change. + let expected_outputs: Vec<_> = splice_b_change_output.into_iter().collect(); + assert_eq!(*outputs, expected_outputs); + }, + other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), + } + } else { + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + } let _ = get_event_msg!(closee_node, MessageSendEvent::SendShutdown, closer_node_id); } @@ -5344,3 +5390,89 @@ fn test_splice_rbf_sequential() { node.chain_source.remove_watched_txn_and_outputs(outpoint_1, new_funding_script.clone()); } } + +#[test] +fn test_splice_rbf_disconnect_filters_prior_contributions() { + // When disconnecting during an RBF round that reuses the same UTXOs as a prior round, + // the SpliceFundingFailed event should filter out inputs/outputs still committed to the prior + // round. This exercises the `reset_pending_splice_state` → `maybe_create_splice_funding_failed` + // macro path. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + // Provide exactly 1 UTXO per node so coin selection is deterministic. + provide_utxo_reserves(&nodes, 1, added_value * 2); + + // --- Round 0: Initial splice-in at floor feerate (253). --- + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx_0, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // --- Round 1: RBF at higher feerate without providing new UTXOs. --- + // The wallet reselects the same UTXO since the splice tx hasn't been mined. + let feerate_1_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; + let rbf_feerate = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu); + let funding_template = nodes[0].node.rbf_channel(&channel_id, &node_id_1, rbf_feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution_1 = funding_template.splice_in_sync(added_value, &wallet).unwrap(); + let rbf_change_output = funding_contribution_1.change_output().cloned(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, funding_contribution_1, None) + .unwrap(); + + // STFU exchange. + let stfu_a = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_a); + let stfu_b = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_b); + + // RBF handshake to start interactive TX. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, feerate_1_sat_per_kwu as u32); + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); + nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf); + + // Disconnect mid-negotiation. Stale interactive TX messages are cleared by peer_disconnected. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + // The initiator should get SpliceFailed + DiscardFunding with filtered contributions. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => { + assert_eq!(*cid, channel_id); + }, + other => panic!("Expected SpliceFailed, got {:?}", other), + } + match &events[1] { + Event::DiscardFunding { + funding_info: FundingInfo::Contribution { inputs, outputs }, + .. + } => { + // The UTXO was filtered out: it's still committed to round 0's splice. + assert!(inputs.is_empty(), "Expected empty inputs (filtered), got {:?}", inputs); + // The change output was NOT filtered: different feerate produces a different amount. + let expected_outputs: Vec<_> = rbf_change_output.into_iter().collect(); + assert_eq!(*outputs, expected_outputs); + }, + other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), + } + + // Reconnect. After a completed splice, channel_ready is not re-sent. + let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_nodes(reconnect_args); +}