Ignore messages from aborted splice negotiations
What changed, and why it matters
This patch fixes a bug where a Lightning node could unnecessarily disconnect from a peer after a splice negotiation was aborted. When both sides agree to stop a splice, some related messages can still be in flight. Previously, those late messages were treated as protocol violations and caused a disconnect. Now the node ignores the stale messages instead, keeping the channel open. There is no direct evidence this was exploitable to steal funds, but unnecessary disconnects can hurt reliability.
Review and merge; consider backporting to branches that support splicing. Monitor for any related protocol-state edge cases around abort and in-flight messages.
Security signals we found
Avoids unnecessary channel disconnects after splice abort
Changes error handling from WarnAndDisconnect to Ignore for stale interactive-tx messages when not quiescent
Adds funding txid validation before batch-delivery enforcement in commitment_signed
Includes regression test for aborted RBF splice with in-flight commitment_signed
Evidence from the diff
The commit changes channel message handling in rust-lightning so that interactive-tx messages (tx_add_input, tx_add_output, tx_remove_input, tx_remove_output, tx_complete) are ignored if the channel is no longer quiescent, while still warning/disconnecting for truly unexpected messages during quiescence. It also changes commitment_signed handling to check whether the funding txid matches any known funding (current or pending) before enforcing batch-delivery rules, so a commitment_signed for a discarded splice funding is ignored rather than causing a channel close. A regression test simulates an aborted RBF splice where the counterparty’s commitment_signed crosses with the abort and verifies the channel remains open.
Changed components
lightning/src/ln/channel.rslightning/src/ln/splicing_tests.rsInspect captured patch +141 / −64
### lightning/src/ln/channel.rs
@@ -1856,6 +1856,27 @@ where
}
}
+ fn interactive_tx_constructor_for_message(
+ &mut self, msg_name: &str,
+ ) -> Result<&mut InteractiveTxConstructor, InteractiveTxMsgError> {
+ if matches!(
+ &self.phase,
+ ChannelPhase::Funded(chan) if !chan.context.channel_state.is_quiescent()
+ ) {
+ return Err(InteractiveTxMsgError::new(
+ ChannelError::Ignore(format!("Ignoring unexpected {msg_name} while not quiescent")),
+ None,
+ ));
+ }
+ match self.interactive_tx_constructor_mut() {
+ Some(interactive_tx_constructor) => Ok(interactive_tx_constructor),
+ None => Err(InteractiveTxMsgError::new(
+ ChannelError::WarnAndDisconnect(format!("Received unexpected {msg_name}")),
+ None,
+ )),
+ }
+ }
+
fn fail_interactive_tx_negotiation<L: Logger>(
&mut self, reason: AbortReason, logger: &L,
) -> InteractiveTxMsgError {
@@ -1885,82 +1906,42 @@ where
pub fn tx_add_input<L: Logger>(
&mut self, msg: &msgs::TxAddInput, logger: &L,
) -> Result<InteractiveTxMessageSend, InteractiveTxMsgError> {
- match self.interactive_tx_constructor_mut() {
- Some(interactive_tx_constructor) => interactive_tx_constructor
- .handle_tx_add_input(msg)
- .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger)),
- None => Err(InteractiveTxMsgError::new(
- ChannelError::WarnAndDisconnect(
- "Received unexpected interactive transaction negotiation message".to_owned(),
- ),
- None,
- )),
- }
+ self.interactive_tx_constructor_for_message("tx_add_input")?
+ .handle_tx_add_input(msg)
+ .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger))
}
pub fn tx_add_output<L: Logger>(
&mut self, msg: &msgs::TxAddOutput, logger: &L,
) -> Result<InteractiveTxMessageSend, InteractiveTxMsgError> {
- match self.interactive_tx_constructor_mut() {
- Some(interactive_tx_constructor) => interactive_tx_constructor
- .handle_tx_add_output(msg)
- .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger)),
- None => Err(InteractiveTxMsgError::new(
- ChannelError::WarnAndDisconnect(
- "Received unexpected interactive transaction negotiation message".to_owned(),
- ),
- None,
- )),
- }
+ self.interactive_tx_constructor_for_message("tx_add_output")?
+ .handle_tx_add_output(msg)
+ .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger))
}
pub fn tx_remove_input<L: Logger>(
&mut self, msg: &msgs::TxRemoveInput, logger: &L,
) -> Result<InteractiveTxMessageSend, InteractiveTxMsgError> {
- match self.interactive_tx_constructor_mut() {
- Some(interactive_tx_constructor) => interactive_tx_constructor
- .handle_tx_remove_input(msg)
- .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger)),
- None => Err(InteractiveTxMsgError::new(
- ChannelError::WarnAndDisconnect(
- "Received unexpected interactive transaction negotiation message".to_owned(),
- ),
- None,
- )),
- }
+ self.interactive_tx_constructor_for_message("tx_remove_input")?
+ .handle_tx_remove_input(msg)
+ .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger))
}
pub fn tx_remove_output<L: Logger>(
&mut self, msg: &msgs::TxRemoveOutput, logger: &L,
) -> Result<InteractiveTxMessageSend, InteractiveTxMsgError> {
- match self.interactive_tx_constructor_mut() {
- Some(interactive_tx_constructor) => interactive_tx_constructor
- .handle_tx_remove_output(msg)
- .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger)),
- None => Err(InteractiveTxMsgError::new(
- ChannelError::WarnAndDisconnect(
- "Received unexpected interactive transaction negotiation message".to_owned(),
- ),
- None,
- )),
- }
+ self.interactive_tx_constructor_for_message("tx_remove_output")?
+ .handle_tx_remove_output(msg)
+ .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger))
}
pub fn tx_complete<F: FeeEstimator, L: Logger>(
&mut self, msg: &msgs::TxComplete, fee_estimator: &LowerBoundedFeeEstimator<F>, logger: &L,
) -> Result<TxCompleteResult, InteractiveTxMsgError> {
- let tx_complete_action = match self.interactive_tx_constructor_mut() {
- Some(interactive_tx_constructor) => interactive_tx_constructor
- .handle_tx_complete(msg)
- .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger))?,
- None => {
- let err = "Received unexpected interactive transaction negotiation message";
- return Err(InteractiveTxMsgError::new(
- ChannelError::WarnAndDisconnect(err.to_owned()),
- None,
- ));
- },
- };
+ let tx_complete_action = self
+ .interactive_tx_constructor_for_message("tx_complete")?
+ .handle_tx_complete(msg)
+ .map_err(|reason| self.fail_interactive_tx_negotiation(reason, logger))?;
let (interactive_tx_msg_send, negotiation_complete) = match tx_complete_action {
HandleTxCompleteValue::SendTxMessage(interactive_tx_msg_send) => {
@@ -8939,21 +8920,25 @@ where
) -> Result<Option<ChannelMonitorUpdate>, ChannelError> {
self.commitment_signed_check_state()?;
- if !self.negotiated_candidates().is_empty() {
- return Err(ChannelError::close(
- "Got a single commitment_signed message when expecting a batch".to_owned(),
- ));
- }
if let Some(funding_txid) = msg.funding_txid {
- let locked_funding_txid =
- self.funding.get_funding_txid().expect("funded channel must have known txid");
- if funding_txid != locked_funding_txid {
+ // We may have aborted a pending funding negotiation while the counterparty's initial
+ // `commitment_signed` was in flight.
+ let is_known_funding = core::iter::once(&self.funding)
+ .chain(self.pending_funding())
+ .any(|funding| funding.get_funding_txid() == Some(funding_txid));
+ if !is_known_funding {
return Err(ChannelError::Ignore(format!(
"Ignoring commitment_signed for stale funding txid {funding_txid}"
)));
}
}
+ if !self.negotiated_candidates().is_empty() {
+ return Err(ChannelError::close(
+ "Got a single commitment_signed message when expecting a batch".to_owned(),
+ ));
+ }
+
let transaction_number = self.holder_commitment_point.next_transaction_number();
let commitment_point = self.holder_commitment_point.next_point();
let update = self
### lightning/src/ln/splicing_tests.rs
@@ -7274,6 +7274,98 @@ fn test_splice_rbf_no_pending_splice() {
);
}
+#[test]
+fn test_aborted_rbf_ignores_inflight_commitment_signed() {
+ // A peer may sign an RBF while we are aborting it. Its crossing commitment_signed refers only
+ // to the discarded funding scope and must not close the channel.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (first_splice_tx, funding_script) =
+ splice_channel(&nodes[0], &nodes[1], channel_id, contribution);
+
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
+ let rbf_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, feerate);
+ let acceptor_contribution =
+ do_initiate_splice_in_at_feerate(&nodes[1], &nodes[0], channel_id, added_value, feerate);
+ let tx_ack_rbf = complete_rbf_handshake(&nodes[0], &nodes[1]);
+ complete_interactive_funding_negotiation_for_both(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ rbf_contribution.clone(),
+ Some(acceptor_contribution),
+ tx_ack_rbf.funding_output_contribution.unwrap_or(0),
+ funding_script,
+ );
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+ let expected_acceptor_rbf_contribution =
+ nodes[1].node.list_channels()[0].splice_details.as_ref().unwrap().candidates[1]
+ .contribution
+ .clone()
+ .expect("acceptor contributed to the RBF");
+ let _ = get_event!(nodes[0], Event::FundingTransactionReadyForSigning);
+ match get_event!(nodes[1], Event::FundingTransactionReadyForSigning) {
+ Event::FundingTransactionReadyForSigning {
+ channel_id,
+ counterparty_node_id,
+ unsigned_transaction,
+ ..
+ } => {
+ let partially_signed_tx = nodes[1].wallet_source.sign_tx(unsigned_transaction).unwrap();
+ nodes[1]
+ .node
+ .funding_transaction_signed(&channel_id, &counterparty_node_id, partially_signed_tx)
+ .unwrap();
+ },
+ other => panic!("Unexpected event {other:?}"),
+ }
+ let crossing_commitment_signed = get_htlc_update_msgs(&nodes[1], &node_id_0).commitment_signed;
+
+ nodes[0].node.cancel_funding_contributed(&channel_id, &node_id_1).unwrap();
+ let events = nodes[0].node.get_and_clear_pending_events();
+ assert!(
+ matches!(
+ events.as_slice(),
+ [Event::SpliceNegotiationFailed {
+ channel_id: failed_channel_id,
+ contribution: Some(failed_contribution),
+ reason: NegotiationFailureReason::LocallyCanceled,
+ ..
+ }] if *failed_channel_id == channel_id
+ && failed_contribution == &rbf_contribution
+ ),
+ "{events:?}"
+ );
+ let tx_abort = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1);
+
+ nodes[0].node.handle_commitment_signed_batch_test(node_id_1, &crossing_commitment_signed);
+ nodes[1].node.handle_tx_abort(node_id_0, &tx_abort);
+ expect_failed_rbf_events(
+ &nodes[1],
+ &channel_id,
+ &expected_acceptor_rbf_contribution,
+ NegotiationFailureReason::CounterpartyAborted {
+ msg: UntrustedString("Manually aborted funding negotiation".to_owned()),
+ },
+ );
+ let tx_abort_ack = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0);
+ nodes[0].node.handle_tx_abort(node_id_1, &tx_abort_ack);
+
+ mine_transaction(&nodes[0], &first_splice_tx);
+ mine_transaction(&nodes[1], &first_splice_tx);
+ lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
+}
+
#[test]
fn test_splice_rbf_after_splice_locked() {
// Test that tx_init_rbf is rejected when the counterparty has already sent splice_locked.Why this scored 37/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.