diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index 37351460634..925bdfb62f8 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -4039,9 +4039,14 @@ impl ChannelMonitorImpl { } if let Some(parent_funding_txid) = channel_parameters.splice_parent_funding_txid.as_ref() { - // Only one splice can be negotiated at a time after we've exchanged `channel_ready` - // (implying our funding is confirmed) that spends our currently locked funding. - if !self.pending_funding.is_empty() { + // Multiple RBF candidates for the same splice are allowed (they share the same + // parent funding txid). A new splice with a different parent while one is pending + // is not allowed. + let has_different_parent = self.pending_funding.iter().any(|funding| { + funding.channel_parameters.splice_parent_funding_txid.as_ref() + != Some(parent_funding_txid) + }); + if has_different_parent { log_error!( logger, "Negotiated splice while channel is pending channel_ready/splice_locked" diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 3dfed10d5c8..dc51eebcc64 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -77,6 +77,13 @@ pub enum FundingInfo { /// The outpoint of the funding outpoint: transaction::OutPoint, }, + /// The contributions used for a dual funding or splice funding transaction. + Contribution { + /// UTXOs spent as inputs contributed to the funding transaction. + inputs: Vec, + /// Outputs contributed to the funding transaction. + outputs: Vec, + }, } impl_writeable_tlv_based_enum!(FundingInfo, @@ -85,6 +92,10 @@ impl_writeable_tlv_based_enum!(FundingInfo, }, (1, OutPoint) => { (1, outpoint, required) + }, + (2, Contribution) => { + (1, inputs, optional_vec), + (3, outputs, optional_vec), } ); @@ -1561,10 +1572,6 @@ pub enum Event { abandoned_funding_txo: Option, /// The features that this channel will operate with, if available. channel_type: Option, - /// UTXOs spent as inputs contributed to the splice transaction. - contributed_inputs: Vec, - /// Outputs contributed to the splice transaction. - contributed_outputs: Vec, }, /// Used to indicate to the user that they can abandon the funding transaction and recycle the /// inputs for another purpose. @@ -2326,8 +2333,6 @@ impl Writeable for Event { ref counterparty_node_id, ref abandoned_funding_txo, ref channel_type, - ref contributed_inputs, - ref contributed_outputs, } => { 52u8.write(writer)?; write_tlv_fields!(writer, { @@ -2336,8 +2341,6 @@ impl Writeable for Event { (5, user_channel_id, required), (7, counterparty_node_id, required), (9, abandoned_funding_txo, option), - (11, *contributed_inputs, optional_vec), - (13, *contributed_outputs, optional_vec), }); }, // Note that, going forward, all new events must only write data inside of @@ -2964,8 +2967,6 @@ impl MaybeReadable for Event { (5, user_channel_id, required), (7, counterparty_node_id, required), (9, abandoned_funding_txo, option), - (11, contributed_inputs, optional_vec), - (13, contributed_outputs, optional_vec), }); Ok(Some(Event::SpliceFailed { @@ -2974,8 +2975,6 @@ impl MaybeReadable for Event { counterparty_node_id: counterparty_node_id.0.unwrap(), abandoned_funding_txo, channel_type, - contributed_inputs: contributed_inputs.unwrap_or_default(), - contributed_outputs: contributed_outputs.unwrap_or_default(), })) }; f() diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 7943ed98719..f9cd3c2f876 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,8 @@ 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, SharedOwnedInput, SharedOwnedOutput, }; use crate::ln::msgs; use crate::ln::msgs::{ClosingSigned, ClosingSignedFeeRange, DecodeError, OnionErrorPacket}; @@ -2400,6 +2399,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)) @@ -2927,6 +2927,17 @@ struct PendingFunding { /// The funding txid used in the `splice_locked` received from the counterparty. received_funding_txid: Option, + + /// The feerate used in the last successfully negotiated funding transaction. + /// Used for validating the 25/24 feerate increase rule on RBF attempts. + last_funding_feerate_sat_per_1000_weight: Option, + + /// The funding contributions from all explicit splice/RBF attempts on this channel. + /// Each entry reflects the feerate-adjusted contribution that was actually used in that + /// negotiation. The last entry is re-used when the counterparty initiates an RBF and we + /// have no pending `QuiescentAction`. When re-used as acceptor, the last entry is replaced + /// with the version adjusted for the new feerate. + contributions: Vec, } impl_writeable_tlv_based!(PendingFunding, { @@ -2934,13 +2945,14 @@ impl_writeable_tlv_based!(PendingFunding, { (3, negotiated_candidates, required_vec), (5, sent_funding_txid, option), (7, received_funding_txid, option), + (9, last_funding_feerate_sat_per_1000_weight, option), + (11, contributions, optional_vec), }); #[derive(Debug)] enum FundingNegotiation { AwaitingAck { context: FundingNegotiationContext, - change_strategy: ChangeStrategy, new_holder_funding_key: PublicKey, }, ConstructingTransaction { @@ -3026,38 +3038,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, @@ -3066,9 +3048,35 @@ pub(crate) enum QuiescentAction { DoNothing, } +pub(super) enum QuiescentError { + DoNothing, + DiscardFunding { inputs: Vec, outputs: Vec }, + FailSplice(SpliceFundingFailed), +} + +impl From for QuiescentError { + fn from(action: QuiescentAction) -> Self { + match action { + QuiescentAction::Splice { contribution, .. } => { + let (contributed_inputs, contributed_outputs) = + contribution.into_contributed_inputs_and_outputs(); + return QuiescentError::FailSplice(SpliceFundingFailed { + funding_txo: None, + channel_type: None, + contributed_inputs, + contributed_outputs, + }); + }, + #[cfg(any(test, fuzzing, feature = "_test_utils"))] + QuiescentAction::DoNothing => QuiescentError::DoNothing, + } + } +} + pub(crate) enum StfuResponse { Stfu(msgs::Stfu), SpliceInit(msgs::SpliceInit), + TxInitRbf(msgs::TxInitRbf), } #[cfg(any(test, fuzzing, feature = "_test_utils"))] @@ -3078,7 +3086,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, @@ -3086,7 +3093,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`]. @@ -6724,24 +6730,13 @@ 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, - ) -> Result { + self, context: &ChannelContext, funding: &FundingScope, entropy_source: &ES, + holder_node_id: PublicKey, + ) -> InteractiveTxConstructor { debug_assert_eq!( self.shared_funding_input.is_some(), funding.channel_transaction_parameters.splice_parent_funding_txid.is_some(), @@ -6753,32 +6748,17 @@ 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, 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, @@ -6788,58 +6768,11 @@ impl FundingNegotiationContext { ), outputs_to_contribute: self.our_funding_outputs, }; - 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)); - } + if self.is_initiator { + InteractiveTxConstructor::new_for_outbound(constructor_args) + } else { + InteractiveTxConstructor::new_for_inbound(constructor_args) } - - 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) { @@ -6849,11 +6782,16 @@ impl FundingNegotiationContext { (contributed_inputs, contributed_outputs) } + fn contributed_inputs(&self) -> impl Iterator + '_ { + self.our_funding_inputs.iter().map(|input| input.utxo.outpoint) + } + + fn contributed_outputs(&self) -> impl Iterator + '_ { + self.our_funding_outputs.iter() + } + fn to_contributed_inputs_and_outputs(&self) -> (Vec, Vec) { - let contributed_inputs = - self.our_funding_inputs.iter().map(|input| input.utxo.outpoint).collect(); - let contributed_outputs = self.our_funding_outputs.clone(); - (contributed_inputs, contributed_outputs) + (self.contributed_inputs().collect(), self.contributed_outputs().cloned().collect()) } } @@ -6873,6 +6811,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))] @@ -7087,16 +7029,6 @@ where self.reset_pending_splice_state() } else { match self.quiescent_action.take() { - Some(QuiescentAction::LegacySplice(instructions)) => { - self.context.channel_state.clear_awaiting_quiescence(); - 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, .. }) => { self.context.channel_state.clear_awaiting_quiescence(); let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs(); @@ -11529,10 +11461,7 @@ where if let Some(quiescent_action) = self.quiescent_action.as_ref() { // TODO(splicing): If we didn't win quiescence, then we can contribute as an acceptor // instead of waiting for the splice to lock. - if matches!( - quiescent_action, - QuiescentAction::Splice { .. } | QuiescentAction::LegacySplice(_) - ) { + if matches!(quiescent_action, QuiescentAction::Splice { .. }) { self.context.channel_state.set_awaiting_quiescence(); } } @@ -12231,11 +12160,171 @@ 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, SpliceFundingFailed> { + ) -> Result, QuiescentError> { debug_assert!(contribution.is_splice()); + if let Some(QuiescentAction::Splice { contribution: existing, .. }) = &self.quiescent_action + { + return match contribution.into_unique_contributions( + existing.contributed_inputs(), + existing.contributed_outputs(), + ) { + None => Err(QuiescentError::DoNothing), + Some((inputs, outputs)) => Err(QuiescentError::DiscardFunding { inputs, outputs }), + }; + } + + let initiated_funding_negotiation = self + .pending_splice + .as_ref() + .and_then(|pending_splice| pending_splice.funding_negotiation.as_ref()) + .filter(|funding_negotiation| funding_negotiation.is_initiator()); + + if let Some(funding_negotiation) = initiated_funding_negotiation { + let unique_contributions = match funding_negotiation { + FundingNegotiation::AwaitingAck { context, .. } => contribution + .into_unique_contributions( + context.contributed_inputs(), + context.contributed_outputs(), + ), + FundingNegotiation::ConstructingTransaction { + interactive_tx_constructor, .. + } => contribution.into_unique_contributions( + interactive_tx_constructor.contributed_inputs(), + interactive_tx_constructor.contributed_outputs(), + ), + FundingNegotiation::AwaitingSignatures { .. } => { + let session = self + .context + .interactive_tx_signing_session + .as_ref() + .expect("pending splice awaiting signatures"); + contribution.into_unique_contributions( + session.contributed_inputs(), + session.contributed_outputs(), + ) + }, + }; + + return match unique_contributions { + None => Err(QuiescentError::DoNothing), + Some((contributed_inputs, contributed_outputs)) => { + Err(QuiescentError::FailSplice(SpliceFundingFailed { + funding_txo: None, + channel_type: None, + contributed_inputs, + contributed_outputs, + })) + }, + }; + } + if let Err(e) = contribution.validate().and_then(|()| { // For splice-out, our_funding_contribution is adjusted to cover fees if there // aren't any inputs. @@ -12247,65 +12336,39 @@ where let (contributed_inputs, contributed_outputs) = contribution.into_contributed_inputs_and_outputs(); - return Err(SpliceFundingFailed { + return Err(QuiescentError::FailSplice(SpliceFundingFailed { funding_txo: None, channel_type: None, contributed_inputs, contributed_outputs, - }); + })); } - self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }).map_err( - |action| { - // FIXME: Any better way to do this? - if let QuiescentAction::Splice { contribution, .. } = action { - let (contributed_inputs, contributed_outputs) = - contribution.into_contributed_inputs_and_outputs(); - SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs, - contributed_outputs, - } - } else { - debug_assert!(false); - SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs: vec![], - contributed_outputs: vec![], - } - } - }, - ) + 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, - }; + /// 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, + } + } - self.send_splice_init_internal(context, ChangeStrategy::LegacyUserProvided(change_script)) + /// 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_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 @@ -12326,16 +12389,15 @@ 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![], sent_funding_txid: None, received_funding_txid: None, + last_funding_feerate_sat_per_1000_weight: Some(funding_feerate_per_kw), + contributions: vec![], }); msgs::SpliceInit { @@ -12348,6 +12410,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, @@ -12406,10 +12496,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!( @@ -12533,11 +12619,41 @@ 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, + &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))?; + + // 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!( logger, @@ -12554,33 +12670,19 @@ 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 mut interactive_tx_constructor = funding_negotiation_context .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!( - "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 - // 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 { @@ -12590,6 +12692,8 @@ 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), + contributions: adjusted_contribution.into_iter().collect(), }); Ok(msgs::SpliceAck { @@ -12600,9 +12704,333 @@ 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 feerate = FeeRate::from_sat_per_kwu(msg.feerate_sat_per_1000_weight as u64); + + // 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!( + logger, + "Cannot accommodate initiator's feerate for channel {}: {}; \ + proceeding without contribution", + self.context.channel_id(), + e, + ); + }) + .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, + )?; + + // 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"); + 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!( + 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, + our_funding_outputs, + }; + + let mut interactive_tx_constructor = funding_negotiation_context + .into_interactive_tx_constructor( + &self.context, + &rbf_funding, + entropy_source, + holder_node_id.clone(), + ); + debug_assert!(interactive_tx_constructor.take_initiator_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: if our_funding_contribution != SignedAmount::ZERO { + Some(our_funding_contribution.to_sat()) + } else { + None + }, + }) + } + + fn validate_tx_ack_rbf(&self, msg: &msgs::TxAckRbf) -> Result { + let pending_splice = self + .pending_splice + .as_ref() + .ok_or_else(|| ChannelError::Ignore("Channel is not in pending splice".to_owned()))?; + + let funding_negotiation_context = 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 mut interactive_tx_constructor = funding_negotiation_context + .into_interactive_tx_constructor( + &self.context, + &rbf_funding, + entropy_source, + holder_node_id.clone(), + ); + let tx_msg_opt = interactive_tx_constructor.take_initiator_first_message(); + + 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, 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)?; @@ -12617,11 +13045,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!"); }; @@ -12630,17 +13058,9 @@ 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!( - "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()); @@ -13396,19 +13816,19 @@ where #[rustfmt::skip] pub fn propose_quiescence( &mut self, logger: &L, action: QuiescentAction, - ) -> Result, QuiescentAction> { + ) -> Result, QuiescentError> { log_debug!(logger, "Attempting to initiate quiescence"); if !self.context.is_usable() { log_debug!(logger, "Channel is not in a usable state to propose quiescence"); - return Err(action); + return Err(action.into()); } if self.quiescent_action.is_some() { log_debug!( logger, "Channel already has a pending quiescent action and cannot start another", ); - return Err(action); + return Err(action.into()); } self.quiescent_action = Some(action); @@ -13456,6 +13876,7 @@ where self.context.channel_state.clear_awaiting_quiescence(); self.context.channel_state.clear_remote_stfu_sent(); self.context.channel_state.set_quiescent(); + self.holder_is_quiescence_initiator = false; // We are sending an stfu in response to our couterparty's stfu, but had not yet sent // our own stfu (even if `awaiting_quiescence` was set). Thus, the counterparty is the // initiator and they can do "something fundamental". @@ -13534,6 +13955,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, @@ -13549,38 +13971,8 @@ 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. - if self.pending_splice.is_some() { - debug_assert!(false); - self.quiescent_action = - Some(QuiescentAction::Splice { contribution, locktime }); - - return Err(ChannelError::WarnAndDisconnect( - format!( - "Channel {} cannot be spliced as it already has a splice pending", - self.context.channel_id(), - ), - )); - } - + let prior_contribution = contribution.clone(); let prev_funding_input = self.funding.to_splice_funding_input(); let our_funding_contribution = contribution.net_value(); let funding_feerate_per_kw = contribution.feerate().to_sat_per_kwu() as u32; @@ -13596,7 +13988,18 @@ where our_funding_outputs, }; - let splice_init = self.send_splice_init_internal(context, ChangeStrategy::FromCoinSelection); + 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_internal(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"))] @@ -13627,8 +14030,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() { return Ok(None); } @@ -13987,6 +14389,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() @@ -14287,6 +14690,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; @@ -14543,7 +14947,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, @@ -14551,16 +14955,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, @@ -15214,7 +15614,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 @@ -15604,7 +16004,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; @@ -15658,7 +16058,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 @@ -15984,6 +16384,12 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> } } + // quiescent_action is no longer persisted, so clear the awaiting_quiescence flag if set. + let mut channel_state = channel_state; + if channel_state.is_awaiting_quiescence() { + channel_state.clear_awaiting_quiescence(); + } + Ok(FundedChannel { funding: FundingScope { value_to_self_msat, @@ -16123,7 +16529,8 @@ impl<'a, 'b, 'c, ES: EntropySource, SP: SignerProvider> }, holder_commitment_point, pending_splice, - quiescent_action, + quiescent_action: None, + holder_is_quiescence_initiator: false, }) } } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 08cbb6f6bf7..876766d7ba8 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -57,11 +57,12 @@ use crate::events::{FundingInfo, PaidBolt12Invoice}; use crate::ln::chan_utils::selected_commitment_sat_per_1000_weight; #[cfg(any(test, fuzzing, feature = "_test_utils"))] use crate::ln::channel::QuiescentAction; +use crate::ln::channel::QuiescentError; use crate::ln::channel::{ self, hold_time_since, Channel, ChannelError, ChannelUpdateStatus, DisconnectResult, FundedChannel, FundingTxSigned, InboundV1Channel, InteractiveTxMsgError, OutboundHop, - OutboundV1Channel, PendingV2Channel, ReconnectionMsg, ShutdownResult, StfuResponse, - UpdateFulfillCommitFetch, WithChannelContext, + OutboundV1Channel, PendingV2Channel, ReconnectionMsg, ShutdownResult, SpliceFundingFailed, + StfuResponse, UpdateFulfillCommitFetch, WithChannelContext, }; use crate::ln::channel_state::ChannelDetails; use crate::ln::funding::{FundingContribution, FundingTemplate}; @@ -4207,8 +4208,16 @@ impl< user_channel_id: shutdown_res.user_channel_id, abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: shutdown_res.channel_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }, None, )); @@ -4667,6 +4676,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, @@ -4728,8 +4789,16 @@ impl< user_channel_id: chan.context.get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: *channel_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }, None, )); @@ -6384,13 +6453,27 @@ impl< /// Used after [`ChannelManager::splice_channel`] by constructing a [`FundingContribution`] /// from the returned [`FundingTemplate`] and passing it here. /// + /// # Arguments + /// + /// An optional `locktime` for the funding transaction may be specified. If not given, the + /// current best block height is used. + /// + /// # Events + /// /// Calling this method will commence the process of creating a new funding transaction for the /// channel. An [`Event::FundingTransactionReadyForSigning`] will be generated once the /// transaction is successfully constructed interactively with the counterparty. - /// If unsuccessful, an [`Event::SpliceFailed`] will be surfaced instead. /// - /// An optional `locktime` for the funding transaction may be specified. If not given, the - /// current best block height is used. + /// If unsuccessful, an [`Event::SpliceFailed`] will be produced if there aren't any earlier + /// splice attempts for the channel outstanding (i.e., haven't yet produced either + /// [`Event::SplicePending`] or [`Event::SpliceFailed`]). + /// + /// If unsuccessful, an [`Event::DiscardFunding`] will be produced for any contributions + /// passed in that are not found in any outstanding attempts for the channel. If there are no + /// such contributions, then the [`Event::DiscardFunding`] will not be produced since these + /// contributions must not be reused yet. + /// + /// # Errors /// /// Returns [`ChannelUnavailable`] when a channel is not found or an incorrect /// `counterparty_node_id` is provided. @@ -6406,12 +6489,22 @@ impl< ) -> Result<(), APIError> { let mut result = Ok(()); PersistenceNotifierGuard::optionally_notify(self, || { + let push_discard_funding = |contribution: FundingContribution| { + let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs(); + self.pending_events.lock().unwrap().push_back(( + events::Event::DiscardFunding { + channel_id: *channel_id, + funding_info: FundingInfo::Contribution { inputs, outputs }, + }, + None, + )); + }; + let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex_opt = per_peer_state.get(counterparty_node_id); if peer_state_mutex_opt.is_none() { - result = Err(APIError::ChannelUnavailable { - err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}") - }); + push_discard_funding(contribution); + result = Err(APIError::no_such_peer(counterparty_node_id)); return NotifyOption::SkipPersistNoEvents; } @@ -6435,28 +6528,69 @@ impl< ); } }, - Err(splice_funding_failed) => { + Err(QuiescentError::DoNothing) => { + result = Err(APIError::APIMisuseError { + err: format!( + "Duplicate funding contribution for channel {}", + channel_id + ), + }); + }, + Err(QuiescentError::DiscardFunding { inputs, outputs }) => { + self.pending_events.lock().unwrap().push_back(( + events::Event::DiscardFunding { + channel_id: *channel_id, + funding_info: FundingInfo::Contribution { inputs, outputs }, + }, + None, + )); + result = Err(APIError::APIMisuseError { + err: format!( + "Channel {} already has a pending funding contribution", + channel_id + ), + }); + }, + Err(QuiescentError::FailSplice(SpliceFundingFailed { + funding_txo, + channel_type, + contributed_inputs, + contributed_outputs, + })) => { let pending_events = &mut self.pending_events.lock().unwrap(); pending_events.push_back(( events::Event::SpliceFailed { channel_id: *channel_id, counterparty_node_id: *counterparty_node_id, user_channel_id: channel.context().get_user_id(), - abandoned_funding_txo: splice_funding_failed.funding_txo, - channel_type: splice_funding_failed.channel_type.clone(), - contributed_inputs: splice_funding_failed - .contributed_inputs, - contributed_outputs: splice_funding_failed - .contributed_outputs, + abandoned_funding_txo: funding_txo, + channel_type, }, None, )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: *channel_id, + funding_info: FundingInfo::Contribution { + inputs: contributed_inputs, + outputs: contributed_outputs, + }, + }, + None, + )); + result = Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot accept funding contribution", + channel_id + ), + }); }, } return NotifyOption::DoPersist; }, None => { + push_discard_funding(contribution); result = Err(APIError::APIMisuseError { err: format!( "Channel with id {} not expecting funding contribution", @@ -6467,12 +6601,9 @@ impl< }, }, None => { - result = Err(APIError::ChannelUnavailable { - err: format!( - "Channel with id {} not found for the passed counterparty node_id {}", - channel_id, counterparty_node_id - ), - }); + push_discard_funding(contribution); + result = + Err(APIError::no_such_channel_for_peer(channel_id, counterparty_node_id)); return NotifyOption::SkipPersistNoEvents; }, } @@ -11345,8 +11476,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ user_channel_id: channel.context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type.clone(), - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }, None, )); @@ -11496,8 +11635,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ user_channel_id: chan.context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type.clone(), - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: msg.channel_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }, None, )); @@ -11658,8 +11805,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ user_channel_id: chan_entry.get().context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: msg.channel_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }, None, )); @@ -12423,6 +12578,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"; @@ -12656,9 +12818,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(_) => { @@ -12678,8 +12837,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.signer_provider, &self.entropy_source, &self.get_our_node_id(), &self.logger, @@ -12702,6 +12859,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, @@ -12723,7 +12928,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, @@ -12748,6 +12952,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> { @@ -13394,7 +13643,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }); notify = NotifyOption::SkipPersistHandleEvents; }, - Err(action) => log_trace!(logger, "Failed to propose quiescence for: {:?}", action), + Err(e) => { + debug_assert!(matches!(e, QuiescentError::DoNothing)); + log_trace!(logger, "Failed to propose quiescence"); + }, } } else { result = Err(APIError::APIMisuseError { @@ -14749,8 +15001,13 @@ impl< user_channel_id: chan.context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }); + splice_failed_events.push(events::Event::DiscardFunding { + channel_id: chan.context().channel_id(), + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }); } @@ -16154,19 +16411,29 @@ impl< } fn handle_tx_init_rbf(&self, counterparty_node_id: PublicKey, msg: &msgs::TxInitRbf) { - let err = Err(MsgHandleErrInternal::send_err_msg_no_close( - "Dual-funded channels not supported".to_owned(), - msg.channel_id.clone(), - )); - let _: Result<(), _> = self.handle_error(err, counterparty_node_id); + let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || { + let res = self.internal_tx_init_rbf(&counterparty_node_id, msg); + let persist = match &res { + Err(e) if e.closes_channel() => NotifyOption::DoPersist, + Err(_) => NotifyOption::SkipPersistHandleEvents, + Ok(()) => NotifyOption::SkipPersistHandleEvents, + }; + let _ = self.handle_error(res, counterparty_node_id); + persist + }); } fn handle_tx_ack_rbf(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAckRbf) { - let err = Err(MsgHandleErrInternal::send_err_msg_no_close( - "Dual-funded channels not supported".to_owned(), - msg.channel_id.clone(), - )); - let _: Result<(), _> = self.handle_error(err, counterparty_node_id); + let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || { + let res = self.internal_tx_ack_rbf(&counterparty_node_id, msg); + let persist = match &res { + Err(e) if e.closes_channel() => NotifyOption::DoPersist, + Err(_) => NotifyOption::SkipPersistHandleEvents, + Ok(()) => NotifyOption::SkipPersistHandleEvents, + }; + let _ = self.handle_error(res, counterparty_node_id); + persist + }); } fn handle_tx_abort(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAbort) { @@ -17361,9 +17628,9 @@ impl< let our_pending_intercepts = self.pending_intercepted_htlcs.lock().unwrap(); // Since some FundingNegotiation variants are not persisted, any splice in such state must - // be failed upon reload. However, as the necessary information for the SpliceFailed event - // is not persisted, the event itself needs to be persisted even though it hasn't been - // emitted yet. These are removed after the events are written. + // be failed upon reload. However, as the necessary information for the SpliceFailed and + // DiscardFunding events is not persisted, the events need to be persisted even though they + // haven't been emitted yet. These are removed after the events are written. let mut events = self.pending_events.lock().unwrap(); let event_count = events.len(); for peer_state in peer_states.iter() { @@ -17376,8 +17643,16 @@ impl< user_channel_id: chan.context.get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + events.push_back(( + events::Event::DiscardFunding { + channel_id: chan.context().channel_id(), + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }, None, )); @@ -17500,7 +17775,7 @@ impl< (21, WithoutLength(&self.flow.writeable_async_receive_offer_cache()), required), }); - // Remove the SpliceFailed events added earlier. + // Remove the SpliceFailed and DiscardFunding events added earlier. events.truncate(event_count); Ok(()) diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index aa7eaa509ce..2c7cc16a949 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -17,8 +17,8 @@ use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen, Watch use crate::events::bump_transaction::sync::BumpTransactionEventHandlerSync; use crate::events::bump_transaction::BumpTransactionEvent; use crate::events::{ - ClaimedHTLC, ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Invoice, PathFailure, - PaymentFailureReason, PaymentPurpose, + ClaimedHTLC, ClosureReason, Event, FundingInfo, HTLCHandlingFailureType, PaidBolt12Invoice, + PathFailure, PaymentFailureReason, PaymentPurpose, }; use crate::ln::chan_utils::{ commitment_tx_base_weight, COMMITMENT_TX_WEIGHT_PER_HTLC, TRUC_MAX_WEIGHT, @@ -27,7 +27,7 @@ use crate::ln::channelmanager::{ AChannelManager, ChainParameters, ChannelManager, ChannelManagerReadArgs, PaymentId, RAACommitmentOrder, MIN_CLTV_EXPIRY_DELTA, }; -use crate::ln::funding::FundingTxInput; +use crate::ln::funding::{FundingContribution, FundingTxInput}; use crate::ln::msgs::{self, OpenChannel}; use crate::ln::msgs::{ BaseMessageHandler, ChannelMessageHandler, MessageSendEvent, RoutingMessageHandler, @@ -1088,7 +1088,8 @@ pub fn get_warning_msg(node: &Node, recipient: &PublicKey) -> msgs::WarningMessa macro_rules! get_event { ($node: expr, $event_type: path) => {{ let mut events = $node.node.get_and_clear_pending_events(); - assert_eq!(events.len(), 1); + assert!(!events.is_empty(), "Expected an event"); + assert_eq!(events.len(), 1, "Unexpected events {events:?}"); let ev = events.pop().unwrap(); match ev { $event_type { .. } => ev, @@ -3232,6 +3233,57 @@ pub fn expect_splice_pending_event<'a, 'b, 'c, 'd>( } } +#[cfg(any(test, ldk_bench, feature = "_test_utils"))] +pub fn expect_splice_failed_events<'a, 'b, 'c, 'd>( + node: &'a Node<'b, 'c, 'd>, expected_channel_id: &ChannelId, + funding_contribution: FundingContribution, +) { + let events = node.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2); + match &events[0] { + Event::SpliceFailed { channel_id, .. } => { + assert_eq!(*expected_channel_id, *channel_id); + }, + _ => panic!("Unexpected event"), + } + match &events[1] { + Event::DiscardFunding { funding_info, .. } => { + if let FundingInfo::Contribution { inputs, outputs } = &funding_info { + let (expected_inputs, expected_outputs) = + funding_contribution.into_contributed_inputs_and_outputs(); + assert_eq!(*inputs, expected_inputs); + assert_eq!(*outputs, expected_outputs); + } else { + panic!("Expected FundingInfo::Contribution"); + } + }, + _ => panic!("Unexpected event"), + } +} + +#[cfg(any(test, ldk_bench, feature = "_test_utils"))] +pub fn expect_discard_funding_event<'a, 'b, 'c, 'd>( + node: &'a Node<'b, 'c, 'd>, expected_channel_id: &ChannelId, + funding_contribution: FundingContribution, +) { + let events = node.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::DiscardFunding { channel_id, funding_info } => { + assert_eq!(*expected_channel_id, *channel_id); + if let FundingInfo::Contribution { inputs, outputs } = &funding_info { + let (expected_inputs, expected_outputs) = + funding_contribution.into_contributed_inputs_and_outputs(); + assert_eq!(*inputs, expected_inputs); + assert_eq!(*outputs, expected_outputs); + } else { + panic!("Expected FundingInfo::Contribution"); + } + }, + _ => panic!("Unexpected event"), + } +} + pub fn expect_probe_successful_events( node: &Node, mut probe_results: Vec<(PaymentHash, PaymentId)>, ) { diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 935703ce817..3ee341b898e 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, @@ -116,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 { @@ -208,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() @@ -218,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)); @@ -305,6 +308,22 @@ impl FundingContribution { self.is_splice } + pub(super) fn contributed_inputs(&self) -> impl Iterator + '_ { + self.inputs.iter().map(|input| input.utxo.outpoint) + } + + pub(super) fn contributed_outputs(&self) -> impl Iterator + '_ { + 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; @@ -321,6 +340,24 @@ impl FundingContribution { (inputs.into_iter().map(|input| input.utxo.outpoint).collect(), outputs) } + pub(super) fn into_unique_contributions<'a>( + self, existing_inputs: impl Iterator, + existing_outputs: impl Iterator, + ) -> Option<(Vec, Vec)> { + let (mut inputs, mut outputs) = self.into_contributed_inputs_and_outputs(); + for existing in existing_inputs { + inputs.retain(|input| *input != existing); + } + for existing in existing_outputs { + outputs.retain(|output| *output != *existing); + } + if inputs.is_empty() && outputs.is_empty() { + None + } else { + Some((inputs, outputs)) + } + } + /// Validates that the funding inputs are suitable for use in the interactive transaction /// protocol, checking prevtx sizes and input sufficiency. pub fn validate(&self) -> Result<(), String> { @@ -356,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. @@ -379,11 +416,151 @@ 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 fair_fee = estimate_transaction_fee( + &self.inputs, + &self.outputs, + self.change_output.as_ref(), + 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, + None, + 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, + None, + 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, + None, + 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 @@ -426,45 +603,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] @@ -723,4 +926,358 @@ 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(6000); + let input = funding_input_sats(100_000); + let change = funding_output_sats(10_000); + + // 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), + 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], &[], 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); + 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()], + &[], + Some(&change), + 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], &[], 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); + 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()], + &[], + Some(&change), + 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], &[], None, 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()], + &[], + Some(&change), + 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()], None, 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], None, 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()], None, 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()], + &[], + Some(&change), + 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()], None, 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], None, 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()], + &[], + Some(&change), + 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()], + &[], + Some(&change), + 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")); + } } diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index c5db1bcbe8a..9f9a9ce487e 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}; @@ -361,9 +361,8 @@ impl ConstructedTransaction { NegotiationError { reason, contributed_inputs, contributed_outputs } } - fn to_contributed_inputs_and_outputs(&self) -> (Vec, Vec) { - let contributed_inputs = self - .tx + fn contributed_inputs(&self) -> impl Iterator + '_ { + self.tx .input .iter() .zip(self.input_metadata.iter()) @@ -375,20 +374,21 @@ impl ConstructedTransaction { .unwrap_or(true) }) .map(|(_, (txin, _))| txin.previous_output) - .collect(); + } - let contributed_outputs = self - .tx + fn contributed_outputs(&self) -> impl Iterator + '_ { + self.tx .output .iter() .zip(self.output_metadata.iter()) .enumerate() .filter(|(_, (_, output))| output.is_local(self.holder_is_initiator)) .filter(|(index, _)| *index != self.shared_output_index as usize) - .map(|(_, (txout, _))| txout.clone()) - .collect(); + .map(|(_, (txout, _))| txout) + } - (contributed_inputs, contributed_outputs) + fn to_contributed_inputs_and_outputs(&self) -> (Vec, Vec) { + (self.contributed_inputs().collect(), self.contributed_outputs().cloned().collect()) } fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { @@ -899,8 +899,16 @@ impl InteractiveTxSigningSession { self.unsigned_tx.into_negotiation_error(reason) } + pub(super) fn contributed_inputs(&self) -> impl Iterator + '_ { + self.unsigned_tx.contributed_inputs() + } + + pub(super) fn contributed_outputs(&self) -> impl Iterator + '_ { + self.unsigned_tx.contributed_outputs() + } + pub(super) fn to_contributed_inputs_and_outputs(&self) -> (Vec, Vec) { - self.unsigned_tx.to_contributed_inputs_and_outputs() + (self.contributed_inputs().collect(), self.contributed_outputs().cloned().collect()) } pub(super) fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { @@ -2012,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, @@ -2023,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, @@ -2104,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, @@ -2113,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 { @@ -2149,20 +2166,22 @@ impl InteractiveTxConstructor { (contributed_inputs, contributed_outputs) } - pub(super) fn to_contributed_inputs_and_outputs(&self) -> (Vec, Vec) { - let contributed_inputs = self - .inputs_to_contribute + pub(super) fn contributed_inputs(&self) -> impl Iterator + '_ { + self.inputs_to_contribute .iter() .filter(|(_, input)| !input.is_shared()) .map(|(_, input)| input.tx_in().previous_output) - .collect(); - let contributed_outputs = self - .outputs_to_contribute + } + + pub(super) fn contributed_outputs(&self) -> impl Iterator + '_ { + self.outputs_to_contribute .iter() .filter(|(_, output)| !output.is_shared()) - .map(|(_, output)| output.tx_out().clone()) - .collect(); - (contributed_inputs, contributed_outputs) + .map(|(_, output)| output.tx_out()) + } + + pub(super) fn to_contributed_inputs_and_outputs(&self) -> (Vec, Vec) { + (self.contributed_inputs().collect(), self.contributed_outputs().cloned().collect()) } pub fn is_initiator(&self) -> bool { @@ -2313,102 +2332,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; @@ -2423,8 +2356,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::{ @@ -2515,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| { @@ -3388,118 +3298,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 92a298f6ef1..a7f404cafed 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -25,13 +25,19 @@ 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; -use bitcoin::{Amount, FeeRate, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut}; +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use bitcoin::transaction::Version; +use bitcoin::{ + Amount, FeeRate, OutPoint as BitcoinOutPoint, Psbt, ScriptBuf, Transaction, TxOut, WPubkeyHash, +}; #[test] fn test_splicing_not_supported_api_error() { @@ -109,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, @@ -218,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(); @@ -243,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); @@ -261,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); @@ -271,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:?}"); @@ -279,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(); @@ -330,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] { @@ -548,7 +725,8 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; - let _ = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); // Attempt a splice negotiation that only goes up to receiving `splice_init`. Reconnecting // should implicitly abort the negotiation and reset the splice state such that we're able to @@ -586,14 +764,15 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { nodes[1].node.peer_disconnected(node_id_0); } - let _event = get_event!(nodes[0], Event::SpliceFailed); + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); reconnect_args.send_channel_ready = (true, true); reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - let _ = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); // Attempt a splice negotiation that ends mid-construction of the funding transaction. // Reconnecting should implicitly abort the negotiation and reset the splice state such that @@ -636,14 +815,15 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { nodes[1].node.peer_disconnected(node_id_0); } - let _event = get_event!(nodes[0], Event::SpliceFailed); + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); reconnect_args.send_channel_ready = (true, true); reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - let _ = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); // Attempt a splice negotiation that ends before the initial `commitment_signed` messages are // exchanged. The node missing the other's `commitment_signed` upon reconnecting should @@ -717,7 +897,7 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { let tx_abort = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1); nodes[1].node.handle_tx_abort(node_id_0, &tx_abort); - let _event = get_event!(nodes[0], Event::SpliceFailed); + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); // Attempt a splice negotiation that completes, (i.e. `tx_signatures` are exchanged). Reconnecting // should not abort the negotiation or reset the splice state. @@ -778,7 +958,8 @@ fn test_config_reject_inbound_splices() { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]; - let _ = initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); + let funding_contribution = + initiate_splice_out(&nodes[0], &nodes[1], channel_id, outputs.clone()); let stfu = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); nodes[1].node.handle_stfu(node_id_0, &stfu); @@ -799,7 +980,7 @@ fn test_config_reject_inbound_splices() { nodes[0].node.peer_disconnected(node_id_1); nodes[1].node.peer_disconnected(node_id_0); - let _event = get_event!(nodes[0], Event::SpliceFailed); + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); reconnect_args.send_channel_ready = (true, true); @@ -1145,6 +1326,350 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { ); } +#[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. +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 { @@ -1613,28 +2138,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. + // 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 (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(); @@ -1672,15 +2192,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()); @@ -1688,38 +2201,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]); @@ -1740,23 +2226,28 @@ fn do_test_propose_splice_while_disconnected(reload: bool, 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); @@ -1770,7 +2261,7 @@ fn do_test_propose_splice_while_disconnected(reload: bool, 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); @@ -1778,10 +2269,9 @@ fn do_test_propose_splice_while_disconnected(reload: bool, 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 { @@ -1802,10 +2292,6 @@ fn do_test_propose_splice_while_disconnected(reload: bool, 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:?}"); @@ -1838,78 +2324,6 @@ fn do_test_propose_splice_while_disconnected(reload: bool, 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. - 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); - } - 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); } @@ -1992,14 +2406,7 @@ fn fail_splice_on_interactive_tx_error() { get_event_msg!(acceptor, MessageSendEvent::SendTxComplete, node_id_initiator); initiator.node.handle_tx_add_input(node_id_acceptor, &tx_add_input); - let event = get_event!(initiator, Event::SpliceFailed); - match event { - Event::SpliceFailed { contributed_inputs, .. } => { - assert_eq!(contributed_inputs.len(), 1); - assert_eq!(contributed_inputs[0], funding_contribution.into_tx_parts().0[0].outpoint()); - }, - _ => panic!("Expected Event::SpliceFailed"), - } + expect_splice_failed_events(initiator, &channel_id, funding_contribution); // We exit quiescence upon sending `tx_abort`, so we should see the holding cell be immediately // freed. @@ -2070,14 +2477,7 @@ fn fail_splice_on_tx_abort() { let tx_abort = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator); initiator.node.handle_tx_abort(node_id_acceptor, &tx_abort); - let event = get_event!(initiator, Event::SpliceFailed); - match event { - Event::SpliceFailed { contributed_inputs, .. } => { - assert_eq!(contributed_inputs.len(), 1); - assert_eq!(contributed_inputs[0], funding_contribution.into_tx_parts().0[0].outpoint()); - }, - _ => panic!("Expected Event::SpliceFailed"), - } + expect_splice_failed_events(initiator, &channel_id, funding_contribution); // We exit quiescence upon receiving `tx_abort`, so we should see our `tx_abort` echo and the // holding cell be immediately freed. @@ -2118,7 +2518,7 @@ fn fail_splice_on_tx_complete_error() { value: Amount::from_sat(1_000), script_pubkey: acceptor.wallet_source.get_change_script().unwrap(), }]; - let _ = initiate_splice_out(initiator, acceptor, channel_id, outputs); + let funding_contribution = initiate_splice_out(initiator, acceptor, channel_id, outputs); let _ = complete_splice_handshake(initiator, acceptor); // Queue an outgoing HTLC to the holding cell. It should be freed once we exit quiescence. @@ -2172,7 +2572,8 @@ fn fail_splice_on_tx_complete_error() { }; initiator.node.handle_tx_abort(node_id_acceptor, tx_abort); - let _ = get_event!(initiator, Event::SpliceFailed); + expect_splice_failed_events(initiator, &channel_id, funding_contribution); + let tx_abort = get_event_msg!(initiator, MessageSendEvent::SendTxAbort, node_id_acceptor); acceptor.node.handle_tx_abort(node_id_initiator, &tx_abort); @@ -2306,7 +2707,7 @@ fn fail_splice_on_channel_close() { &nodes[0], &[ExpectedCloseEvent { channel_id: Some(channel_id), - discard_funding: false, + discard_funding: true, splice_failed: true, channel_funding_txo: None, user_channel_id: Some(42), @@ -2352,7 +2753,7 @@ fn fail_quiescent_action_on_channel_close() { &nodes[0], &[ExpectedCloseEvent { channel_id: Some(channel_id), - discard_funding: false, + discard_funding: true, splice_failed: true, channel_funding_txo: None, user_channel_id: Some(42), @@ -2796,3 +3197,2049 @@ fn test_splice_balance_falls_below_reserve() { // Final sanity check: send a payment using the new spliced capacity. let _ = send_payment(&nodes[0], &[&nodes[1]], 1_000_000); } + +#[test] +fn test_funding_contributed_counterparty_not_found() { + // Tests that calling funding_contributed with an unknown counterparty_node_id returns + // ChannelUnavailable and emits a DiscardFunding event. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 50_000_000); + + let splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); + + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + + // Use a fake/unknown public key as counterparty + let fake_node_id = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42; 32]).unwrap()); + + assert_eq!( + nodes[0].node.funding_contributed( + &channel_id, + &fake_node_id, + funding_contribution.clone(), + None + ), + Err(APIError::no_such_peer(&fake_node_id)), + ); + + expect_discard_funding_event(&nodes[0], &channel_id, funding_contribution); +} + +#[test] +fn test_funding_contributed_channel_not_found() { + // Tests that calling funding_contributed with an unknown channel_id returns + // ChannelUnavailable and emits a DiscardFunding event. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 50_000_000); + + let splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); + + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + + // Use a random/unknown channel_id + let fake_channel_id = ChannelId::from_bytes([42; 32]); + + assert_eq!( + nodes[0].node.funding_contributed( + &fake_channel_id, + &node_id_1, + funding_contribution.clone(), + None + ), + Err(APIError::no_such_channel_for_peer(&fake_channel_id, &node_id_1)), + ); + + expect_discard_funding_event(&nodes[0], &fake_channel_id, funding_contribution); +} + +#[test] +fn test_funding_contributed_splice_already_pending() { + // Tests that calling funding_contributed when there's already a pending splice + // contribution returns Err(APIMisuseError) and emits a DiscardFunding event containing only the + // inputs/outputs that are NOT already in the existing 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_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + let splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 2, splice_in_amount * 2); + + // Use splice_in_and_out with an output so we can test output filtering + let first_splice_out = TxOut { + value: Amount::from_sat(5_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())), + }; + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let first_contribution = funding_template + .splice_in_and_out_sync(splice_in_amount, vec![first_splice_out.clone()], &wallet) + .unwrap(); + + // Initiate a second splice with a DIFFERENT output to test that different outputs + // are included in DiscardFunding (not filtered out) + let second_splice_out = TxOut { + value: Amount::from_sat(6_000), // Different amount + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())), + }; + + // Clear UTXOs and add a LARGER one for the second contribution to ensure + // the change output will be different from the first contribution's change + // + // FIXME: Should we actually not consider the change value given DiscardFunding is meant to + // reclaim the change script pubkey? But that means for other cases we'd need to track which + // output is for change later in the pipeline. + nodes[0].wallet_source.clear_utxos(); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 3); + + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let second_contribution = funding_template + .splice_in_and_out_sync(splice_in_amount, vec![second_splice_out.clone()], &wallet) + .unwrap(); + + // First funding_contributed - this sets up the quiescent action + nodes[0].node.funding_contributed(&channel_id, &node_id_1, first_contribution, None).unwrap(); + + // Drain the pending stfu message + let _ = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + + // Second funding_contributed with a different contribution - this should trigger + // DiscardFunding because there's already a pending quiescent action (splice contribution). + // Only inputs/outputs NOT in the existing contribution should be discarded. + let (expected_inputs, expected_outputs) = + second_contribution.clone().into_contributed_inputs_and_outputs(); + + // Returns Err(APIMisuseError) and emits DiscardFunding for the non-duplicate parts of the second contribution + assert_eq!( + nodes[0].node.funding_contributed(&channel_id, &node_id_1, second_contribution, None), + Err(APIError::APIMisuseError { + err: format!("Channel {} already has a pending funding contribution", channel_id), + }) + ); + + // The second contribution has different outputs (second_splice_out differs from first_splice_out), + // so those outputs should NOT be filtered out - they should appear in DiscardFunding. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::DiscardFunding { channel_id: event_channel_id, funding_info } => { + assert_eq!(event_channel_id, &channel_id); + if let FundingInfo::Contribution { inputs, outputs } = funding_info { + // The input is different, so it should be in the discard event + assert_eq!(*inputs, expected_inputs); + // The splice-out output is different (6000 vs 5000), so it should be in discard event + assert!(expected_outputs.contains(&second_splice_out)); + assert!(!expected_outputs.contains(&first_splice_out)); + // The different outputs should NOT be filtered out + assert_eq!(*outputs, expected_outputs); + } else { + panic!("Expected FundingInfo::Contribution"); + } + }, + _ => panic!("Expected DiscardFunding event"), + } +} + +#[test] +fn test_funding_contributed_duplicate_contribution_no_event() { + // Tests that calling funding_contributed with the exact same contribution twice + // returns Err(APIMisuseError) and emits no events on the second call (DoNothing path). + // This tests the case where all inputs/outputs in the second contribution + // are already present in the existing 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_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + let splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); + + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + + // First funding_contributed - this sets up the quiescent action + nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution.clone(), None).unwrap(); + + // Drain the pending stfu message + let _ = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + + // Second funding_contributed with the SAME contribution (same inputs/outputs) + // This should trigger the DoNothing path because all inputs/outputs are duplicates. + // Returns Err(APIMisuseError) and emits NO events. + assert_eq!( + nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None), + Err(APIError::APIMisuseError { + err: format!("Duplicate funding contribution for channel {}", channel_id), + }) + ); + + // Verify no events were emitted - the duplicate contribution is silently ignored + let events = nodes[0].node.get_and_clear_pending_events(); + assert!(events.is_empty(), "Expected no events for duplicate contribution, got {:?}", events); +} + +#[test] +fn test_funding_contributed_active_funding_negotiation() { + do_test_funding_contributed_active_funding_negotiation(0); // AwaitingAck + do_test_funding_contributed_active_funding_negotiation(1); // ConstructingTransaction + do_test_funding_contributed_active_funding_negotiation(2); // AwaitingSignatures +} + +#[cfg(test)] +fn do_test_funding_contributed_active_funding_negotiation(state: u8) { + // Tests that calling funding_contributed when a splice is already being actively negotiated + // (pending_splice.funding_negotiation exists and is_initiator()) returns Err(APIMisuseError) + // and emits SpliceFailed + DiscardFunding events for non-duplicate contributions, or + // returns Err(APIMisuseError) with no events for duplicate contributions. + // + // State 0: AwaitingAck (splice_init sent, splice_ack not yet received) + // State 1: ConstructingTransaction (splice handshake complete, interactive TX in progress) + // State 2: AwaitingSignatures (interactive TX complete, awaiting signing) + 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 (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + let splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 2, splice_in_amount * 2); + + // Build first contribution + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let first_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + + // Build second contribution with different UTXOs so inputs/outputs don't overlap + nodes[0].wallet_source.clear_utxos(); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 3); + + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let second_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + + // First funding_contributed - sets up the quiescent action and queues STFU + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, first_contribution.clone(), None) + .unwrap(); + + // Complete the STFU exchange. This consumes the quiescent_action and creates + // FundingNegotiation::AwaitingAck with splice_init queued. + let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_init); + let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_ack); + + // Drain the splice_init from the initiator's pending message events + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + + if state >= 1 { + // Process splice_init/ack to move to ConstructingTransaction + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + if state == 2 { + // Complete interactive TX negotiation to move to AwaitingSignatures + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + first_contribution.clone(), + new_funding_script, + ); + + // Drain the FundingTransactionReadyForSigning event from the initiator + let _ = get_event!(nodes[0], Event::FundingTransactionReadyForSigning); + } + } + + // Call funding_contributed with a different contribution (non-overlapping inputs/outputs). + // This hits the funding_negotiation path and returns FailSplice. + let (expected_inputs, expected_outputs) = + second_contribution.clone().into_contributed_inputs_and_outputs(); + assert_eq!( + nodes[0].node.funding_contributed(&channel_id, &node_id_1, second_contribution, None), + Err(APIError::APIMisuseError { + err: format!("Channel {} cannot accept funding contribution", channel_id), + }) + ); + + // Assert SpliceFailed + DiscardFunding events with the non-duplicate 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: event_channel_id, .. } => { + assert_eq!(*event_channel_id, channel_id); + }, + _ => panic!("Expected SpliceFailed event, got {:?}", events[0]), + } + match &events[1] { + Event::DiscardFunding { channel_id: event_channel_id, funding_info } => { + assert_eq!(*event_channel_id, channel_id); + if let FundingInfo::Contribution { inputs, outputs } = funding_info { + assert_eq!(*inputs, expected_inputs); + assert_eq!(*outputs, expected_outputs); + } else { + panic!("Expected FundingInfo::Contribution"); + } + }, + _ => panic!("Expected DiscardFunding event, got {:?}", events[1]), + } + + // Also test the DoNothing path: call funding_contributed with the same contribution + // as the existing negotiation. All inputs/outputs are duplicates, so no events. + assert_eq!( + nodes[0].node.funding_contributed(&channel_id, &node_id_1, first_contribution, None), + Err(APIError::APIMisuseError { + err: format!("Duplicate funding contribution for channel {}", channel_id), + }) + ); + + let events = nodes[0].node.get_and_clear_pending_events(); + assert!(events.is_empty(), "Expected no events for duplicate contribution, got {:?}", events); + + // Cleanup: drain leftover message events from the in-progress splice negotiation + if state == 1 { + // Initiator has its first interactive TX message queued after handle_splice_ack + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + assert!(matches!(msg_events[0], MessageSendEvent::SendTxAddInput { .. })); + } + if state == 2 { + // Acceptor (no contribution) auto-signed and sent commitment_signed + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + assert!(matches!(msg_events[0], MessageSendEvent::UpdateHTLCs { .. })); + } +} + +#[test] +fn test_funding_contributed_channel_shutdown() { + // Tests that calling funding_contributed after initiating channel shutdown returns Err(APIMisuseError) + // and emits both SpliceFailed and DiscardFunding events. The channel is no longer usable + // after shutdown is initiated, so quiescence cannot be proposed. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + let splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); + + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + + // Initiate channel shutdown - this makes is_usable() return false + nodes[0].node.close_channel(&channel_id, &node_id_1).unwrap(); + + // Drain the pending shutdown message + let _ = get_event_msg!(nodes[0], MessageSendEvent::SendShutdown, node_id_1); + + // Now call funding_contributed - this should trigger FailSplice because + // propose_quiescence() will fail when is_usable() returns false. + // Returns Err(APIMisuseError) and emits both SpliceFailed and DiscardFunding. + assert_eq!( + nodes[0].node.funding_contributed( + &channel_id, + &node_id_1, + funding_contribution.clone(), + None + ), + Err(APIError::APIMisuseError { + err: format!("Channel {} cannot accept funding contribution", channel_id), + }) + ); + + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); +} + +#[test] +fn test_funding_contributed_unfunded_channel() { + // Tests that calling funding_contributed on an unfunded channel returns APIMisuseError + // and emits a DiscardFunding event. The channel exists but is not yet funded. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_1 = nodes[1].node.get_our_node_id(); + + // Create a funded channel for the splice operation + let (_, _, funded_channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + // Create an unfunded channel (after open/accept but before funding tx) + let unfunded_channel_id = exchange_open_accept_chan(&nodes[0], &nodes[1], 50_000, 0); + + // Drain the FundingGenerationReady event for the unfunded channel + let _ = get_event!(nodes[0], Event::FundingGenerationReady); + + let splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); + + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let funding_template = + nodes[0].node.splice_channel(&funded_channel_id, &node_id_1, feerate).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template.splice_in_sync(splice_in_amount, &wallet).unwrap(); + + // Call funding_contributed with the unfunded channel's ID instead of the funded one. + // Returns APIMisuseError because the channel is not funded. + assert_eq!( + nodes[0].node.funding_contributed( + &unfunded_channel_id, + &node_id_1, + funding_contribution.clone(), + None + ), + Err(APIError::APIMisuseError { + err: format!( + "Channel with id {} not expecting funding contribution", + unfunded_channel_id + ), + }) + ); + + 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 full end-to-end flow for RBF of a pending splice transaction. + // Complete a splice-in, then use rbf_channel API to initiate an RBF attempt + // with a higher feerate, going through the full tx_init_rbf → tx_ack_rbf → + // interactive TX → signing → mining → splice_locked flow. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Step 1: Complete a splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + // Save the pre-splice funding outpoint before splice_channel modifies the monitor. + let original_funding_outpoint = nodes[0] + .chain_monitor + .chain_monitor + .get_monitor(channel_id) + .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) + .unwrap(); + + let (first_splice_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Step 2: Provide more UTXO reserves for the RBF attempt. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Step 3: Use rbf_channel API to initiate the RBF. + // Original feerate was FEERATE_FLOOR_SATS_PER_KW (253). 253 * 25 / 24 = 263.54, so 264 works. + let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24; // ceil(253*25/24) = 264 + let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); + let funding_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); + + // 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 rbf_channel rejects a feerate that doesn't satisfy the 25/24 rule, and that the + // acceptor also rejects tx_init_rbf with an insufficient feerate from a misbehaving peer. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice-in. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Initiator-side: rbf_channel rejects an insufficient feerate. + // Original feerate was 253. Using exactly 253 should fail since 253 * 24 < 253 * 25. + let same_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let err = nodes[0].node.rbf_channel(&channel_id, &node_id_1, same_feerate).unwrap_err(); + assert_eq!( + err, + APIError::APIMisuseError { + err: format!( + "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", + channel_id, FEERATE_FLOOR_SATS_PER_KW, FEERATE_FLOOR_SATS_PER_KW, + ), + } + ); + + // Acceptor-side: tx_init_rbf with an insufficient feerate is also rejected. + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: FEERATE_FLOOR_SATS_PER_KW, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!( + "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", + channel_id, FEERATE_FLOOR_SATS_PER_KW, FEERATE_FLOOR_SATS_PER_KW, + ), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } +} + +#[test] +fn test_splice_rbf_no_pending_splice() { + // Test that tx_init_rbf is rejected when there is no pending splice to RBF. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + // Re-enter quiescence without having done a splice. + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(50_000), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!("Channel {} has no pending splice to RBF", channel_id), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } +} + +#[test] +fn test_splice_rbf_active_negotiation() { + // Test that tx_init_rbf is rejected when a funding negotiation is already in progress. + // Start a splice but don't complete interactive TX construction, then send tx_init_rbf. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Initiate a splice but only complete the handshake (STFU + splice_init/ack), + // leaving interactive TX construction in progress. + let _funding_contribution = + do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let _new_funding_script = complete_splice_handshake(&nodes[0], &nodes[1]); + + // Now the acceptor (node 1) has a funding_negotiation in progress (ConstructingTransaction). + // Sending tx_init_rbf should be rejected. + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!( + "Channel {} already has a funding negotiation in progress", + channel_id, + ), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } + + // Clear the initiator's pending interactive TX messages from the incomplete splice handshake. + nodes[0].node.get_and_clear_pending_msg_events(); +} + +#[test] +fn test_splice_rbf_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]), + } +} + +#[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); +} + +#[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), + } +} + +#[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()); + } +} diff --git a/pending_changelog/4388-splice-failed-discard-funding.txt b/pending_changelog/4388-splice-failed-discard-funding.txt new file mode 100644 index 00000000000..64fc4ab4e26 --- /dev/null +++ b/pending_changelog/4388-splice-failed-discard-funding.txt @@ -0,0 +1,21 @@ +# API Updates + + * `Event::SpliceFailed` no longer carries `contributed_inputs` or `contributed_outputs` fields. + Instead, a separate `Event::DiscardFunding` event with `FundingInfo::Contribution` is emitted + for UTXO cleanup. + + * `Event::DiscardFunding` with `FundingInfo::Contribution` is also emitted without a + corresponding `Event::SpliceFailed` when `ChannelManager::funding_contributed` returns an + error (e.g., channel or peer not found, wrong channel state, duplicate contribution). + +# Backwards Compatibility + + * Older serializations that included `contributed_inputs` and `contributed_outputs` in + `SpliceFailed` will have those fields silently ignored on deserialization (they were odd TLV + fields). A `DiscardFunding` event will not be produced when reading these older serializations. + +# Forward Compatibility + + * Downgrading will not set the removed `contributed_inputs`/`contributed_outputs` fields on + `SpliceFailed`, so older code expecting those fields will see empty vectors for splice + failures.