Send tx_init_rbf instead of splice_init when a splice is pending
What changed, and why it matters
This commit fixes a logic bug in the Lightning Dev Kit's splicing protocol. Previously, if a node tried to RBF (bump the fee of) a pending splice, it would incorrectly send a new splice_init message instead of tx_init_rbf, which could cause the peer to disconnect or the protocol to fail. The change makes the node detect when a splice is already pending and send the correct RBF message instead. It is a protocol correctness fix rather than a critical security vulnerability.
Review and merge as a protocol correctness improvement. Monitor for any edge cases where pending_splice state could be stale or inconsistent when entering quiescence. No immediate security response required.
Security signals we found
Protocol state machine correction in splicing/RBF flow
Removes a debug_assert + WarnAndDisconnect path for already-pending splice
Adds explicit handling for tx_init_rbf initiator path
Includes test coverage for insufficient RBF feerate rejection
Evidence from the diff
The patch modifies the stfu (quiescence) handler in Channel to distinguish between an initial splice and an RBF attempt. It adds a new StfuResponse::TxInitRbf variant and a send_tx_init_rbf helper that reuses the existing pending_splice state. When QuiescentAction::Splice is processed and pending_splice already exists, the code now returns TxInitRbf instead of SpliceInit. The channelmanager is updated to emit SendTxInitRbf message events. Tests are updated to exercise the initiator-side RBF flow and fee-rate validation.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/splicing_tests.rsInspect captured patch +82 / −30
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 29efe9a..d24e604 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -3073,6 +3073,7 @@ impl From<QuiescentAction> 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 888e9ff..2c416e4 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 9adc318..d0fb29d 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.
Why this scored 26/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.