From e93d43cf767f11a2f3953316bf8ff08cc0096bbc Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 11 Mar 2026 14:20:02 -0400 Subject: [PATCH 01/11] Emit SpliceFailed for acceptor contributions The maybe_create_splice_funding_failed! macro only emitted SpliceFailed and DiscardFunding events for the splice initiator. When an acceptor contributed inputs/outputs and the negotiation failed (e.g., disconnect), their contributions were silently discarded with no event notification, preventing the acceptor from reclaiming its UTXOs. Replace the is_initiator() filter with a post-hoc check on whether there are contributions to discard. The initiator always gets events, the acceptor gets events when it has contributions, and acceptors without contributions get no events (nothing to discard). Co-Authored-By: Claude Opus 4.6 (1M context) --- lightning/src/ln/channel.rs | 14 ++++-- lightning/src/ln/splicing_tests.rs | 72 ++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 9361cd3c749..05bd9b3611e 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -6568,8 +6568,9 @@ macro_rules! maybe_create_splice_funding_failed { ($funded_channel: expr, $pending_splice: expr, $get: ident, $contributed_inputs_and_outputs: ident) => {{ $pending_splice .and_then(|pending_splice| pending_splice.funding_negotiation.$get()) - .filter(|funding_negotiation| funding_negotiation.is_initiator()) - .map(|funding_negotiation| { + .and_then(|funding_negotiation| { + let is_initiator = funding_negotiation.is_initiator(); + let funding_txo = funding_negotiation .as_funding() .and_then(|funding| funding.get_funding_txo()) @@ -6595,12 +6596,17 @@ macro_rules! maybe_create_splice_funding_failed { .$contributed_inputs_and_outputs(), }; - SpliceFundingFailed { + if !is_initiator && contributed_inputs.is_empty() && contributed_outputs.is_empty() + { + return None; + } + + Some(SpliceFundingFailed { funding_txo, channel_type, contributed_inputs, contributed_outputs, - } + }) }) }}; } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 486e386be87..f8c188c68a0 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -4006,3 +4006,75 @@ fn do_test_splice_pending_htlcs(config: UserConfig) { let _ = send_payment(&nodes[0], &[&nodes[1]], 2_000 * 1000); let _ = send_payment(&nodes[1], &[&nodes[0]], 2_000 * 1000); } + +#[test] +fn test_splice_acceptor_disconnect_emits_events() { + // When both nodes contribute to a splice and the negotiation fails due to disconnect, + // both the initiator and acceptor should receive SpliceFailed + DiscardFunding events + // so each can reclaim their UTXOs. + 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, 1, added_value * 2); + + // Both nodes initiate splice-in (tiebreak: node 0 wins). + let node_0_funding_contribution = + do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let _node_1_funding_contribution = + do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value); + + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert_ne!(splice_ack.funding_contribution_satoshis, 0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + // Disconnect mid-interactive-TX negotiation. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + // The initiator should get SpliceFailed + DiscardFunding. + expect_splice_failed_events(&nodes[0], &channel_id, node_0_funding_contribution); + + // The acceptor should also get SpliceFailed + DiscardFunding with its contributions + // so it can reclaim its UTXOs. The contribution is feerate-adjusted by handle_splice_init, + // so we check for non-empty inputs/outputs rather than exact values. + let events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id), + other => panic!("Expected SpliceFailed, got {:?}", other), + } + match &events[1] { + Event::DiscardFunding { + funding_info: FundingInfo::Contribution { inputs, outputs }, + .. + } => { + assert!(!inputs.is_empty(), "Expected acceptor inputs, got empty"); + assert!(!outputs.is_empty(), "Expected acceptor outputs, got empty"); + }, + other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), + } + + // Reconnect and verify the channel is still operational. + 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); +} From 55196db0e147ead7b0a683e982c2293b36079be6 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 13:37:44 -0600 Subject: [PATCH 02/11] Accept tx_init_rbf for pending splice transactions When a splice funding transaction has been negotiated but not yet confirmed, either party may initiate RBF to bump the feerate. This enables the acceptor to handle such requests, allowing continued progress toward on-chain confirmation of splices in rising fee environments. Only the acceptor side is implemented; the acceptor does not contribute funds beyond the shared funding input. The initiator side (sending tx_init_rbf and handling tx_ack_rbf) is left for a follow-up. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 240 +++++++++++++++++++--- lightning/src/ln/channelmanager.rs | 62 +++++- lightning/src/ln/interactivetxs.rs | 9 + lightning/src/ln/splicing_tests.rs | 313 +++++++++++++++++++++++++++++ 4 files changed, 590 insertions(+), 34 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 05bd9b3611e..be8e0e1c307 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2106,6 +2106,7 @@ where let funding_negotiation = pending_splice.funding_negotiation.take(); if let Some(FundingNegotiation::ConstructingTransaction { mut funding, + funding_feerate_sat_per_1000_weight, interactive_tx_constructor, }) = funding_negotiation { @@ -2116,6 +2117,7 @@ where Some(FundingNegotiation::AwaitingSignatures { is_initiator, funding, + funding_feerate_sat_per_1000_weight, initial_commitment_signed_from_counterparty: None, }); interactive_tx_constructor @@ -2896,6 +2898,10 @@ struct PendingFunding { /// The funding txid used in the `splice_locked` received from the counterparty. received_funding_txid: Option, + + /// The feerate used in the last successfully negotiated funding transaction. + /// Used for validating the 25/24 feerate increase rule on RBF attempts. + last_funding_feerate_sat_per_1000_weight: Option, } impl_writeable_tlv_based!(PendingFunding, { @@ -2903,6 +2909,7 @@ impl_writeable_tlv_based!(PendingFunding, { (3, negotiated_candidates, required_vec), (5, sent_funding_txid, option), (7, received_funding_txid, option), + (8, last_funding_feerate_sat_per_1000_weight, option), }); #[derive(Debug)] @@ -2913,10 +2920,12 @@ enum FundingNegotiation { }, ConstructingTransaction { funding: FundingScope, + funding_feerate_sat_per_1000_weight: u32, interactive_tx_constructor: InteractiveTxConstructor, }, AwaitingSignatures { funding: FundingScope, + funding_feerate_sat_per_1000_weight: u32, is_initiator: bool, /// The initial [`msgs::CommitmentSigned`] message received for the [`FundingScope`] above. /// We delay processing this until the user manually approves the splice via @@ -2936,6 +2945,7 @@ impl_writeable_tlv_based_enum_upgradable!(FundingNegotiation, (0, AwaitingSignatures) => { (1, funding, required), (3, is_initiator, required), + (5, funding_feerate_sat_per_1000_weight, (default_value, 0)), (_unused, initial_commitment_signed_from_counterparty, (static_value, None)), }, unread_variants: AwaitingAck, ConstructingTransaction @@ -2959,6 +2969,37 @@ impl FundingNegotiation { FundingNegotiation::AwaitingSignatures { is_initiator, .. } => *is_initiator, } } + fn for_acceptor( + funding: FundingScope, context: &ChannelContext, entropy_source: &ES, + holder_node_id: &PublicKey, our_funding_contribution: SignedAmount, + prev_funding_input: SharedOwnedInput, locktime: u32, feerate_sat_per_1000_weight: u32, + our_funding_inputs: Vec, our_funding_outputs: Vec, + ) -> FundingNegotiation { + let funding_negotiation_context = FundingNegotiationContext { + is_initiator: false, + our_funding_contribution, + funding_tx_locktime: LockTime::from_consensus(locktime), + funding_feerate_sat_per_1000_weight: feerate_sat_per_1000_weight, + shared_funding_input: Some(prev_funding_input), + our_funding_inputs, + our_funding_outputs, + }; + + let (interactive_tx_constructor, first_message) = funding_negotiation_context + .into_interactive_tx_constructor( + context, + &funding, + entropy_source, + holder_node_id.clone(), + ); + debug_assert!(first_message.is_none()); + + FundingNegotiation::ConstructingTransaction { + funding, + funding_feerate_sat_per_1000_weight: feerate_sat_per_1000_weight, + interactive_tx_constructor, + } + } } impl PendingFunding { @@ -8794,10 +8835,15 @@ where if let Some(pending_splice) = self.pending_splice.as_mut() { self.context.channel_state.clear_quiescent(); - if let Some(FundingNegotiation::AwaitingSignatures { mut funding, .. }) = - pending_splice.funding_negotiation.take() + if let Some(FundingNegotiation::AwaitingSignatures { + mut funding, + funding_feerate_sat_per_1000_weight, + .. + }) = pending_splice.funding_negotiation.take() { funding.funding_transaction = Some(funding_tx); + pending_splice.last_funding_feerate_sat_per_1000_weight = + Some(funding_feerate_sat_per_1000_weight); let funding_txo = funding.get_funding_txo().expect("funding outpoint should be set"); @@ -11904,6 +11950,7 @@ where negotiated_candidates: vec![], sent_funding_txid: None, received_funding_txid: None, + last_funding_feerate_sat_per_1000_weight: None, }); msgs::SpliceInit { @@ -12096,11 +12143,9 @@ where Ok(()) } - pub(crate) fn splice_init( - &mut self, msg: &msgs::SpliceInit, entropy_source: &ES, holder_node_id: &PublicKey, - logger: &L, - ) -> Result { - let feerate = FeeRate::from_sat_per_kwu(msg.funding_feerate_per_kw as u64); + fn resolve_queued_contribution( + &self, feerate: FeeRate, logger: &L, + ) -> (Option, Option) { let holder_balance = self .get_holder_counterparty_balances_floor_incl_fee(&self.funding) .map(|(holder, _)| holder) @@ -12114,7 +12159,8 @@ where ); }) .ok(); - let our_funding_contribution = + + let net_value = holder_balance.and_then(|_| self.queued_funding_contribution()).and_then(|c| { c.net_value_for_acceptor_at_feerate(feerate, holder_balance.unwrap()) .map_err(|e| { @@ -12130,6 +12176,17 @@ where .ok() }); + (net_value, holder_balance) + } + + pub(crate) fn splice_init( + &mut self, msg: &msgs::SpliceInit, entropy_source: &ES, holder_node_id: &PublicKey, + logger: &L, + ) -> Result { + let feerate = FeeRate::from_sat_per_kwu(msg.funding_feerate_per_kw as u64); + let (our_funding_contribution, holder_balance) = + self.resolve_queued_contribution(feerate, logger); + let splice_funding = self.validate_splice_init(msg, our_funding_contribution.unwrap_or(SignedAmount::ZERO))?; @@ -12152,35 +12209,26 @@ where self.funding.get_value_satoshis(), ); + let new_funding_pubkey = splice_funding.get_holder_pubkeys().funding_pubkey; let prev_funding_input = self.funding.to_splice_funding_input(); - let funding_negotiation_context = FundingNegotiationContext { - is_initiator: false, + let funding_negotiation = FundingNegotiation::for_acceptor( + splice_funding, + &self.context, + entropy_source, + holder_node_id, our_funding_contribution, - funding_tx_locktime: LockTime::from_consensus(msg.locktime), - funding_feerate_sat_per_1000_weight: msg.funding_feerate_per_kw, - shared_funding_input: Some(prev_funding_input), + prev_funding_input, + msg.locktime, + msg.funding_feerate_per_kw, our_funding_inputs, our_funding_outputs, - }; - - let (interactive_tx_constructor, first_message) = funding_negotiation_context - .into_interactive_tx_constructor( - &self.context, - &splice_funding, - entropy_source, - holder_node_id.clone(), - ); - debug_assert!(first_message.is_none()); - - let new_funding_pubkey = splice_funding.get_holder_pubkeys().funding_pubkey; + ); self.pending_splice = Some(PendingFunding { - funding_negotiation: Some(FundingNegotiation::ConstructingTransaction { - funding: splice_funding, - interactive_tx_constructor, - }), + funding_negotiation: Some(funding_negotiation), negotiated_candidates: Vec::new(), received_funding_txid: None, sent_funding_txid: None, + last_funding_feerate_sat_per_1000_weight: None, }); Ok(msgs::SpliceAck { @@ -12191,6 +12239,137 @@ where }) } + /// Checks during handling tx_init_rbf for an existing splice + fn validate_tx_init_rbf( + &self, msg: &msgs::TxInitRbf, our_funding_contribution: SignedAmount, + fee_estimator: &LowerBoundedFeeEstimator, + ) -> Result { + if self.holder_commitment_point.current_point().is_none() { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} commitment point needs to be advanced once before RBF", + self.context.channel_id(), + ))); + } + + if !self.context.channel_state.is_quiescent() { + return Err(ChannelError::WarnAndDisconnect("Quiescence needed for RBF".to_owned())); + } + + if self.context.minimum_depth(&self.funding) == Some(0) { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} has option_zeroconf, cannot RBF splice", + self.context.channel_id(), + ))); + } + + let pending_splice = match &self.pending_splice { + Some(pending_splice) => pending_splice, + None => { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} has no pending splice to RBF", + self.context.channel_id(), + ))); + }, + }; + + if pending_splice.funding_negotiation.is_some() { + return Err(ChannelError::Abort(AbortReason::NegotiationInProgress)); + } + + if pending_splice.received_funding_txid.is_some() { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} counterparty already sent splice_locked, cannot RBF", + self.context.channel_id(), + ))); + } + + if pending_splice.sent_funding_txid.is_some() { + return Err(ChannelError::WarnAndDisconnect(format!( + "Channel {} already sent splice_locked, cannot RBF", + self.context.channel_id(), + ))); + } + + let last_candidate = match pending_splice.negotiated_candidates.last() { + 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::Abort(AbortReason::InsufficientRbfFeerate)); + } + + 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 last negotiated candidate since all RBF candidates + // for the same splice share the same funding output script. + let holder_pubkeys = last_candidate.get_holder_pubkeys().clone(); + let counterparty_funding_pubkey = *last_candidate.counterparty_funding_pubkey(); + + Ok(FundingScope::for_splice( + &self.funding, + &self.context, + our_funding_contribution, + their_funding_contribution, + counterparty_funding_pubkey, + holder_pubkeys, + )) + } + + pub(crate) fn tx_init_rbf( + &mut self, msg: &msgs::TxInitRbf, entropy_source: &ES, holder_node_id: &PublicKey, + fee_estimator: &LowerBoundedFeeEstimator, logger: &L, + ) -> Result { + let our_funding_contribution = SignedAmount::ZERO; + let rbf_funding = + self.validate_tx_init_rbf(msg, our_funding_contribution, fee_estimator)?; + + log_info!( + logger, + "Starting RBF funding negotiation for channel {} after receiving tx_init_rbf; channel value: {} sats", + self.context.channel_id, + rbf_funding.get_value_satoshis(), + ); + + let prev_funding_input = self.funding.to_splice_funding_input(); + let funding_negotiation = FundingNegotiation::for_acceptor( + rbf_funding, + &self.context, + entropy_source, + holder_node_id, + our_funding_contribution, + prev_funding_input, + msg.locktime, + msg.feerate_sat_per_1000_weight, + Vec::new(), + Vec::new(), + ); + let pending_splice = self.pending_splice.as_mut().expect("pending_splice should exist"); + pending_splice.funding_negotiation = Some(funding_negotiation); + + Ok(msgs::TxAckRbf { + channel_id: self.context.channel_id, + funding_output_contribution: None, + }) + } + pub(crate) fn splice_ack( &mut self, msg: &msgs::SpliceAck, entropy_source: &ES, holder_node_id: &PublicKey, logger: &L, @@ -12217,6 +12396,8 @@ where panic!("We should have returned an error earlier!"); }; + let funding_feerate_sat_per_1000_weight = + funding_negotiation_context.funding_feerate_sat_per_1000_weight; let (interactive_tx_constructor, tx_msg_opt) = funding_negotiation_context .into_interactive_tx_constructor( &self.context, @@ -12230,6 +12411,7 @@ where pending_splice.funding_negotiation = Some(FundingNegotiation::ConstructingTransaction { funding: splice_funding, + funding_feerate_sat_per_1000_weight, interactive_tx_constructor, }); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index ada27af749f..640dc821fb4 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12877,6 +12877,53 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } + /// Handle incoming tx_init_rbf, start a new round of interactive transaction construction. + fn internal_tx_init_rbf( + &self, counterparty_node_id: &PublicKey, msg: &msgs::TxInitRbf, + ) -> Result<(), MsgHandleErrInternal> { + let per_peer_state = self.per_peer_state.read().unwrap(); + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) + })?; + let mut peer_state_lock = peer_state_mutex.lock().unwrap(); + let peer_state = &mut *peer_state_lock; + + match peer_state.channel_by_id.entry(msg.channel_id) { + hash_map::Entry::Vacant(_) => { + return Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )) + }, + hash_map::Entry::Occupied(mut chan_entry) => { + if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { + let init_res = funded_channel.tx_init_rbf( + msg, + &self.entropy_source, + &self.get_our_node_id(), + &self.fee_estimator, + &self.logger, + ); + let tx_ack_rbf_msg = try_channel_entry!(self, peer_state, init_res, chan_entry); + peer_state.pending_msg_events.push(MessageSendEvent::SendTxAckRbf { + node_id: *counterparty_node_id, + msg: tx_ack_rbf_msg, + }); + Ok(()) + } else { + try_channel_entry!( + self, + peer_state, + Err( + ChannelError::close("Channel is not funded, cannot RBF splice".into(),) + ), + chan_entry + ) + } + }, + } + } + /// Handle incoming splice request ack, transition channel to splice-pending (unless some check fails). fn internal_splice_ack( &self, counterparty_node_id: &PublicKey, msg: &msgs::SpliceAck, @@ -16330,11 +16377,16 @@ impl< } fn handle_tx_init_rbf(&self, counterparty_node_id: PublicKey, msg: &msgs::TxInitRbf) { - let err = Err(MsgHandleErrInternal::send_err_msg_no_close( - "Dual-funded channels not supported".to_owned(), - msg.channel_id.clone(), - )); - let _: Result<(), _> = self.handle_error(err, counterparty_node_id); + let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || { + let res = self.internal_tx_init_rbf(&counterparty_node_id, msg); + let persist = match &res { + Err(e) if e.closes_channel() => NotifyOption::DoPersist, + Err(_) => NotifyOption::SkipPersistHandleEvents, + Ok(()) => NotifyOption::SkipPersistHandleEvents, + }; + let _ = self.handle_error(res, counterparty_node_id); + persist + }); } fn handle_tx_ack_rbf(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAckRbf) { diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index f7e0ce34346..5a9964a6063 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -136,6 +136,11 @@ pub(crate) enum AbortReason { DuplicateFundingOutput, /// More than one funding (shared) input found. DuplicateFundingInput, + /// The RBF feerate is insufficient (e.g., doesn't satisfy the 25/24 rule or can't accommodate + /// prior contributions). + InsufficientRbfFeerate, + /// A funding negotiation is already in progress. + NegotiationInProgress, /// Internal error InternalError(&'static str), } @@ -195,6 +200,10 @@ impl Display for AbortReason { f.write_str("More than one funding output found") }, AbortReason::DuplicateFundingInput => f.write_str("More than one funding input found"), + AbortReason::InsufficientRbfFeerate => f.write_str("Insufficient RBF feerate"), + AbortReason::NegotiationInProgress => { + f.write_str("A funding negotiation is already in progress") + }, AbortReason::InternalError(text) => { f.write_fmt(format_args!("Internal error: {}", text)) }, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index f8c188c68a0..9adc318a4d8 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -4007,6 +4007,20 @@ fn do_test_splice_pending_htlcs(config: UserConfig) { let _ = send_payment(&nodes[1], &[&nodes[0]], 2_000 * 1000); } +// Returns after both sides are quiescent (no splice_init is generated since we use DoNothing). +pub 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_acceptor_disconnect_emits_events() { // When both nodes contribute to a splice and the negotiation fails due to disconnect, @@ -4078,3 +4092,302 @@ fn test_splice_acceptor_disconnect_emits_events() { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); } + +#[test] +fn test_splice_rbf_acceptor_basic() { + // Test the happy path for accepting an RBF of a pending splice transaction. + // After completing a splice-in, re-enter quiescence and process tx_init_rbf + // from the counterparty, responding with tx_ack_rbf. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Re-enter quiescence for RBF (node 0 initiates). + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + // Node 0 sends tx_init_rbf with feerate satisfying the 25/24 rule. + // Original feerate was FEERATE_FLOOR_SATS_PER_KW (253). 253 * 25 / 24 = 263.54, so 264 works. + let rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: rbf_feerate as u32, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); + + assert_eq!(tx_ack_rbf.channel_id, channel_id); + // Acceptor doesn't contribute funds in the RBF. + assert_eq!(tx_ack_rbf.funding_output_contribution, None); +} + +#[test] +fn test_splice_rbf_insufficient_feerate() { + // Test that tx_init_rbf with an insufficient feerate (less than 25/24 of previous) is rejected. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice-in. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Re-enter quiescence. + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + // Send tx_init_rbf with feerate that does NOT satisfy the 25/24 rule. + // Original feerate was 253. Using exactly 253 should fail since 253 * 24 < 253 * 25. + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: FEERATE_FLOOR_SATS_PER_KW, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!(tx_abort.channel_id, channel_id); +} + +#[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 tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!(tx_abort.channel_id, channel_id); + + // Clear the initiator's pending interactive TX messages from the incomplete splice handshake. + nodes[0].node.get_and_clear_pending_msg_events(); +} + +#[test] +fn test_splice_rbf_after_splice_locked() { + // Test that tx_init_rbf is rejected when the counterparty has already sent splice_locked. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Mine the splice tx on both nodes. + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + + // Connect enough blocks on node 0 only so it sends splice_locked. + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + + let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + + // Deliver splice_locked to node 1. Since node 1 hasn't confirmed enough blocks, + // it won't send its own splice_locked back, but it will set received_funding_txid. + nodes[1].node.handle_splice_locked(node_id_0, &splice_locked); + + // Node 1 shouldn't have any messages to send (no splice_locked since it hasn't confirmed). + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert!(msg_events.is_empty(), "Expected no messages, got {:?}", msg_events); + + // Re-enter quiescence (node 0 initiates). + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + // Node 0 sends tx_init_rbf, but node 0 already sent splice_locked, so it should be rejected. + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!( + "Channel {} counterparty already sent splice_locked, cannot RBF", + channel_id, + ), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } +} + +#[test] +fn test_splice_rbf_zeroconf_rejected() { + // Test that tx_init_rbf is rejected when option_zeroconf is negotiated. + // The zero-conf check happens before the pending_splice check, so we don't need to complete + // a splice — just enter quiescence and send tx_init_rbf. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_limits.trust_own_funding_0conf = true; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (funding_tx, channel_id) = + open_zero_conf_channel_with_value(&nodes[0], &nodes[1], None, initial_channel_value_sat, 0); + mine_transaction(&nodes[0], &funding_tx); + mine_transaction(&nodes[1], &funding_tx); + + // Enter quiescence (node 0 initiates). + reenter_quiescence(&nodes[0], &nodes[1], &channel_id); + + // Node 0 sends tx_init_rbf, but the channel has option_zeroconf, so it should be rejected. + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(50_000), + }; + + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + match &msg_events[0] { + MessageSendEvent::HandleError { action, .. } => { + assert_eq!( + *action, + msgs::ErrorAction::DisconnectPeerWithWarning { + msg: msgs::WarningMessage { + channel_id, + data: format!( + "Channel {} has option_zeroconf, cannot RBF splice", + channel_id, + ), + }, + } + ); + }, + _ => panic!("Expected HandleError, got {:?}", msg_events[0]), + } +} From 51fa46e944e4b6898d99322d488e3f93d59ca5fa Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 20:32:54 -0600 Subject: [PATCH 03/11] Allow multiple RBF splice candidates in channel monitor The channel monitor previously rejected any new pending funding when one already existed. This prevented adding RBF candidates for a pending splice since each candidate needs its own pending funding entry. Relax the check to only reject new pending funding when its splice parent differs from existing entries, allowing multiple RBF candidates that compete to confirm the same splice. Co-Authored-By: Claude Opus 4.6 --- lightning/src/chain/channelmonitor.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index a8d055a9c5b..02a3a42b383 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -4039,9 +4039,16 @@ 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. This also ensures a dual-funded channel has exchanged + // `channel_ready` (implying funding is confirmed) before allowing a splice, + // since unconfirmed initial funding has no splice parent. + let has_different_parent = self.pending_funding.iter().any(|funding| { + funding.channel_parameters.splice_parent_funding_txid.as_ref() + != Some(parent_funding_txid) + }); + if has_different_parent { log_error!( logger, "Negotiated splice while channel is pending channel_ready/splice_locked" From 5b6ba4391c78e7a260c4b06b7a57b7a02ca5b11d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 20:34:56 -0600 Subject: [PATCH 04/11] Add rbf_channel API for initiating splice RBF Expose ChannelManager::rbf_channel as the entry point for bumping the feerate of a pending splice funding transaction. Like splice_channel, it returns a FundingTemplate to be completed and passed to funding_contributed. Validates that a pending splice exists with at least one negotiated candidate, no active funding negotiation, and that the new feerate satisfies the 25/24 increase rule required by the spec. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 143 ++++++++++++++++++++++++++--- lightning/src/ln/channelmanager.rs | 88 ++++++++++++++++++ 2 files changed, 220 insertions(+), 11 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index be8e0e1c307..29efe9a1ce2 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -11827,6 +11827,126 @@ where Ok(FundingTemplate::new(Some(shared_input), min_feerate, max_feerate)) } + /// Initiate an RBF of a pending splice transaction. + pub fn rbf_channel( + &self, min_feerate: FeeRate, max_feerate: FeeRate, + ) -> Result { + if self.holder_commitment_point.current_point().is_none() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot RBF until a payment is routed", + self.context.channel_id(), + ), + }); + } + + if self.quiescent_action.is_some() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot RBF as one is waiting to be negotiated", + self.context.channel_id(), + ), + }); + } + + if !self.context.is_usable() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot RBF as it is either pending open/close", + self.context.channel_id() + ), + }); + } + + if self.context.minimum_depth(&self.funding) == Some(0) { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} has option_zeroconf, cannot RBF splice", + self.context.channel_id(), + ), + }); + } + + if min_feerate > max_feerate { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} min_feerate {} exceeds max_feerate {}", + self.context.channel_id(), + min_feerate, + max_feerate, + ), + }); + } + + self.can_initiate_rbf(min_feerate).map_err(|err| APIError::APIMisuseError { err })?; + + let funding_txo = self.funding.get_funding_txo().expect("funding_txo should be set"); + let previous_utxo = + self.funding.get_funding_output().expect("funding_output should be set"); + let shared_input = Input { + outpoint: funding_txo.into_bitcoin_outpoint(), + previous_utxo, + satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, + }; + + Ok(FundingTemplate::new(Some(shared_input), min_feerate, max_feerate)) + } + + fn can_initiate_rbf(&self, feerate: FeeRate) -> Result<(), String> { + let pending_splice = match &self.pending_splice { + Some(pending_splice) => pending_splice, + None => { + return Err(format!( + "Channel {} has no pending splice to RBF", + self.context.channel_id(), + )); + }, + }; + + if pending_splice.funding_negotiation.is_some() { + return Err(format!( + "Channel {} cannot RBF as a funding negotiation is already in progress", + self.context.channel_id(), + )); + } + + if pending_splice.sent_funding_txid.is_some() { + return Err(format!( + "Channel {} already sent splice_locked, cannot RBF", + self.context.channel_id(), + )); + } + + if pending_splice.received_funding_txid.is_some() { + return Err(format!( + "Channel {} counterparty already sent splice_locked, cannot RBF", + self.context.channel_id(), + )); + } + + if pending_splice.negotiated_candidates.is_empty() { + return Err(format!( + "Channel {} has no negotiated splice candidates to RBF", + self.context.channel_id(), + )); + } + + // Check the 25/24 feerate increase rule + let new_feerate = feerate.to_sat_per_kwu() as u32; + if let Some(prev_feerate) = pending_splice.last_funding_feerate_sat_per_1000_weight { + if (new_feerate as u64) * 24 < (prev_feerate as u64) * 25 { + return Err(format!( + "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", + self.context.channel_id(), + new_feerate, + prev_feerate, + )); + } + } + + Ok(()) + } + pub fn funding_contributed( &mut self, contribution: FundingContribution, locktime: LockTime, logger: &L, ) -> Result, QuiescentError> { @@ -13353,17 +13473,18 @@ where } if let Some(action) = self.quiescent_action.as_ref() { - // We can't initiate another splice while ours is pending, so don't bother becoming - // quiescent yet. - // TODO(splicing): Allow the splice as an RBF once supported. - let has_splice_action = matches!(action, QuiescentAction::Splice { .. }); - if has_splice_action && self.pending_splice.is_some() { - log_given_level!( - logger, - logger_level, - "Waiting for pending splice to lock before sending stfu for new splice" - ); - return None; + #[allow(irrefutable_let_patterns)] + if let QuiescentAction::Splice { contribution, .. } = action { + if self.pending_splice.is_some() { + if let Err(msg) = self.can_initiate_rbf(contribution.feerate()) { + log_given_level!( + logger, + logger_level, + "Waiting on sending stfu for splice RBF: {msg}" + ); + return None; + } + } } } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 640dc821fb4..888e9fffd9b 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4728,6 +4728,94 @@ impl< } } + /// Initiate an RBF of a pending splice transaction for an existing channel. + /// + /// This is used after a splice has been negotiated but before it has been locked, in order + /// to bump the feerate of the funding transaction via replace-by-fee. + /// + /// # Required Feature Flags + /// + /// Initiating an RBF requires that the channel counterparty supports splicing. The + /// counterparty must be currently connected. + /// + /// # Arguments + /// + /// The RBF initiator is responsible for paying fees for common fields, shared inputs, and + /// shared outputs along with any contributed inputs and outputs. When building a + /// [`FundingContribution`], fees are estimated using `min_feerate` and must be covered by the + /// supplied inputs for splice-in or the channel balance for splice-out. If the counterparty + /// also initiates an RBF and wins the tie-break, they become the initiator and choose the + /// feerate. In that case, `max_feerate` is used to reject a feerate that is too high for our + /// contribution. + /// + /// Returns a [`FundingTemplate`] which should be used to build a [`FundingContribution`] via + /// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The resulting + /// contribution must then be passed to [`ChannelManager::funding_contributed`]. + /// + /// # Events + /// + /// Once the funding transaction has been constructed, an [`Event::SplicePending`] will be + /// emitted. At this point, any inputs contributed to the splice can only be re-spent if an + /// [`Event::DiscardFunding`] is seen. + /// + /// After initial signatures have been exchanged, [`Event::FundingTransactionReadyForSigning`] + /// will be generated and [`ChannelManager::funding_transaction_signed`] should be called. + /// + /// If any failures occur while negotiating the funding transaction, an [`Event::SpliceFailed`] + /// will be emitted. Any contributed inputs no longer used will be included here and thus can + /// be re-spent. + /// + /// Once the splice has been locked by both counterparties, an [`Event::ChannelReady`] will be + /// emitted with the new funding output. At this point, a new splice can be negotiated by + /// calling `splice_channel` again on this channel. + /// + /// [`FundingContribution`]: crate::ln::funding::FundingContribution + pub fn rbf_channel( + &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, min_feerate: FeeRate, + max_feerate: FeeRate, + ) -> Result { + let per_peer_state = self.per_peer_state.read().unwrap(); + + let peer_state_mutex = match per_peer_state + .get(counterparty_node_id) + .ok_or_else(|| APIError::no_such_peer(counterparty_node_id)) + { + Ok(p) => p, + Err(e) => return Err(e), + }; + + let mut peer_state = peer_state_mutex.lock().unwrap(); + if !peer_state.latest_features.supports_splicing() { + return Err(APIError::ChannelUnavailable { + err: "Peer does not support splicing".to_owned(), + }); + } + if !peer_state.latest_features.supports_quiescence() { + return Err(APIError::ChannelUnavailable { + err: "Peer does not support quiescence, a splicing prerequisite".to_owned(), + }); + } + + // Look for the channel + match peer_state.channel_by_id.entry(*channel_id) { + hash_map::Entry::Occupied(chan_phase_entry) => { + if let Some(chan) = chan_phase_entry.get().as_funded() { + chan.rbf_channel(min_feerate, max_feerate) + } else { + Err(APIError::ChannelUnavailable { + err: format!( + "Channel with id {} is not funded, cannot RBF splice", + channel_id + ), + }) + } + }, + hash_map::Entry::Vacant(_) => { + Err(APIError::no_such_channel_for_peer(channel_id, counterparty_node_id)) + }, + } + } + #[cfg(test)] pub(crate) fn abandon_splice( &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, From 5873660a0021a07705a743f4f0de31a83a5ef088 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 20:36:33 -0600 Subject: [PATCH 05/11] Send tx_init_rbf instead of splice_init when a splice is pending When the quiescence initiator has a pending splice and enters the stfu handler with a QuiescentAction::Splice, send tx_init_rbf to bump the existing splice's feerate rather than starting a new splice_init. This reuses the same QuiescentAction::Splice variant for both initial splices and RBF attempts -- the stfu handler distinguishes them by checking whether pending_splice already exists. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 48 +++++++++++++++++-------- lightning/src/ln/channelmanager.rs | 7 ++++ lightning/src/ln/splicing_tests.rs | 57 ++++++++++++++++++++++-------- 3 files changed, 82 insertions(+), 30 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 29efe9a1ce2..d24e60416b4 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3073,6 +3073,7 @@ impl From for QuiescentError { pub(crate) enum StfuResponse { Stfu(msgs::Stfu), SpliceInit(msgs::SpliceInit), + TxInitRbf(msgs::TxInitRbf), } /// Wrapper around a [`Transaction`] useful for caching the result of [`Transaction::compute_txid`]. @@ -12083,6 +12084,33 @@ where } } + fn send_tx_init_rbf(&mut self, context: FundingNegotiationContext) -> msgs::TxInitRbf { + let pending_splice = + self.pending_splice.as_mut().expect("pending_splice should exist for RBF"); + debug_assert!(!pending_splice.negotiated_candidates.is_empty()); + + let new_holder_funding_key = pending_splice + .negotiated_candidates + .first() + .unwrap() + .get_holder_pubkeys() + .funding_pubkey; + + let funding_feerate_per_kw = context.funding_feerate_sat_per_1000_weight; + let funding_contribution_satoshis = context.our_funding_contribution.to_sat(); + let locktime = context.funding_tx_locktime.to_consensus_u32(); + + pending_splice.funding_negotiation = + Some(FundingNegotiation::AwaitingAck { context, new_holder_funding_key }); + + 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, @@ -13404,21 +13432,6 @@ where )); }, Some(QuiescentAction::Splice { contribution, locktime }) => { - // TODO(splicing): If the splice has been negotiated but has not been locked, we - // can RBF here to add the contribution. - if self.pending_splice.is_some() { - debug_assert!(false); - self.quiescent_action = - Some(QuiescentAction::Splice { contribution, locktime }); - - return Err(ChannelError::WarnAndDisconnect( - format!( - "Channel {} cannot be spliced as it already has a splice pending", - self.context.channel_id(), - ), - )); - } - let prev_funding_input = self.funding.to_splice_funding_input(); let our_funding_contribution = contribution.net_value(); let funding_feerate_per_kw = contribution.feerate().to_sat_per_kwu() as u32; @@ -13434,6 +13447,11 @@ where our_funding_outputs, }; + if self.pending_splice.is_some() { + let tx_init_rbf = self.send_tx_init_rbf(context); + return Ok(Some(StfuResponse::TxInitRbf(tx_init_rbf))); + } + let splice_init = self.send_splice_init(context); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 888e9fffd9b..2c416e42486 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12691,6 +12691,13 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }); Ok(true) }, + Some(StfuResponse::TxInitRbf(msg)) => { + peer_state.pending_msg_events.push(MessageSendEvent::SendTxInitRbf { + node_id: *counterparty_node_id, + msg, + }); + Ok(true) + }, } } else { let msg = "Peer sent `stfu` for an unfunded channel"; diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 9adc318a4d8..d0fb29d1923 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -4096,14 +4096,15 @@ fn test_splice_acceptor_disconnect_emits_events() { #[test] fn test_splice_rbf_acceptor_basic() { // Test the happy path for accepting an RBF of a pending splice transaction. - // After completing a splice-in, re-enter quiescence and process tx_init_rbf - // from the counterparty, responding with tx_ack_rbf. + // After completing a splice-in, initiate an RBF attempt with a higher feerate, + // going through the tx_init_rbf → tx_ack_rbf 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, _) = @@ -4117,18 +4118,27 @@ fn test_splice_rbf_acceptor_basic() { let (_splice_tx, _new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); - // Re-enter quiescence for RBF (node 0 initiates). - reenter_quiescence(&nodes[0], &nodes[1], &channel_id); - - // Node 0 sends tx_init_rbf with feerate satisfying the 25/24 rule. + // Initiate an RBF with a feerate satisfying the 25/24 rule. // Original feerate was FEERATE_FLOOR_SATS_PER_KW (253). 253 * 25 / 24 = 263.54, so 264 works. - let rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); - let tx_init_rbf = msgs::TxInitRbf { - channel_id, - locktime: 0, - feerate_sat_per_1000_weight: rbf_feerate as u32, - funding_output_contribution: Some(added_value.to_sat() as i64), - }; + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); + let funding_template = + nodes[0].node.rbf_channel(&channel_id, &node_id_1, rbf_feerate, FeeRate::MAX).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(); + + nodes[0].node.funding_contributed(&channel_id, &node_id_1, funding_contribution, 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.channel_id, channel_id); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, rbf_feerate_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); @@ -4140,13 +4150,15 @@ fn test_splice_rbf_acceptor_basic() { #[test] fn test_splice_rbf_insufficient_feerate() { - // Test that tx_init_rbf with an insufficient feerate (less than 25/24 of previous) is rejected. + // Test that rbf_channel rejects a feerate that doesn't satisfy the 25/24 rule, and that the + // acceptor also rejects tx_init_rbf with an insufficient feerate from a misbehaving peer. let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); let initial_channel_value_sat = 100_000; let (_, _, channel_id, _) = @@ -4160,7 +4172,22 @@ fn test_splice_rbf_insufficient_feerate() { let (_splice_tx, _new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); - // Re-enter quiescence. + // Initiator-side: rbf_channel rejects an insufficient feerate. + // Original feerate was 253. Using exactly 253 should fail since 253 * 24 < 253 * 25. + let same_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let err = + nodes[0].node.rbf_channel(&channel_id, &node_id_1, same_feerate, FeeRate::MAX).unwrap_err(); + assert_eq!( + err, + APIError::APIMisuseError { + err: format!( + "Channel {} RBF feerate {} is less than 25/24 of the previous feerate {}", + channel_id, FEERATE_FLOOR_SATS_PER_KW, FEERATE_FLOOR_SATS_PER_KW, + ), + } + ); + + // Acceptor-side: tx_init_rbf with an insufficient feerate is also rejected. reenter_quiescence(&nodes[0], &nodes[1], &channel_id); // Send tx_init_rbf with feerate that does NOT satisfy the 25/24 rule. From b07bfff8b9f0188a73dfd95a36d14ec2e19ec3ed Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 20:38:06 -0600 Subject: [PATCH 06/11] Handle tx_ack_rbf on the initiator side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After sending tx_init_rbf, the initiator receives tx_ack_rbf from the acceptor. Implement the handler to validate the response and begin interactive transaction construction for the RBF funding transaction. Only clear the interactive signing session in `reset_pending_splice_state` when the current funding negotiation is in `AwaitingSignatures`. When an earlier round completed signing and a later RBF round is in `AwaitingAck` or `ConstructingTransaction`, the session belongs to the prior round and must be preserved. Otherwise, disconnecting mid-RBF would destroy the completed prior round's signing session and fire a false debug assertion. Update test_splice_rbf_acceptor_basic to exercise the full initiator flow: rbf_channel → funding_contributed → STFU exchange → tx_init_rbf → tx_ack_rbf → interactive TX → signing → mining → splice_locked. This replaces the previous test that manually constructed tx_init_rbf. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 226 ++++++++++++++++++++++------- lightning/src/ln/channelmanager.rs | 59 +++++++- lightning/src/ln/splicing_tests.rs | 219 ++++++++++++++++++++++++---- 3 files changed, 417 insertions(+), 87 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index d24e60416b4..96a147a9e6b 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2969,6 +2969,32 @@ impl FundingNegotiation { FundingNegotiation::AwaitingSignatures { is_initiator, .. } => *is_initiator, } } + fn for_initiator( + funding: FundingScope, context: &ChannelContext, + funding_negotiation_context: FundingNegotiationContext, entropy_source: &ES, + holder_node_id: &PublicKey, + ) -> (FundingNegotiation, Option) { + let funding_feerate_sat_per_1000_weight = + funding_negotiation_context.funding_feerate_sat_per_1000_weight; + let (interactive_tx_constructor, tx_msg_opt) = funding_negotiation_context + .into_interactive_tx_constructor( + context, + &funding, + entropy_source, + holder_node_id.clone(), + ); + debug_assert!(tx_msg_opt.is_some()); + + ( + FundingNegotiation::ConstructingTransaction { + funding, + funding_feerate_sat_per_1000_weight, + interactive_tx_constructor, + }, + tx_msg_opt, + ) + } + fn for_acceptor( funding: FundingScope, context: &ChannelContext, entropy_source: &ES, holder_node_id: &PublicKey, our_funding_contribution: SignedAmount, @@ -3003,6 +3029,43 @@ impl FundingNegotiation { } impl PendingFunding { + fn awaiting_ack_context( + &self, msg_name: &str, + ) -> Result<(&FundingNegotiationContext, &PublicKey), ChannelError> { + match &self.funding_negotiation { + Some(FundingNegotiation::AwaitingAck { context, new_holder_funding_key }) => { + Ok((context, new_holder_funding_key)) + }, + Some(FundingNegotiation::ConstructingTransaction { .. }) + | Some(FundingNegotiation::AwaitingSignatures { .. }) => Err(ChannelError::WarnAndDisconnect( + format!("Got unexpected {}; funding negotiation already in progress", msg_name,), + )), + None => Err(ChannelError::Ignore(format!( + "Got unexpected {}; no funding negotiation in progress", + msg_name, + ))), + } + } + + fn take_awaiting_ack_context( + &mut self, msg_name: &str, + ) -> Result { + match self.funding_negotiation.take() { + Some(FundingNegotiation::AwaitingAck { context, .. }) => Ok(context), + Some(other) => { + self.funding_negotiation = Some(other); + Err(ChannelError::WarnAndDisconnect(format!( + "Got unexpected {}; funding negotiation already in progress", + msg_name, + ))) + }, + None => Err(ChannelError::Ignore(format!( + "Got unexpected {}; no funding negotiation in progress", + msg_name, + ))), + } + } + fn check_get_splice_locked( &mut self, context: &ChannelContext, confirmed_funding_index: usize, height: u32, ) -> Option { @@ -6791,15 +6854,27 @@ where fn reset_pending_splice_state(&mut self) -> Option { debug_assert!(self.should_reset_pending_splice_state(true)); - debug_assert!( - self.context.interactive_tx_signing_session.is_none() - || !self - .context - .interactive_tx_signing_session - .as_ref() - .expect("We have a pending splice awaiting signatures") - .has_received_commitment_signed() - ); + + // Only clear the signing session if the current round is mid-signing. When an earlier + // round completed signing and a later RBF round is in AwaitingAck or + // ConstructingTransaction, the session belongs to the prior round and must be preserved. + let current_is_awaiting_signatures = self + .pending_splice + .as_ref() + .and_then(|ps| ps.funding_negotiation.as_ref()) + .map(|fn_| matches!(fn_, FundingNegotiation::AwaitingSignatures { .. })) + .unwrap_or(false); + if current_is_awaiting_signatures { + debug_assert!( + self.context.interactive_tx_signing_session.is_none() + || !self + .context + .interactive_tx_signing_session + .as_ref() + .expect("We have a pending splice awaiting signatures") + .has_received_commitment_signed() + ); + } let splice_funding_failed = maybe_create_splice_funding_failed!( self, @@ -6813,7 +6888,9 @@ where } self.context.channel_state.clear_quiescent(); - self.context.interactive_tx_signing_session.take(); + if current_is_awaiting_signatures { + self.context.interactive_tx_signing_session.take(); + } splice_funding_failed } @@ -12518,6 +12595,71 @@ where }) } + fn validate_tx_ack_rbf(&self, msg: &msgs::TxAckRbf) -> Result { + let pending_splice = self + .pending_splice + .as_ref() + .ok_or_else(|| ChannelError::Ignore("Channel is not in pending splice".to_owned()))?; + + let (funding_negotiation_context, _) = pending_splice.awaiting_ack_context("tx_ack_rbf")?; + + let our_funding_contribution = funding_negotiation_context.our_funding_contribution; + let their_funding_contribution = match msg.funding_output_contribution { + Some(value) => SignedAmount::from_sat(value), + None => SignedAmount::ZERO, + }; + self.validate_splice_contributions(our_funding_contribution, their_funding_contribution) + .map_err(|e| ChannelError::WarnAndDisconnect(e))?; + + let last_candidate = pending_splice.negotiated_candidates.last().ok_or_else(|| { + ChannelError::WarnAndDisconnect("No negotiated splice candidates for RBF".to_owned()) + })?; + let holder_pubkeys = last_candidate.get_holder_pubkeys().clone(); + let counterparty_funding_pubkey = *last_candidate.counterparty_funding_pubkey(); + + Ok(FundingScope::for_splice( + &self.funding, + &self.context, + our_funding_contribution, + their_funding_contribution, + counterparty_funding_pubkey, + holder_pubkeys, + )) + } + + pub(crate) fn tx_ack_rbf( + &mut self, msg: &msgs::TxAckRbf, entropy_source: &ES, holder_node_id: &PublicKey, + logger: &L, + ) -> Result, ChannelError> { + let rbf_funding = self.validate_tx_ack_rbf(msg)?; + + log_info!( + logger, + "Starting RBF funding negotiation for channel {} after receiving tx_ack_rbf; channel value: {} sats", + self.context.channel_id, + rbf_funding.get_value_satoshis(), + ); + + let pending_splice = self + .pending_splice + .as_mut() + .expect("pending_splice existence validated in validate_tx_ack_rbf"); + let funding_negotiation_context = pending_splice + .take_awaiting_ack_context("tx_ack_rbf") + .expect("awaiting ack state validated in validate_tx_ack_rbf"); + + let (funding_negotiation, tx_msg_opt) = FundingNegotiation::for_initiator( + rbf_funding, + &self.context, + funding_negotiation_context, + entropy_source, + holder_node_id, + ); + pending_splice.funding_negotiation = Some(funding_negotiation); + + Ok(tx_msg_opt) + } + pub(crate) fn splice_ack( &mut self, msg: &msgs::SpliceAck, entropy_source: &ES, holder_node_id: &PublicKey, logger: &L, @@ -12532,36 +12674,24 @@ where self.funding.get_value_satoshis(), ); - let pending_splice = - self.pending_splice.as_mut().expect("We should have returned an error earlier!"); - // TODO: Good candidate for a let else statement once MSRV >= 1.65 - let funding_negotiation_context = - if let Some(FundingNegotiation::AwaitingAck { context, .. }) = - pending_splice.funding_negotiation.take() - { - context - } else { - panic!("We should have returned an error earlier!"); - }; - - let funding_feerate_sat_per_1000_weight = - funding_negotiation_context.funding_feerate_sat_per_1000_weight; - let (interactive_tx_constructor, tx_msg_opt) = funding_negotiation_context - .into_interactive_tx_constructor( - &self.context, - &splice_funding, - entropy_source, - holder_node_id.clone(), - ); - debug_assert!(tx_msg_opt.is_some()); - debug_assert!(self.context.interactive_tx_signing_session.is_none()); - pending_splice.funding_negotiation = Some(FundingNegotiation::ConstructingTransaction { - funding: splice_funding, - funding_feerate_sat_per_1000_weight, - interactive_tx_constructor, - }); + let pending_splice = self + .pending_splice + .as_mut() + .expect("pending_splice existence validated in validate_splice_ack"); + let funding_negotiation_context = pending_splice + .take_awaiting_ack_context("splice_ack") + .expect("awaiting ack state validated in validate_splice_ack"); + + let (funding_negotiation, tx_msg_opt) = FundingNegotiation::for_initiator( + splice_funding, + &self.context, + funding_negotiation_context, + entropy_source, + holder_node_id, + ); + pending_splice.funding_negotiation = Some(funding_negotiation); Ok(tx_msg_opt) } @@ -12574,24 +12704,8 @@ where .as_ref() .ok_or_else(|| ChannelError::Ignore("Channel is not in pending splice".to_owned()))?; - let (funding_negotiation_context, new_holder_funding_key) = match &pending_splice - .funding_negotiation - { - Some(FundingNegotiation::AwaitingAck { context, new_holder_funding_key, .. }) => { - (context, new_holder_funding_key) - }, - Some(FundingNegotiation::ConstructingTransaction { .. }) - | Some(FundingNegotiation::AwaitingSignatures { .. }) => { - return Err(ChannelError::WarnAndDisconnect( - "Got unexpected splice_ack; splice negotiation already in progress".to_owned(), - )); - }, - None => { - return Err(ChannelError::Ignore( - "Got unexpected splice_ack; no splice negotiation in progress".to_owned(), - )); - }, - }; + let (funding_negotiation_context, new_holder_funding_key) = + pending_splice.awaiting_ack_context("splice_ack")?; let our_funding_contribution = funding_negotiation_context.our_funding_contribution; let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2c416e42486..330ed386628 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13064,6 +13064,50 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } } + fn internal_tx_ack_rbf( + &self, counterparty_node_id: &PublicKey, msg: &msgs::TxAckRbf, + ) -> Result<(), MsgHandleErrInternal> { + let per_peer_state = self.per_peer_state.read().unwrap(); + let peer_state_mutex = per_peer_state.get(counterparty_node_id).ok_or_else(|| { + MsgHandleErrInternal::unreachable_no_such_peer(counterparty_node_id, msg.channel_id) + })?; + let mut peer_state_lock = peer_state_mutex.lock().unwrap(); + let peer_state = &mut *peer_state_lock; + + // Look for the channel + match peer_state.channel_by_id.entry(msg.channel_id) { + hash_map::Entry::Vacant(_) => Err(MsgHandleErrInternal::no_such_channel_for_peer( + counterparty_node_id, + msg.channel_id, + )), + hash_map::Entry::Occupied(mut chan_entry) => { + if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { + let tx_ack_rbf_res = funded_channel.tx_ack_rbf( + msg, + &self.entropy_source, + &self.get_our_node_id(), + &self.logger, + ); + let tx_msg_opt = + try_channel_entry!(self, peer_state, tx_ack_rbf_res, chan_entry); + if let Some(tx_msg) = tx_msg_opt { + peer_state + .pending_msg_events + .push(tx_msg.into_msg_send_event(counterparty_node_id.clone())); + } + Ok(()) + } else { + try_channel_entry!( + self, + peer_state, + Err(ChannelError::close("Channel is not funded, cannot RBF splice".into())), + chan_entry + ) + } + }, + } + } + fn internal_splice_locked( &self, counterparty_node_id: &PublicKey, msg: &msgs::SpliceLocked, ) -> Result<(), MsgHandleErrInternal> { @@ -16485,11 +16529,16 @@ impl< } fn handle_tx_ack_rbf(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAckRbf) { - let err = Err(MsgHandleErrInternal::send_err_msg_no_close( - "Dual-funded channels not supported".to_owned(), - msg.channel_id.clone(), - )); - let _: Result<(), _> = self.handle_error(err, counterparty_node_id); + let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || { + let res = self.internal_tx_ack_rbf(&counterparty_node_id, msg); + let persist = match &res { + Err(e) if e.closes_channel() => NotifyOption::DoPersist, + Err(_) => NotifyOption::SkipPersistHandleEvents, + Ok(()) => NotifyOption::SkipPersistHandleEvents, + }; + let _ = self.handle_error(res, counterparty_node_id); + persist + }); } fn handle_tx_abort(&self, counterparty_node_id: PublicKey, msg: &msgs::TxAbort) { diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index d0fb29d1923..b09f5aaa4f2 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -234,6 +234,21 @@ pub fn do_initiate_splice_in<'a, 'b, 'c, 'd>( funding_contribution } +pub fn do_initiate_rbf_splice_in<'a, 'b, 'c, 'd>( + node: &'a Node<'b, 'c, 'd>, counterparty: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, + value_added: Amount, feerate: FeeRate, +) -> FundingContribution { + let node_id_counterparty = counterparty.node.get_our_node_id(); + let funding_template = + node.node.rbf_channel(&channel_id, &node_id_counterparty, feerate, FeeRate::MAX).unwrap(); + let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger); + let funding_contribution = funding_template.splice_in_sync(value_added, &wallet).unwrap(); + node.node + .funding_contributed(&channel_id, &node_id_counterparty, funding_contribution.clone(), None) + .unwrap(); + funding_contribution +} + pub fn initiate_splice_out<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, outputs: Vec, @@ -312,6 +327,25 @@ pub fn complete_splice_handshake<'a, 'b, 'c, 'd>( new_funding_script } +pub fn complete_rbf_handshake<'a, 'b, 'c, 'd>( + initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, +) -> msgs::TxAckRbf { + let node_id_initiator = initiator.node.get_our_node_id(); + let node_id_acceptor = acceptor.node.get_our_node_id(); + + let stfu_init = get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor); + acceptor.node.handle_stfu(node_id_initiator, &stfu_init); + let stfu_ack = get_event_msg!(acceptor, MessageSendEvent::SendStfu, node_id_initiator); + initiator.node.handle_stfu(node_id_acceptor, &stfu_ack); + + let tx_init_rbf = get_event_msg!(initiator, MessageSendEvent::SendTxInitRbf, node_id_acceptor); + acceptor.node.handle_tx_init_rbf(node_id_initiator, &tx_init_rbf); + let tx_ack_rbf = get_event_msg!(acceptor, MessageSendEvent::SendTxAckRbf, node_id_initiator); + initiator.node.handle_tx_ack_rbf(node_id_acceptor, &tx_ack_rbf); + + tx_ack_rbf +} + pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, initiator_contribution: FundingContribution, new_funding_script: ScriptBuf, @@ -4095,9 +4129,10 @@ fn test_splice_acceptor_disconnect_emits_events() { #[test] fn test_splice_rbf_acceptor_basic() { - // Test the happy path for accepting an RBF of a pending splice transaction. - // After completing a splice-in, initiate an RBF attempt with a higher feerate, - // going through the tx_init_rbf → tx_ack_rbf flow. + // 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]); @@ -4113,39 +4148,117 @@ fn test_splice_rbf_acceptor_basic() { let added_value = Amount::from_sat(50_000); provide_utxo_reserves(&nodes, 2, added_value * 2); - // Complete a splice-in from node 0. + // Step 1: Complete a splice-in from node 0. let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); - let (_splice_tx, _new_funding_script) = + // Save the pre-splice funding outpoint before splice_channel modifies the monitor. + let original_funding_outpoint = nodes[0] + .chain_monitor + .chain_monitor + .get_monitor(channel_id) + .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) + .unwrap(); + + let (first_splice_tx, new_funding_script) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); - // Initiate an RBF with a feerate satisfying the 25/24 rule. - // Original feerate was FEERATE_FLOOR_SATS_PER_KW (253). 253 * 25 / 24 = 263.54, so 264 works. + // 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).div_ceil(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, FeeRate::MAX).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(); + let funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); - nodes[0].node.funding_contributed(&channel_id, &node_id_1, funding_contribution, None).unwrap(); + // Steps 4-8: STFU exchange → tx_init_rbf → tx_ack_rbf. + complete_rbf_handshake(&nodes[0], &nodes[1]); - 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 9: Complete interactive funding negotiation. + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + funding_contribution, + new_funding_script.clone(), + ); - 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 10: Sign and broadcast. + let (rbf_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); - 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); + 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); - assert_eq!(tx_ack_rbf.channel_id, channel_id); - // Acceptor doesn't contribute funds in the RBF. - assert_eq!(tx_ack_rbf.funding_output_contribution, None); + // 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] @@ -4190,8 +4303,6 @@ fn test_splice_rbf_insufficient_feerate() { // Acceptor-side: tx_init_rbf with an insufficient feerate is also rejected. reenter_quiescence(&nodes[0], &nodes[1], &channel_id); - // Send tx_init_rbf with feerate that does NOT satisfy the 25/24 rule. - // Original feerate was 253. Using exactly 253 should fail since 253 * 24 < 253 * 25. let tx_init_rbf = msgs::TxInitRbf { channel_id, locktime: 0, @@ -4418,3 +4529,59 @@ fn test_splice_rbf_zeroconf_rejected() { _ => panic!("Expected HandleError, got {:?}", msg_events[0]), } } + +#[test] +fn test_splice_rbf_not_quiescence_initiator() { + // Test that tx_init_rbf from the non-quiescence-initiator is rejected because the + // quiescence initiator's RBF flow has already set funding_negotiation to AwaitingAck. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete a splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Provide more UTXO reserves for the RBF attempt. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Initiate RBF from node 0 (quiescence initiator). + let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); + let _funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + + // STFU exchange: node 0 initiates quiescence. + let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_init); + let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_ack); + + // Node 0 sends tx_init_rbf as the quiescence initiator — grab and discard. + let _tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + + // Now craft a competing tx_init_rbf from node 1 (the non-initiator). + let tx_init_rbf = msgs::TxInitRbf { + channel_id, + locktime: 0, + feerate_sat_per_1000_weight: 500, + funding_output_contribution: Some(added_value.to_sat() as i64), + }; + + nodes[0].node.handle_tx_init_rbf(node_id_1, &tx_init_rbf); + + let tx_abort = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1); + assert_eq!(tx_abort.channel_id, channel_id); +} From d8081559a8e3f61a59d480cfe12a580fcf09fbf5 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 18 Feb 2026 21:51:37 -0600 Subject: [PATCH 07/11] Allow acceptor contribution to RBF splice via tx_init_rbf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the tx_init_rbf acceptor always contributed zero to the RBF transaction. This is incorrect when both parties try to RBF simultaneously and one loses the quiescence tie-breaker — the loser becomes the acceptor but still has a pending QuiescentAction::Splice with inputs/outputs that should be included in the RBF transaction. Consume the acceptor's QuiescentAction in the tx_init_rbf handler, just as is already done in the splice_init handler, and report the contribution in the TxAckRbf response. --- .../src/upgrade_downgrade_tests.rs | 2 +- lightning/src/ln/channel.rs | 33 +- lightning/src/ln/splicing_tests.rs | 462 ++++++++++++++---- lightning/src/util/test_utils.rs | 4 + 4 files changed, 403 insertions(+), 98 deletions(-) diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index f68615dbb87..7f607bba848 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -466,7 +466,7 @@ fn do_test_0_1_htlc_forward_after_splice(fail_htlc: bool) { } let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_b_id); - lock_splice(&nodes[0], &nodes[1], &splice_locked, false); + lock_splice(&nodes[0], &nodes[1], &splice_locked, false, &[]); for node in nodes.iter() { connect_blocks(node, EXTRA_BLOCKS_BEFORE_FAIL - ANTI_REORG_DELAY); diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 96a147a9e6b..5587d429b24 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12562,9 +12562,26 @@ where &mut self, msg: &msgs::TxInitRbf, entropy_source: &ES, holder_node_id: &PublicKey, fee_estimator: &LowerBoundedFeeEstimator, logger: &L, ) -> Result { - let our_funding_contribution = SignedAmount::ZERO; - let rbf_funding = - self.validate_tx_init_rbf(msg, our_funding_contribution, fee_estimator)?; + let feerate = FeeRate::from_sat_per_kwu(msg.feerate_sat_per_1000_weight as u64); + let (our_funding_contribution, holder_balance) = + self.resolve_queued_contribution(feerate, logger); + + let rbf_funding = self.validate_tx_init_rbf( + msg, + our_funding_contribution.unwrap_or(SignedAmount::ZERO), + fee_estimator, + )?; + + let (our_funding_inputs, our_funding_outputs) = if our_funding_contribution.is_some() { + self.take_queued_funding_contribution() + .expect("queued_funding_contribution was Some") + .for_acceptor_at_feerate(feerate, holder_balance.unwrap()) + .expect("feerate compatibility already checked") + .into_tx_parts() + } else { + Default::default() + }; + let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO); log_info!( logger, @@ -12583,15 +12600,19 @@ where prev_funding_input, msg.locktime, msg.feerate_sat_per_1000_weight, - Vec::new(), - Vec::new(), + our_funding_inputs, + our_funding_outputs, ); let pending_splice = self.pending_splice.as_mut().expect("pending_splice should exist"); pending_splice.funding_negotiation = Some(funding_negotiation); Ok(msgs::TxAckRbf { channel_id: self.context.channel_id, - funding_output_contribution: None, + funding_output_contribution: if our_funding_contribution != SignedAmount::ZERO { + Some(our_funding_contribution.to_sat()) + } else { + None + }, }) } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index b09f5aaa4f2..f7b867ca928 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -40,7 +40,8 @@ use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::transaction::Version; use bitcoin::{ - Amount, FeeRate, OutPoint as BitcoinOutPoint, Psbt, ScriptBuf, Transaction, TxOut, WPubkeyHash, + Amount, FeeRate, OutPoint as BitcoinOutPoint, Psbt, ScriptBuf, Transaction, TxOut, Txid, + WPubkeyHash, }; #[test] @@ -664,18 +665,18 @@ pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>( let node_id_b = node_b.node.get_our_node_id(); let splice_locked_for_node_b = get_event_msg!(node_a, MessageSendEvent::SendSpliceLocked, node_id_b); - lock_splice(node_a, node_b, &splice_locked_for_node_b, false) + lock_splice(node_a, node_b, &splice_locked_for_node_b, false, &[]) } pub fn lock_splice<'a, 'b, 'c, 'd>( node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, - splice_locked_for_node_b: &msgs::SpliceLocked, is_0conf: bool, + splice_locked_for_node_b: &msgs::SpliceLocked, is_0conf: bool, expected_discard_txids: &[Txid], ) -> Option { - let (prev_funding_outpoint, prev_funding_script) = node_a + let prev_funding_txid = node_a .chain_monitor .chain_monitor .get_monitor(splice_locked_for_node_b.channel_id) - .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) + .map(|monitor| monitor.get_funding_txo().txid) .unwrap(); let node_id_a = node_a.node.get_our_node_id(); @@ -707,10 +708,32 @@ pub fn lock_splice<'a, 'b, 'c, 'd>( } } - expect_channel_ready_event(&node_a, &node_id_b); - check_added_monitors(&node_a, 1); - expect_channel_ready_event(&node_b, &node_id_a); - check_added_monitors(&node_b, 1); + let mut all_discard_txids = Vec::new(); + let expected_num_events = 1 + expected_discard_txids.len(); + for node in [node_a, node_b] { + let events = node.node.get_and_clear_pending_events(); + assert_eq!(events.len(), expected_num_events, "{events:?}"); + assert!(matches!(events[0], Event::ChannelReady { .. })); + let discard_txids: Vec<_> = events[1..] + .iter() + .map(|e| match e { + Event::DiscardFunding { funding_info: FundingInfo::Tx { transaction }, .. } => { + transaction.compute_txid() + }, + Event::DiscardFunding { + funding_info: FundingInfo::OutPoint { outpoint }, .. + } => outpoint.txid, + other => panic!("Expected DiscardFunding, got {:?}", other), + }) + .collect(); + for txid in expected_discard_txids { + assert!(discard_txids.contains(txid), "Missing DiscardFunding for txid {}", txid); + } + if all_discard_txids.is_empty() { + all_discard_txids = discard_txids; + } + check_added_monitors(node, 1); + } if !is_0conf { let mut msg_events = node_a.node.get_and_clear_pending_msg_events(); @@ -735,14 +758,32 @@ pub fn lock_splice<'a, 'b, 'c, 'd>( // Remove the corresponding outputs and transactions the chain source is watching for the // old funding as it is no longer being tracked. - node_a - .chain_source - .remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script.clone()); - node_b.chain_source.remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script); + for node in [node_a, node_b] { + node.chain_source.remove_watched_by_txid(prev_funding_txid); + for txid in &all_discard_txids { + node.chain_source.remove_watched_by_txid(*txid); + } + } node_b_stfu } +pub fn lock_rbf_splice_after_blocks<'a, 'b, 'c, 'd>( + node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, tx: &Transaction, num_blocks: u32, + expected_discard_txids: &[Txid], +) -> Option { + mine_transaction(node_a, tx); + mine_transaction(node_b, tx); + + connect_blocks(node_a, num_blocks); + connect_blocks(node_b, num_blocks); + + let node_id_b = node_b.node.get_our_node_id(); + let splice_locked_for_node_b = + get_event_msg!(node_a, MessageSendEvent::SendSpliceLocked, node_id_b); + lock_splice(node_a, node_b, &splice_locked_for_node_b, false, expected_discard_txids) +} + #[test] fn test_splice_state_reset_on_disconnect() { do_test_splice_state_reset_on_disconnect(false); @@ -2962,13 +3003,13 @@ fn do_test_splice_with_inflight_htlc_forward_and_resolution(expire_scid_pre_forw connect_blocks(node, ANTI_REORG_DELAY - 2); } let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); - lock_splice(&nodes[0], &nodes[1], &splice_locked, false); + lock_splice(&nodes[0], &nodes[1], &splice_locked, false, &[]); for node in &nodes { connect_blocks(node, 1); } let splice_locked = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceLocked, node_id_2); - lock_splice(&nodes[1], &nodes[2], &splice_locked, false); + lock_splice(&nodes[1], &nodes[2], &splice_locked, false, &[]); if expire_scid_pre_forward { for node in &nodes { @@ -4150,13 +4191,6 @@ fn test_splice_rbf_acceptor_basic() { // 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); @@ -4190,75 +4224,14 @@ fn test_splice_rbf_acceptor_basic() { 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()); - } + // Step 11: Mine, lock, and verify DiscardFunding for the replaced splice candidate. + lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx, + ANTI_REORG_DELAY - 1, + &[first_splice_tx.compute_txid()], + ); } #[test] @@ -4585,3 +4558,310 @@ fn test_splice_rbf_not_quiescence_initiator() { let tx_abort = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1); assert_eq!(tx_abort.channel_id, channel_id); } + +#[test] +fn test_splice_rbf_both_contribute_tiebreak() { + let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let feerate = FeeRate::from_sat_per_kwu(min_rbf_feerate); + let added_value = Amount::from_sat(50_000); + do_test_splice_rbf_tiebreak(feerate, feerate, added_value, true); +} + +#[test] +fn test_splice_rbf_tiebreak_higher_feerate() { + // Node 0 (winner) uses a higher feerate than node 1 (loser). Node 1's change output is + // adjusted (reduced) to accommodate the higher feerate. Negotiation succeeds. + let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + do_test_splice_rbf_tiebreak( + FeeRate::from_sat_per_kwu(min_rbf_feerate * 3), + FeeRate::from_sat_per_kwu(min_rbf_feerate), + Amount::from_sat(50_000), + true, + ); +} + +#[test] +fn test_splice_rbf_tiebreak_lower_feerate() { + // Node 0 (winner) uses a lower feerate than node 1 (loser). Since the initiator's feerate + // is below node 1's minimum, node 1 proceeds without contribution and will retry via a new + // splice at its preferred feerate after the RBF locks. + let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + do_test_splice_rbf_tiebreak( + FeeRate::from_sat_per_kwu(min_rbf_feerate), + FeeRate::from_sat_per_kwu(min_rbf_feerate * 3), + Amount::from_sat(50_000), + false, + ); +} + +#[test] +fn test_splice_rbf_tiebreak_feerate_too_high() { + // Node 0 (winner) uses a feerate high enough that node 1's (loser) contribution cannot + // cover the fees. Node 1 proceeds without its contribution (QuiescentAction is preserved + // for a future splice). The RBF completes with only node 0's inputs/outputs. + let min_rbf_feerate = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + do_test_splice_rbf_tiebreak( + FeeRate::from_sat_per_kwu(20_000), + FeeRate::from_sat_per_kwu(min_rbf_feerate), + Amount::from_sat(95_000), + false, + ); +} + +/// Runs the tie-breaker test with the given per-node feerates and node 1's splice value. +/// +/// Both nodes call `rbf_channel` + `funding_contributed`, both send STFU, and node 0 (the outbound +/// channel funder) wins the quiescence tie-break. The loser (node 1) becomes the acceptor. Whether +/// node 1 contributes to the RBF transaction depends on the feerate and budget constraints. +/// +/// `expect_acceptor_contributes` asserts the expected outcome: whether node 1's `tx_ack_rbf` +/// includes a funding output contribution. +pub fn do_test_splice_rbf_tiebreak( + rbf_feerate_0: FeeRate, rbf_feerate_1: FeeRate, node_1_splice_value: Amount, + expect_acceptor_contributes: bool, +) { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + // Complete an initial splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (first_splice_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Provide more UTXOs for both nodes' RBF attempts. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Node 0 calls rbf_channel + funding_contributed. + let node_0_funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_0); + + // Node 1 calls rbf_channel + funding_contributed. + let node_1_funding_contribution = do_initiate_rbf_splice_in( + &nodes[1], + &nodes[0], + channel_id, + node_1_splice_value, + rbf_feerate_1, + ); + + // Both nodes sent STFU (both have awaiting_quiescence set). + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + assert!(stfu_0.initiator); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + assert!(stfu_1.initiator); + + // Exchange STFUs. Node 0 is the outbound channel funder and wins the tie-break. + // Node 1 handles node 0's STFU first — it already sent its own STFU (local_stfu_sent is set), + // so this goes through the tie-break path. Node 1 loses (is_outbound = false) and becomes the + // acceptor. Its quiescent_action is preserved for the tx_init_rbf handler. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + // Node 0 handles node 1's STFU — it already sent its own STFU, so tie-break again. + // Node 0 wins (is_outbound = true), consumes its quiescent_action, and sends tx_init_rbf. + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Node 0 sends tx_init_rbf. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.channel_id, channel_id); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, rbf_feerate_0.to_sat_per_kwu() as u32); + + // Node 1 handles tx_init_rbf — its quiescent_action is consumed, adjusting its contribution + // for node 0's feerate. Whether it contributes depends on the feerate and budget constraints. + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); + assert_eq!(tx_ack_rbf.channel_id, channel_id); + + // Node 0 handles tx_ack_rbf. + let acceptor_contributes = tx_ack_rbf.funding_output_contribution.is_some(); + assert_eq!( + acceptor_contributes, expect_acceptor_contributes, + "Expected acceptor contribution: {}, got: {}", + expect_acceptor_contributes, acceptor_contributes, + ); + nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf); + + if acceptor_contributes { + // Capture change output values for assertions. + let node_0_change = node_0_funding_contribution + .change_output() + .expect("splice-in should have a change output") + .clone(); + let node_1_change = node_1_funding_contribution + .change_output() + .expect("splice-in should have a change output") + .clone(); + + // Complete interactive funding negotiation with both parties' inputs/outputs. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + Some(node_1_funding_contribution), + tx_ack_rbf.funding_output_contribution.unwrap(), + new_funding_script.clone(), + ); + + // Sign (acceptor has contribution) and broadcast. + let (rbf_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( + &nodes[0], &nodes[1], false, true, + ); + assert!(splice_locked.is_none()); + + // The initiator's change output should remain unchanged (no feerate adjustment). + let initiator_change_in_tx = rbf_tx + .output + .iter() + .find(|o| o.script_pubkey == node_0_change.script_pubkey) + .expect("Initiator's change output should be in the RBF transaction"); + assert_eq!( + initiator_change_in_tx.value, node_0_change.value, + "Initiator's change output should remain unchanged", + ); + + // The acceptor's change output should be adjusted based on the feerate difference. + let acceptor_change_in_tx = rbf_tx + .output + .iter() + .find(|o| o.script_pubkey == node_1_change.script_pubkey) + .expect("Acceptor's change output should be in the RBF transaction"); + if rbf_feerate_0 <= rbf_feerate_1 { + // Initiator's feerate <= acceptor's original: the acceptor's change increases because + // is_initiator=false has lower weight, and the feerate is the same or lower. + assert!( + acceptor_change_in_tx.value > node_1_change.value, + "Acceptor's change should increase when initiator feerate ({}) <= acceptor \ + feerate ({}): adjusted {} vs original {}", + rbf_feerate_0.to_sat_per_kwu(), + rbf_feerate_1.to_sat_per_kwu(), + acceptor_change_in_tx.value, + node_1_change.value, + ); + } else { + // Initiator's feerate > acceptor's original: the higher feerate more than compensates + // for the lower weight, so the acceptor's change decreases. + assert!( + acceptor_change_in_tx.value < node_1_change.value, + "Acceptor's change should decrease when initiator feerate ({}) > acceptor \ + feerate ({}): adjusted {} vs original {}", + rbf_feerate_0.to_sat_per_kwu(), + rbf_feerate_1.to_sat_per_kwu(), + acceptor_change_in_tx.value, + node_1_change.value, + ); + } + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Mine, lock, and verify DiscardFunding for the replaced splice candidate. + lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx, + ANTI_REORG_DELAY - 1, + &[first_splice_tx.compute_txid()], + ); + } else { + // Acceptor does not contribute — complete with only node 0's inputs/outputs. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + None, + 0, + new_funding_script.clone(), + ); + + // Sign (acceptor has no contribution) and broadcast. + let (rbf_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution( + &nodes[0], &nodes[1], false, false, + ); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Mine, lock, and verify DiscardFunding for the replaced splice candidate. + // Node 1's QuiescentAction was preserved, so after splice_locked it re-initiates + // quiescence to retry its contribution in a future splice. + let node_b_stfu = lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx, + ANTI_REORG_DELAY - 1, + &[first_splice_tx.compute_txid()], + ); + let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = node_b_stfu { + msg + } else { + panic!("Expected SendStfu from node 1"); + }; + assert!(stfu_1.initiator); + + // === 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); + } +} diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 22be4367c7a..6c19af55f60 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -2143,6 +2143,10 @@ impl TestChainSource { self.watched_outputs.lock().unwrap().remove(&(outpoint, script_pubkey.clone())); self.watched_txn.lock().unwrap().remove(&(outpoint.txid, script_pubkey)); } + pub fn remove_watched_by_txid(&self, txid: Txid) { + self.watched_outputs.lock().unwrap().retain(|(op, _)| op.txid != txid); + self.watched_txn.lock().unwrap().retain(|(tid, _)| *tid != txid); + } } impl UtxoLookup for TestChainSource { From 99390f0156ff78ea80c1806a51edf992f32bf395 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 23 Feb 2026 15:17:25 -0600 Subject: [PATCH 08/11] Preserve our funding contribution across counterparty RBF attempts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the counterparty initiates an RBF and we have no new contribution queued via QuiescentAction, we must re-use our prior contribution so that our splice is not lost. Track contributions in a new field on PendingFunding so the last entry can be re-used in this scenario. Each entry stores the feerate-adjusted version because that reflects what was actually negotiated and allows correct feerate re-adjustment on subsequent RBFs. Only explicitly provided contributions (from a QuiescentAction) append to the vec. Re-used contributions are replaced in-place with the version adjusted for the new feerate so they remain accurate for further RBF rounds, without growing the vec. Add test_splice_rbf_acceptor_recontributes to verify that when the counterparty initiates an RBF and we have no new QuiescentAction queued, our prior contribution is automatically re-used so the splice is preserved. Add test_splice_rbf_recontributes_feerate_too_high to verify that when the counterparty RBFs at a feerate too high for our prior contribution to cover, the RBF is rejected rather than proceeding without our contribution. Add test for sequential RBF splice attempts Add test_splice_rbf_sequential that exercises three consecutive RBF rounds on the same splice (initial → RBF #1 → RBF #2) to verify: - Each round requires the 25/24 feerate increase (253 → 264 → 275) - DiscardFunding events reference the correct funding txid from each replaced candidate - The final RBF splice can be mined and splice_locked successfully Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 97 ++++++- lightning/src/ln/funding.rs | 11 + lightning/src/ln/splicing_tests.rs | 444 +++++++++++++++++++++++++++++ 3 files changed, 537 insertions(+), 15 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 5587d429b24..741da76d047 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2902,6 +2902,13 @@ struct PendingFunding { /// The feerate used in the last successfully negotiated funding transaction. /// Used for validating the 25/24 feerate increase rule on RBF attempts. last_funding_feerate_sat_per_1000_weight: Option, + + /// The funding contributions from all explicit splice/RBF attempts on this channel. + /// Each entry reflects the feerate-adjusted contribution that was actually used in that + /// negotiation. The last entry is re-used when the counterparty initiates an RBF and we + /// have no pending `QuiescentAction`. When re-used as acceptor, the last entry is replaced + /// with the version adjusted for the new feerate. + contributions: Vec, } impl_writeable_tlv_based!(PendingFunding, { @@ -2910,6 +2917,7 @@ impl_writeable_tlv_based!(PendingFunding, { (5, sent_funding_txid, option), (7, received_funding_txid, option), (8, last_funding_feerate_sat_per_1000_weight, option), + (10, contributions, optional_vec), }); #[derive(Debug)] @@ -12149,6 +12157,7 @@ where sent_funding_txid: None, received_funding_txid: None, last_funding_feerate_sat_per_1000_weight: None, + contributions: vec![], }); msgs::SpliceInit { @@ -12415,15 +12424,19 @@ where let splice_funding = self.validate_splice_init(msg, our_funding_contribution.unwrap_or(SignedAmount::ZERO))?; - let (our_funding_inputs, our_funding_outputs) = if our_funding_contribution.is_some() { - self.take_queued_funding_contribution() - .expect("queued_funding_contribution was Some") - .for_acceptor_at_feerate(feerate, holder_balance.unwrap()) - .expect("feerate compatibility already checked") - .into_tx_parts() - } else { - Default::default() - }; + // Adjust for the feerate and clone so we can store it for future RBF re-use. + let (adjusted_contribution, our_funding_inputs, our_funding_outputs) = + if our_funding_contribution.is_some() { + let adjusted_contribution = self + .take_queued_funding_contribution() + .expect("queued_funding_contribution was Some") + .for_acceptor_at_feerate(feerate, holder_balance.unwrap()) + .expect("feerate compatibility already checked"); + let (inputs, outputs) = adjusted_contribution.clone().into_tx_parts(); + (Some(adjusted_contribution), inputs, outputs) + } else { + (None, Default::default(), Default::default()) + }; let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO); log_info!( @@ -12454,6 +12467,7 @@ where received_funding_txid: None, sent_funding_txid: None, last_funding_feerate_sat_per_1000_weight: None, + contributions: adjusted_contribution.into_iter().collect(), }); Ok(msgs::SpliceAck { @@ -12563,8 +12577,30 @@ where fee_estimator: &LowerBoundedFeeEstimator, logger: &L, ) -> Result { let feerate = FeeRate::from_sat_per_kwu(msg.feerate_sat_per_1000_weight as u64); - let (our_funding_contribution, holder_balance) = - self.resolve_queued_contribution(feerate, logger); + let (queued_net_value, holder_balance) = self.resolve_queued_contribution(feerate, logger); + + // If no queued contribution, try prior contribution from previous negotiation. + // Failing here means the RBF would erase our splice — reject it. + let prior_net_value = if queued_net_value.is_some() { + None + } else if let Some(prior) = self + .pending_splice + .as_ref() + .and_then(|pending_splice| pending_splice.contributions.last()) + { + let net_value = holder_balance + .ok_or_else(|| ChannelError::Abort(AbortReason::InsufficientRbfFeerate)) + .and_then(|holder_balance| { + prior + .net_value_for_acceptor_at_feerate(feerate, holder_balance) + .map_err(|_| ChannelError::Abort(AbortReason::InsufficientRbfFeerate)) + })?; + Some(net_value) + } else { + None + }; + + let our_funding_contribution = queued_net_value.or(prior_net_value); let rbf_funding = self.validate_tx_init_rbf( msg, @@ -12572,15 +12608,40 @@ where fee_estimator, )?; - let (our_funding_inputs, our_funding_outputs) = if our_funding_contribution.is_some() { - self.take_queued_funding_contribution() + // Consume the appropriate contribution source. + let (our_funding_inputs, our_funding_outputs) = if queued_net_value.is_some() { + let adjusted_contribution = self + .take_queued_funding_contribution() .expect("queued_funding_contribution was Some") .for_acceptor_at_feerate(feerate, holder_balance.unwrap()) - .expect("feerate compatibility already checked") - .into_tx_parts() + .expect("feerate compatibility already checked"); + self.pending_splice + .as_mut() + .expect("pending_splice is Some") + .contributions + .push(adjusted_contribution.clone()); + adjusted_contribution.into_tx_parts() + } else if prior_net_value.is_some() { + let prior_contribution = self + .pending_splice + .as_mut() + .expect("pending_splice is Some") + .contributions + .pop() + .expect("prior_net_value was Some"); + let adjusted_contribution = prior_contribution + .for_acceptor_at_feerate(feerate, holder_balance.unwrap()) + .expect("feerate compatibility already checked"); + self.pending_splice + .as_mut() + .expect("pending_splice is Some") + .contributions + .push(adjusted_contribution.clone()); + adjusted_contribution.into_tx_parts() } else { Default::default() }; + let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO); log_info!( @@ -13567,6 +13628,7 @@ where )); }, Some(QuiescentAction::Splice { contribution, locktime }) => { + let prior_contribution = contribution.clone(); let prev_funding_input = self.funding.to_splice_funding_input(); let our_funding_contribution = contribution.net_value(); let funding_feerate_per_kw = contribution.feerate().to_sat_per_kwu() as u32; @@ -13584,10 +13646,15 @@ where if self.pending_splice.is_some() { let tx_init_rbf = self.send_tx_init_rbf(context); + self.pending_splice.as_mut().unwrap() + .contributions.push(prior_contribution); return Ok(Some(StfuResponse::TxInitRbf(tx_init_rbf))); } let splice_init = self.send_splice_init(context); + debug_assert!(self.pending_splice.is_some()); + self.pending_splice.as_mut().unwrap() + .contributions.push(prior_contribution); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, #[cfg(any(test, fuzzing, feature = "_test_utils"))] diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 84c9d4dd343..7c1bada94c3 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -388,6 +388,17 @@ pub struct FundingContribution { is_splice: bool, } +impl_writeable_tlv_based!(FundingContribution, { + (1, value_added, required), + (3, estimated_fee, required), + (5, inputs, optional_vec), + (7, outputs, optional_vec), + (9, change_output, option), + (11, feerate, required), + (13, max_feerate, required), + (15, is_splice, required), +}); + impl FundingContribution { pub(super) fn feerate(&self) -> FeeRate { self.feerate diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index f7b867ca928..656d3c14057 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -4865,3 +4865,447 @@ pub fn do_test_splice_rbf_tiebreak( 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, FeeRate::MAX).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(added_value, &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + let funding_template_1 = + nodes[1].node.splice_channel(&channel_id, &node_id_0, feerate, FeeRate::MAX).unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Step 2: Both send STFU; tiebreak: node 0 wins. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Step 3: Node 0 sends SpliceInit, node 1 handles as acceptor (QuiescentAction consumed). + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert_ne!(splice_ack.funding_contribution_satoshis, 0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + // Complete interactive funding with both contributions. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + Some(node_1_funding_contribution.clone()), + splice_ack.funding_contribution_satoshis, + new_funding_script.clone(), + ); + + let (first_splice_tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + assert!(splice_locked.is_none()); + + 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).div_ceil(24); + let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); + let rbf_funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + + // Steps 6-9: STFU exchange → tx_init_rbf → tx_ack_rbf. + // Node 1 should re-contribute via our_prior_contribution. + let tx_ack_rbf = complete_rbf_handshake(&nodes[0], &nodes[1]); + assert!( + tx_ack_rbf.funding_output_contribution.is_some(), + "Acceptor should re-contribute via our_prior_contribution" + ); + + // Step 10: Complete interactive funding with both contributions. + // Node 1's prior contribution is re-used — pass a clone for matching. + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + rbf_funding_contribution, + Some(node_1_funding_contribution), + tx_ack_rbf.funding_output_contribution.unwrap(), + new_funding_script.clone(), + ); + + // Step 11: Sign (acceptor has contribution) and broadcast. + let (rbf_tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Step 12: Mine, lock, and verify DiscardFunding for the replaced splice candidate. + lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx, + ANTI_REORG_DELAY - 1, + &[first_splice_tx.compute_txid()], + ); +} + +#[test] +fn test_splice_rbf_recontributes_feerate_too_high() { + // When the counterparty RBFs at a feerate too high for our prior contribution, + // we should reject the RBF rather than proceeding without our contribution. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + // Step 1: Both nodes initiate a splice. Node 0 at floor feerate, node 1 splices in 95k + // from a 100k UTXO (tight budget: ~5k for change/fees). + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + + let funding_template_0 = + nodes[0].node.splice_channel(&channel_id, &node_id_1, floor_feerate, FeeRate::MAX).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(Amount::from_sat(50_000), &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + let node_1_added_value = Amount::from_sat(95_000); + let funding_template_1 = + nodes[1].node.splice_channel(&channel_id, &node_id_0, floor_feerate, FeeRate::MAX).unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(node_1_added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Step 2: Both send STFU; tiebreak: node 0 wins. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Step 3: Complete the initial splice with both contributing. + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + assert_ne!(splice_ack.funding_contribution_satoshis, 0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + complete_interactive_funding_negotiation_for_both( + &nodes[0], + &nodes[1], + channel_id, + node_0_funding_contribution, + Some(node_1_funding_contribution), + splice_ack.funding_contribution_satoshis, + new_funding_script.clone(), + ); + + let (_first_splice_tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Step 4: Provide new UTXOs. Node 0 initiates RBF at 20,000 sat/kwu. + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + let high_feerate = FeeRate::from_sat_per_kwu(20_000); + let funding_template = + nodes[0].node.rbf_channel(&channel_id, &node_id_1, high_feerate, FeeRate::MAX).unwrap(); + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let rbf_funding_contribution = + funding_template.splice_in_sync(Amount::from_sat(50_000), &wallet).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, rbf_funding_contribution.clone(), None) + .unwrap(); + + // Step 5: STFU exchange. + let stfu_a = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_a); + let stfu_b = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_b); + + // Step 6: Node 0 sends tx_init_rbf at 20,000 sat/kwu. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, high_feerate.to_sat_per_kwu() as u32); + + // Step 7: Node 1's prior contribution (95k from 100k UTXO) can't cover fees at 20k sat/kwu. + // Should reject with tx_abort rather than proceeding without contribution. + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!(tx_abort.channel_id, channel_id); +} + +#[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); + + // --- 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).div_ceil(24); // 264 + let feerate_2_sat_per_kwu = (feerate_1_sat_per_kwu * 25).div_ceil(24); + + // --- Round 1: RBF #1 at feerate 264. --- + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let rbf_feerate_1 = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu); + let funding_contribution_1 = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_1); + complete_rbf_handshake(&nodes[0], &nodes[1]); + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + funding_contribution_1, + new_funding_script.clone(), + ); + let (splice_tx_1, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // --- Round 2: RBF #2 at feerate 275. --- + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let rbf_feerate_2 = FeeRate::from_sat_per_kwu(feerate_2_sat_per_kwu); + let funding_contribution_2 = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_2); + complete_rbf_handshake(&nodes[0], &nodes[1]); + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + funding_contribution_2, + new_funding_script.clone(), + ); + let (rbf_tx_final, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // --- Mine and lock the final RBF, verifying DiscardFunding for both replaced candidates. --- + let splice_tx_0_txid = splice_tx_0.compute_txid(); + let splice_tx_1_txid = splice_tx_1.compute_txid(); + lock_rbf_splice_after_blocks( + &nodes[0], + &nodes[1], + &rbf_tx_final, + ANTI_REORG_DELAY - 1, + &[splice_tx_0_txid, splice_tx_1_txid], + ); +} + +#[test] +fn test_splice_rbf_acceptor_contributes_then_disconnects() { + // When both nodes contribute to a splice and the initiator RBFs (with the acceptor + // re-contributing via prior contribution), disconnecting mid-interactive-TX should emit + // SpliceFailed + DiscardFunding for both nodes so each can reclaim their UTXOs. + 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)); + + // --- Round 0: Both nodes initiate splice-in (tiebreak: node 0 wins). --- + let node_0_funding_contribution = + do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let node_1_funding_contribution = + do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value); + + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + 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.clone()), + splice_ack.funding_contribution_satoshis, + new_funding_script.clone(), + ); + + let (_first_splice_tx, splice_locked) = + sign_interactive_funding_tx_with_acceptor_contribution(&nodes[0], &nodes[1], false, true); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // --- Round 1: Node 0 initiates RBF; node 1 re-contributes via prior. --- + provide_utxo_reserves(&nodes, 2, added_value * 2); + + let rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); + let _rbf_funding_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate); + + let tx_ack_rbf = complete_rbf_handshake(&nodes[0], &nodes[1]); + assert!( + tx_ack_rbf.funding_output_contribution.is_some(), + "Acceptor should re-contribute via prior contribution" + ); + + // Disconnect mid-interactive-TX negotiation. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + // The initiator should get SpliceFailed + DiscardFunding. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id), + other => panic!("Expected SpliceFailed, got {:?}", other), + } + match &events[1] { + Event::DiscardFunding { funding_info: FundingInfo::Contribution { .. }, .. } => {}, + other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), + } + + // The acceptor should also get SpliceFailed + DiscardFunding with its contributed + // inputs/outputs so it can reclaim its UTXOs. + let events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id), + other => panic!("Expected SpliceFailed, got {:?}", other), + } + match &events[1] { + Event::DiscardFunding { + funding_info: FundingInfo::Contribution { inputs, outputs }, + .. + } => { + assert!(!inputs.is_empty(), "Expected acceptor inputs, got empty"); + assert!(!outputs.is_empty(), "Expected acceptor outputs, got empty"); + }, + other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), + } + + // Reconnect. + let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_nodes(reconnect_args); +} From 7ad073d586633c403011724941a00fa460d79638 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 23 Feb 2026 20:32:32 -0600 Subject: [PATCH 09/11] Consider prior contributions when filtering unique inputs/outputs When funding_contributed is called while a splice negotiation is already in progress, unique contributions are computed to determine what to return via FailSplice or DiscardFunding. Without considering negotiated candidates stored in PendingFunding::contributions, UTXOs locked in earlier candidates could be incorrectly returned as reclaimable. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 741da76d047..f5272e26305 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3074,6 +3074,14 @@ impl PendingFunding { } } + fn contributed_inputs(&self) -> impl Iterator + '_ { + self.contributions.iter().flat_map(|c| c.contributed_inputs()) + } + + fn contributed_outputs(&self) -> impl Iterator + '_ { + self.contributions.iter().flat_map(|c| c.contributed_outputs()) + } + fn check_get_splice_locked( &mut self, context: &ChannelContext, confirmed_funding_index: usize, height: u32, ) -> Option { @@ -12040,9 +12048,16 @@ where if let Some(QuiescentAction::Splice { contribution: existing, .. }) = &self.quiescent_action { + let pending_splice = self.pending_splice.as_ref(); + let prior_inputs = pending_splice + .into_iter() + .flat_map(|pending_splice| pending_splice.contributed_inputs()); + let prior_outputs = pending_splice + .into_iter() + .flat_map(|pending_splice| pending_splice.contributed_outputs()); return match contribution.into_unique_contributions( - existing.contributed_inputs(), - existing.contributed_outputs(), + existing.contributed_inputs().chain(prior_inputs), + existing.contributed_outputs().chain(prior_outputs), ) { None => Err(QuiescentError::DoNothing), Some((inputs, outputs)) => Err(QuiescentError::DiscardFunding { inputs, outputs }), @@ -12056,17 +12071,21 @@ where .filter(|funding_negotiation| funding_negotiation.is_initiator()); if let Some(funding_negotiation) = initiated_funding_negotiation { + let pending_splice = + self.pending_splice.as_ref().expect("funding negotiation implies pending splice"); + let prior_inputs = pending_splice.contributed_inputs(); + let prior_outputs = pending_splice.contributed_outputs(); let unique_contributions = match funding_negotiation { FundingNegotiation::AwaitingAck { context, .. } => contribution .into_unique_contributions( - context.contributed_inputs(), - context.contributed_outputs(), + context.contributed_inputs().chain(prior_inputs), + context.contributed_outputs().chain(prior_outputs), ), FundingNegotiation::ConstructingTransaction { interactive_tx_constructor, .. } => contribution.into_unique_contributions( - interactive_tx_constructor.contributed_inputs(), - interactive_tx_constructor.contributed_outputs(), + interactive_tx_constructor.contributed_inputs().chain(prior_inputs), + interactive_tx_constructor.contributed_outputs().chain(prior_outputs), ), FundingNegotiation::AwaitingSignatures { .. } => { let session = self @@ -12075,8 +12094,8 @@ where .as_ref() .expect("pending splice awaiting signatures"); contribution.into_unique_contributions( - session.contributed_inputs(), - session.contributed_outputs(), + session.contributed_inputs().chain(prior_inputs), + session.contributed_outputs().chain(prior_outputs), ) }, }; From c327ef929ee77401155cfc53d6d795b155f5244f Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 24 Feb 2026 22:32:35 -0600 Subject: [PATCH 10/11] Filter prior contributions from SpliceFundingFailed events SpliceFundingFailed events return contributed inputs and outputs to the user so they can unlock the associated UTXOs. When an RBF attempt is in progress, inputs/outputs already consumed by prior contributions must be excluded to avoid the user prematurely unlocking UTXOs that are still needed by the active funding negotiation. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 81 +++++++----- lightning/src/ln/splicing_tests.rs | 206 +++++++++++++++++++++++++++-- 2 files changed, 248 insertions(+), 39 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index f5272e26305..0ab729209a9 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3082,6 +3082,16 @@ impl PendingFunding { self.contributions.iter().flat_map(|c| c.contributed_outputs()) } + fn prior_contributed_inputs(&self) -> impl Iterator + '_ { + let len = self.contributions.len(); + self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_inputs()) + } + + fn prior_contributed_outputs(&self) -> impl Iterator + '_ { + let len = self.contributions.len(); + self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_outputs()) + } + fn check_get_splice_locked( &mut self, context: &ChannelContext, confirmed_funding_index: usize, height: u32, ) -> Option { @@ -3130,25 +3140,6 @@ pub(super) enum QuiescentError { FailSplice(SpliceFundingFailed), } -impl From for QuiescentError { - fn from(action: QuiescentAction) -> Self { - match action { - QuiescentAction::Splice { contribution, .. } => { - let (contributed_inputs, contributed_outputs) = - contribution.into_contributed_inputs_and_outputs(); - return QuiescentError::FailSplice(SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs, - contributed_outputs, - }); - }, - #[cfg(any(test, fuzzing, feature = "_test_utils"))] - QuiescentAction::DoNothing => QuiescentError::DoNothing, - } - } -} - pub(crate) enum StfuResponse { Stfu(msgs::Stfu), SpliceInit(msgs::SpliceInit), @@ -6686,7 +6677,7 @@ pub struct SpliceFundingFailed { } macro_rules! maybe_create_splice_funding_failed { - ($funded_channel: expr, $pending_splice: expr, $get: ident, $contributed_inputs_and_outputs: ident) => {{ + ($funded_channel: expr, $pending_splice: expr, $pending_splice_ref: expr, $get: ident, $contributed_inputs_and_outputs: ident) => {{ $pending_splice .and_then(|pending_splice| pending_splice.funding_negotiation.$get()) .and_then(|funding_negotiation| { @@ -6701,7 +6692,7 @@ macro_rules! maybe_create_splice_funding_failed { .as_funding() .map(|funding| funding.get_channel_type().clone()); - let (contributed_inputs, contributed_outputs) = match funding_negotiation { + let (mut contributed_inputs, mut contributed_outputs) = match funding_negotiation { FundingNegotiation::AwaitingAck { context, .. } => { context.$contributed_inputs_and_outputs() }, @@ -6717,6 +6708,15 @@ macro_rules! maybe_create_splice_funding_failed { .$contributed_inputs_and_outputs(), }; + if let Some(pending_splice) = $pending_splice_ref { + for input in pending_splice.prior_contributed_inputs() { + contributed_inputs.retain(|i| *i != input); + } + for output in pending_splice.prior_contributed_outputs() { + contributed_outputs.retain(|o| o.script_pubkey != output.script_pubkey); + } + } + if !is_initiator && contributed_inputs.is_empty() && contributed_outputs.is_empty() { return None; @@ -6755,11 +6755,19 @@ where shutdown_result } - fn abandon_quiescent_action(&mut self) -> Option { - match self.quiescent_action.take() { - Some(QuiescentAction::Splice { contribution, .. }) => { - let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs(); - Some(SpliceFundingFailed { + fn quiescent_action_into_error(&self, action: QuiescentAction) -> QuiescentError { + match action { + QuiescentAction::Splice { contribution, .. } => { + let (mut inputs, mut outputs) = contribution.into_contributed_inputs_and_outputs(); + if let Some(ref pending_splice) = self.pending_splice { + for input in pending_splice.contributed_inputs() { + inputs.retain(|i| *i != input); + } + for output in pending_splice.contributed_outputs() { + outputs.retain(|o| o.script_pubkey != output.script_pubkey); + } + } + QuiescentError::FailSplice(SpliceFundingFailed { funding_txo: None, channel_type: None, contributed_inputs: inputs, @@ -6767,11 +6775,20 @@ where }) }, #[cfg(any(test, fuzzing, feature = "_test_utils"))] - Some(quiescent_action) => { - self.quiescent_action = Some(quiescent_action); + QuiescentAction::DoNothing => QuiescentError::DoNothing, + } + } + + fn abandon_quiescent_action(&mut self) -> Option { + let action = self.quiescent_action.take()?; + match self.quiescent_action_into_error(action) { + QuiescentError::FailSplice(failed) => Some(failed), + #[cfg(any(test, fuzzing, feature = "_test_utils"))] + QuiescentError::DoNothing => None, + _ => { + debug_assert!(false); None }, - None => None, } } @@ -6895,6 +6912,7 @@ where let splice_funding_failed = maybe_create_splice_funding_failed!( self, self.pending_splice.as_mut(), + self.pending_splice.as_ref(), take, into_contributed_inputs_and_outputs ); @@ -6919,6 +6937,7 @@ where maybe_create_splice_funding_failed!( self, self.pending_splice.as_ref(), + self.pending_splice.as_ref(), as_ref, to_contributed_inputs_and_outputs ) @@ -13549,14 +13568,14 @@ where if !self.context.is_usable() { log_debug!(logger, "Channel is not in a usable state to propose quiescence"); - return Err(action.into()); + return Err(self.quiescent_action_into_error(action)); } if self.quiescent_action.is_some() { log_debug!( logger, "Channel already has a pending quiescent action and cannot start another", ); - return Err(action.into()); + return Err(self.quiescent_action_into_error(action)); } // Since we don't have a pending quiescent action, we should never be in a state where we // sent `stfu` without already having become quiescent. diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 656d3c14057..bf689ae6262 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -250,6 +250,22 @@ pub fn do_initiate_rbf_splice_in<'a, 'b, 'c, 'd>( funding_contribution } +pub fn do_initiate_rbf_splice_in_and_out<'a, 'b, 'c, 'd>( + node: &'a Node<'b, 'c, 'd>, counterparty: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, + value_added: Amount, outputs: Vec, feerate: FeeRate, +) -> FundingContribution { + let node_id_counterparty = counterparty.node.get_our_node_id(); + let funding_template = + node.node.rbf_channel(&channel_id, &node_id_counterparty, feerate, FeeRate::MAX).unwrap(); + let wallet = WalletSync::new(Arc::clone(&node.wallet_source), node.logger); + let funding_contribution = + funding_template.splice_in_and_out_sync(value_added, outputs, &wallet).unwrap(); + node.node + .funding_contributed(&channel_id, &node_id_counterparty, funding_contribution.clone(), None) + .unwrap(); + funding_contribution +} + pub fn initiate_splice_out<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, outputs: Vec, @@ -2865,12 +2881,14 @@ fn fail_quiescent_action_on_channel_close() { #[test] fn abandon_splice_quiescent_action_on_shutdown() { - do_abandon_splice_quiescent_action_on_shutdown(true); - do_abandon_splice_quiescent_action_on_shutdown(false); + do_abandon_splice_quiescent_action_on_shutdown(true, false); + do_abandon_splice_quiescent_action_on_shutdown(false, false); + do_abandon_splice_quiescent_action_on_shutdown(true, true); + do_abandon_splice_quiescent_action_on_shutdown(false, true); } #[cfg(test)] -fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { +fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_splice: bool) { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); @@ -2884,6 +2902,19 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); + // When testing with a prior pending splice, complete splice A first so that + // `quiescent_action_into_error` filters against `pending_splice.contributed_inputs/outputs`. + if pending_splice { + let funding_contribution = do_initiate_splice_in( + &nodes[0], + &nodes[1], + channel_id, + Amount::from_sat(initial_channel_capacity / 2), + ); + let (_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + } + // Since we cannot close after having sent `stfu`, send an HTLC so that when we attempt to // splice, the `stfu` message is held back. let payment_amount = 1_000_000; @@ -2896,7 +2927,8 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { check_added_monitors(&nodes[0], 1); nodes[1].node.handle_update_add_htlc(node_id_0, &update.update_add_htlcs[0]); - nodes[1].node.handle_commitment_signed(node_id_0, &update.commitment_signed[0]); + // After a splice, commitment_signed messages are batched across funding scopes. + nodes[1].node.handle_commitment_signed_batch_test(node_id_0, &update.commitment_signed); check_added_monitors(&nodes[1], 1); let (revoke_and_ack, _) = get_revoke_commit_msgs(&nodes[1], &node_id_0); @@ -2904,9 +2936,29 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { check_added_monitors(&nodes[0], 1); // Attempt the splice. `stfu` should not go out yet as the state machine is pending. - let splice_in_amount = initial_channel_capacity / 2; - let funding_contribution = - initiate_splice_in(&nodes[0], &nodes[1], channel_id, Amount::from_sat(splice_in_amount)); + // When there's a prior splice, include a splice-out output with a different script_pubkey + // so the test can verify selective filtering: the change output (same script_pubkey as + // the prior splice) is filtered, while the splice-out output (different script_pubkey) + // survives. + let splice_in_amount = + if pending_splice { initial_channel_capacity / 4 } else { initial_channel_capacity / 2 }; + let splice_out_output = if pending_splice { + let script_pubkey = nodes[1].wallet_source.get_change_script().unwrap(); + Some(TxOut { value: Amount::from_sat(1_000), script_pubkey }) + } else { + None + }; + let funding_contribution = if let Some(ref output) = splice_out_output { + initiate_splice_in_and_out( + &nodes[0], + &nodes[1], + channel_id, + Amount::from_sat(splice_in_amount), + vec![output.clone()], + ) + } else { + initiate_splice_in(&nodes[0], &nodes[1], channel_id, Amount::from_sat(splice_in_amount)) + }; assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); // Close the channel. We should see a `SpliceFailed` event for the pending splice @@ -2920,7 +2972,33 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) { let shutdown = get_event_msg!(closer_node, MessageSendEvent::SendShutdown, closee_node_id); closee_node.node.handle_shutdown(closer_node_id, &shutdown); - expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + if pending_splice { + // With a prior pending splice, contributions are filtered against committed inputs/outputs. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => { + assert_eq!(*cid, channel_id); + }, + other => panic!("Expected SpliceFailed, got {:?}", other), + } + match &events[1] { + Event::DiscardFunding { + funding_info: FundingInfo::Contribution { inputs, outputs }, + .. + } => { + // The UTXO was filtered: it's still committed to the prior splice. + assert!(inputs.is_empty(), "Expected empty inputs (filtered), got {:?}", inputs); + // The change output was filtered (same script_pubkey as the prior splice's + // change output), but the splice-out output survives (different script_pubkey). + let expected_outputs: Vec<_> = splice_out_output.into_iter().collect(); + assert_eq!(*outputs, expected_outputs); + }, + other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), + } + } else { + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); + } let _ = get_event_msg!(closee_node, MessageSendEvent::SendShutdown, closer_node_id); } @@ -5309,3 +5387,115 @@ fn test_splice_rbf_acceptor_contributes_then_disconnects() { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); } + +#[test] +fn test_splice_rbf_disconnect_filters_prior_contributions() { + // When disconnecting during an RBF round that reuses the same UTXOs as a prior round, + // the SpliceFundingFailed event should filter out inputs/outputs still committed to the prior + // round. This exercises the `reset_pending_splice_state` → `maybe_create_splice_funding_failed` + // macro path. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + // Provide exactly 1 UTXO per node so coin selection is deterministic. + provide_utxo_reserves(&nodes, 1, added_value * 2); + + // --- Round 0: Initial splice-in at floor feerate (253). --- + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_splice_tx_0, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // --- Round 1: RBF at higher feerate without providing new UTXOs. --- + // The wallet reselects the same UTXO since the splice tx hasn't been mined. + // Include a splice-out output with a different script_pubkey so the test can verify + // selective filtering: the change output (same script_pubkey as round 0) is filtered, + // while the splice-out output (different script_pubkey) survives. + let feerate_1_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let rbf_feerate = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu); + let splice_out_output = TxOut { + value: Amount::from_sat(1_000), + script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), + }; + let _funding_contribution_1 = do_initiate_rbf_splice_in_and_out( + &nodes[0], + &nodes[1], + channel_id, + added_value, + vec![splice_out_output.clone()], + rbf_feerate, + ); + + // STFU exchange + RBF handshake to start interactive TX. + complete_rbf_handshake(&nodes[0], &nodes[1]); + + // Disconnect mid-negotiation. Stale interactive TX messages are cleared by peer_disconnected. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + // The initiator should get SpliceFailed + DiscardFunding with filtered contributions. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => { + assert_eq!(*cid, channel_id); + }, + other => panic!("Expected SpliceFailed, got {:?}", other), + } + match &events[1] { + Event::DiscardFunding { + funding_info: FundingInfo::Contribution { inputs, outputs }, + .. + } => { + // The UTXO was filtered out: it's still committed to round 0's splice. + assert!(inputs.is_empty(), "Expected empty inputs (filtered), got {:?}", inputs); + // The change output was filtered (same script_pubkey as round 0's change output), + // but the splice-out output survives (different script_pubkey). + assert_eq!(*outputs, vec![splice_out_output.clone()]); + }, + other => panic!("Expected DiscardFunding with Contribution, got {:?}", other), + } + + // Reconnect. After a completed splice, channel_ready is not re-sent. + let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_nodes(reconnect_args); + + // --- Round 2: RBF at the same feerate as the failed round 1 (264). --- + // This should succeed because the failed round never updated the feerate floor, which + // remains at round 0's rate (253), and 264 >= ceil(253 * 25/24). + provide_utxo_reserves(&nodes, 1, added_value * 2); + + let rbf_feerate_2 = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu); + let _funding_contribution_2 = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_2); + complete_rbf_handshake(&nodes[0], &nodes[1]); + + // Disconnect again to clean up the in-progress interactive TX negotiation. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2, "{events:?}"); + match &events[0] { + Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id), + other => panic!("Expected SpliceFailed, got {:?}", other), + } + match &events[1] { + Event::DiscardFunding { .. } => {}, + other => panic!("Expected DiscardFunding, got {:?}", other), + } + + let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_nodes(reconnect_args); +} From 5e521ac1f2a5e670231503a051a22ef92c9b9daa Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 25 Feb 2026 15:54:05 -0600 Subject: [PATCH 11/11] Handle FeeRateAdjustmentError variants in splice acceptor path Replace the generic error handling in splice_init and tx_init_rbf with explicit matching on FeeRateAdjustmentError variants: - FeeRateTooLow: initiator's feerate is below our minimum. Proceed without contribution and preserve QuiescentAction for an RBF retry at our preferred feerate. - FeeRateTooHigh: initiator's feerate exceeds our maximum and would consume too much of our change output. Reject the splice with WarnAndDisconnect. - FeeBufferInsufficient: our fee buffer can't cover the acceptor's estimated fee at this feerate. Proceed without contribution. Co-Authored-By: Claude Opus 4.6 --- lightning/src/ln/channel.rs | 37 ++++--- lightning/src/ln/funding.rs | 18 +++- lightning/src/ln/interactivetxs.rs | 5 + lightning/src/ln/splicing_tests.rs | 152 +++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 18 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 0ab729209a9..0e8f46bde04 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -55,7 +55,9 @@ use crate::ln::channelmanager::{ PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; -use crate::ln::funding::{FundingContribution, FundingTemplate, FundingTxInput}; +use crate::ln::funding::{ + FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput, +}; use crate::ln::interactivetxs::{ AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, InteractiveTxMessageSend, InteractiveTxSigningSession, SharedOwnedInput, SharedOwnedOutput, @@ -12417,7 +12419,7 @@ where fn resolve_queued_contribution( &self, feerate: FeeRate, logger: &L, - ) -> (Option, Option) { + ) -> Result<(Option, Option), ChannelError> { let holder_balance = self .get_holder_counterparty_balances_floor_incl_fee(&self.funding) .map(|(holder, _)| holder) @@ -12432,23 +12434,29 @@ where }) .ok(); - let net_value = - holder_balance.and_then(|_| self.queued_funding_contribution()).and_then(|c| { - c.net_value_for_acceptor_at_feerate(feerate, holder_balance.unwrap()) - .map_err(|e| { + let net_value = match holder_balance.and_then(|_| self.queued_funding_contribution()) { + Some(c) => { + match c.net_value_for_acceptor_at_feerate(feerate, holder_balance.unwrap()) { + Ok(net_value) => Some(net_value), + Err(FeeRateAdjustmentError::FeeRateTooHigh { .. }) => { + return Err(ChannelError::Abort(AbortReason::FeeRateTooHigh)); + }, + Err(e) => { log_info!( logger, - "Cannot accommodate initiator's feerate ({}) for channel {}: {}; \ - proceeding without contribution", + "Cannot accommodate initiator's feerate ({}) for channel {}: {}", feerate, self.context.channel_id(), e, ); - }) - .ok() - }); + None + }, + } + }, + None => None, + }; - (net_value, holder_balance) + Ok((net_value, holder_balance)) } pub(crate) fn splice_init( @@ -12457,7 +12465,7 @@ where ) -> Result { let feerate = FeeRate::from_sat_per_kwu(msg.funding_feerate_per_kw as u64); let (our_funding_contribution, holder_balance) = - self.resolve_queued_contribution(feerate, logger); + self.resolve_queued_contribution(feerate, logger)?; let splice_funding = self.validate_splice_init(msg, our_funding_contribution.unwrap_or(SignedAmount::ZERO))?; @@ -12615,7 +12623,8 @@ where fee_estimator: &LowerBoundedFeeEstimator, logger: &L, ) -> Result { let feerate = FeeRate::from_sat_per_kwu(msg.feerate_sat_per_1000_weight as u64); - let (queued_net_value, holder_balance) = self.resolve_queued_contribution(feerate, logger); + let (queued_net_value, holder_balance) = + self.resolve_queued_contribution(feerate, logger)?; // If no queued contribution, try prior contribution from previous negotiation. // Failing here means the RBF would erase our splice — reject it. diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 7c1bada94c3..c81024ca080 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -42,7 +42,7 @@ use crate::util::wallet_utils::{ #[derive(Debug)] pub(super) enum FeeRateAdjustmentError { /// The counterparty's proposed feerate is below `min_feerate`, which was used as the feerate - /// during coin selection. + /// during coin selection. We'll retry via RBF at our preferred feerate. FeeRateTooLow { target_feerate: FeeRate, min_feerate: FeeRate }, /// The counterparty's proposed feerate is above `max_feerate` and the re-estimated fee for /// our contributed inputs and outputs exceeds the original fee estimate (computed at @@ -68,7 +68,12 @@ impl core::fmt::Display for FeeRateAdjustmentError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { FeeRateAdjustmentError::FeeRateTooLow { target_feerate, min_feerate } => { - write!(f, "Target feerate {} is below our minimum {}", target_feerate, min_feerate) + write!( + f, + "Target feerate {} is below our minimum {}; \ + proceeding without contribution, will RBF later", + target_feerate, min_feerate, + ) }, FeeRateAdjustmentError::FeeRateTooHigh { target_feerate, @@ -83,12 +88,17 @@ impl core::fmt::Display for FeeRateAdjustmentError { ) }, FeeRateAdjustmentError::FeeBufferOverflow => { - write!(f, "Arithmetic overflow when computing available fee buffer") + write!( + f, + "Arithmetic overflow when computing available fee buffer; \ + proceeding without contribution", + ) }, FeeRateAdjustmentError::FeeBufferInsufficient { source, available, required } => { write!( f, - "Fee buffer {} ({}) is insufficient for required fee {}", + "Fee buffer {} ({}) is insufficient for required fee {}; \ + proceeding without contribution", available, source, required, ) }, diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 5a9964a6063..36367611abb 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -141,6 +141,8 @@ pub(crate) enum AbortReason { InsufficientRbfFeerate, /// A funding negotiation is already in progress. NegotiationInProgress, + /// The initiator's feerate exceeds our maximum. + FeeRateTooHigh, /// Internal error InternalError(&'static str), } @@ -204,6 +206,9 @@ impl Display for AbortReason { AbortReason::NegotiationInProgress => { f.write_str("A funding negotiation is already in progress") }, + AbortReason::FeeRateTooHigh => { + f.write_str("The initiator's feerate exceeds our maximum") + }, AbortReason::InternalError(text) => { f.write_fmt(format_args!("Internal error: {}", text)) }, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index bf689ae6262..bdfe14635e0 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -1782,6 +1782,78 @@ fn do_test_splice_tiebreak( } } +#[test] +fn test_splice_tiebreak_feerate_too_high_rejected() { + // Node 0 (winner) proposes a feerate far above node 1's (loser) max_feerate, and node 1's + // fair fee at that feerate exceeds its budget. This triggers FeeRateAdjustmentError::TooHigh, + // causing node 1 to reject with tx_abort. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000)); + + // Node 0 uses an extremely high feerate (100,000 sat/kwu). Node 1 uses the floor feerate + // with a moderate splice-in (50,000 sats from a 100,000 sat UTXO) and a low max_feerate + // (3,000 sat/kwu). The target (100k) far exceeds node 1's max (3k), and the fair fee at + // 100k exceeds node 1's budget, triggering TooHigh. + let high_feerate = FeeRate::from_sat_per_kwu(100_000); + let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let node_0_added_value = Amount::from_sat(50_000); + let node_1_added_value = Amount::from_sat(50_000); + let node_1_max_feerate = FeeRate::from_sat_per_kwu(3_000); + + // Node 0: very high feerate, moderate splice-in. + let funding_template_0 = + nodes[0].node.splice_channel(&channel_id, &node_id_1, high_feerate, FeeRate::MAX).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(node_0_added_value, &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + // Node 1: floor feerate, moderate splice-in, low max_feerate. + let funding_template_1 = nodes[1] + .node + .splice_channel(&channel_id, &node_id_0, floor_feerate, node_1_max_feerate) + .unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(node_1_added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Both emit STFU. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + // Tie-break: node 0 wins. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Node 0 sends SpliceInit at 100,000 sat/kwu. + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + + // Node 1 handles SpliceInit — TooHigh: target (100k) >> max (3k) and fair fee > budget. + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!(tx_abort.channel_id, channel_id); +} + #[cfg(test)] #[derive(PartialEq)] enum SpliceStatus { @@ -4944,6 +5016,86 @@ pub fn do_test_splice_rbf_tiebreak( } } +#[test] +fn test_splice_rbf_tiebreak_feerate_too_high_rejected() { + // Node 0 (winner) proposes an RBF feerate far above node 1's (loser) max_feerate, and + // node 1's fair fee at that feerate exceeds its budget. This triggers + // FeeRateAdjustmentError::TooHigh in the queued contribution path, causing node 1 to + // reject with tx_abort. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_0 = nodes[0].node.get_our_node_id(); + let node_id_1 = nodes[1].node.get_our_node_id(); + + let initial_channel_value_sat = 100_000; + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Complete an initial splice-in from node 0. + let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + let (_first_splice_tx, _new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution); + + // Provide more UTXOs for both nodes' RBF attempts. + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Node 0 uses an extremely high feerate (100,000 sat/kwu). Node 1 uses the minimum RBF + // feerate with a moderate splice-in (50,000 sats) and a low max_feerate (3,000 sat/kwu). + // The target (100k) far exceeds node 1's max (3k), and the fair fee at 100k exceeds + // node 1's budget, triggering TooHigh. + let high_feerate = FeeRate::from_sat_per_kwu(100_000); + let min_rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24); + let min_rbf_feerate = FeeRate::from_sat_per_kwu(min_rbf_feerate_sat_per_kwu); + let node_1_max_feerate = FeeRate::from_sat_per_kwu(3_000); + + let funding_template_0 = + nodes[0].node.rbf_channel(&channel_id, &node_id_1, high_feerate, FeeRate::MAX).unwrap(); + let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let node_0_funding_contribution = + funding_template_0.splice_in_sync(added_value, &wallet_0).unwrap(); + nodes[0] + .node + .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None) + .unwrap(); + + let funding_template_1 = nodes[1] + .node + .rbf_channel(&channel_id, &node_id_0, min_rbf_feerate, node_1_max_feerate) + .unwrap(); + let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger); + let node_1_funding_contribution = + funding_template_1.splice_in_sync(added_value, &wallet_1).unwrap(); + nodes[1] + .node + .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None) + .unwrap(); + + // Both sent STFU. + let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + + // Tie-break: node 0 wins. + nodes[1].node.handle_stfu(node_id_0, &stfu_0); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_stfu(node_id_1, &stfu_1); + + // Node 0 sends tx_init_rbf at 100,000 sat/kwu. + let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); + assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, high_feerate.to_sat_per_kwu() as u32); + + // Node 1 handles tx_init_rbf — TooHigh: target (100k) >> max (3k) and fair fee > budget. + nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); + + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!(tx_abort.channel_id, channel_id); +} + #[test] fn test_splice_rbf_acceptor_recontributes() { // When the counterparty RBFs a splice and we have no pending QuiescentAction,