Skip to content

Commit 183b777

Browse files
jkczyzclaude
andcommitted
Check can_initiate_rbf in stfu handler before sending tx_init_rbf
If splice_locked is sent between our outgoing STFU and the counterparty's STFU response, the stfu() handler would proceed to send tx_init_rbf for an already-confirmed splice. Guard against this by re-checking can_initiate_rbf when entering quiescence. Disconnect because there is no way to cancel quiescence after both sides have exchanged STFU. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8551eee commit 183b777

4 files changed

Lines changed: 119 additions & 1 deletion

File tree

fuzz/src/chanmon_consistency.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,7 @@ fn assert_action_timeout_awaiting_response(action: &msgs::ErrorAction) {
932932
action,
933933
msgs::ErrorAction::DisconnectPeerWithWarning { msg }
934934
if msg.data.contains("Disconnecting due to timeout awaiting response")
935+
|| msg.data.contains("already sent splice_locked, cannot RBF")
935936
),
936937
"Expected timeout disconnect, got: {:?}",
937938
action,

lightning/src/events/mod.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,12 @@ pub enum NegotiationFailureReason {
149149
/// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel
150150
/// [`FundingTemplate`]: crate::ln::funding::FundingTemplate
151151
FeeRateTooLow,
152+
/// An RBF attempt could not be initiated (e.g., a prior splice transaction already
153+
/// confirmed). The channel remains operational — start a new splice with
154+
/// [`ChannelManager::splice_channel`] if further changes are needed.
155+
///
156+
/// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel
157+
CannotInitiateRbf,
152158
}
153159

154160
impl NegotiationFailureReason {
@@ -166,7 +172,8 @@ impl NegotiationFailureReason {
166172
Self::CounterpartyAborted { .. }
167173
| Self::NegotiationError { .. }
168174
| Self::LocallyAbandoned
169-
| Self::ChannelClosing => false,
175+
| Self::ChannelClosing
176+
| Self::CannotInitiateRbf => false,
170177
}
171178
}
172179
}
@@ -185,6 +192,7 @@ impl core::fmt::Display for NegotiationFailureReason {
185192

186193
Self::ChannelClosing => f.write_str("channel is closing"),
187194
Self::FeeRateTooLow => f.write_str("feerate too low for RBF"),
195+
Self::CannotInitiateRbf => f.write_str("cannot initiate RBF"),
188196
}
189197
}
190198
}
@@ -202,6 +210,7 @@ impl_writeable_tlv_based_enum_upgradable!(NegotiationFailureReason,
202210
(10, LocallyAbandoned) => {},
203211
(12, ChannelClosing) => {},
204212
(14, FeeRateTooLow) => {},
213+
(16, CannotInitiateRbf) => {},
205214
);
206215

207216
/// Some information provided on receipt of payment depends on whether the payment received is a

lightning/src/ln/channel.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14208,6 +14208,16 @@ where
1420814208
};
1420914209

1421014210
if self.pending_splice.is_some() {
14211+
if let Err(e) = self.can_initiate_rbf() {
14212+
let failed = self.splice_funding_failed_for(prior_contribution);
14213+
return Err((
14214+
ChannelError::WarnAndDisconnect(e),
14215+
QuiescentError::FailSplice(
14216+
failed,
14217+
NegotiationFailureReason::CannotInitiateRbf,
14218+
),
14219+
));
14220+
}
1421114221
let tx_init_rbf = self.send_tx_init_rbf(context);
1421214222
self.pending_splice.as_mut().unwrap()
1421314223
.contributions.push(prior_contribution);

lightning/src/ln/splicing_tests.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5154,6 +5154,104 @@ fn test_splice_rbf_after_splice_locked() {
51545154
}
51555155
}
51565156

5157+
#[test]
5158+
fn test_splice_rbf_stfu_after_splice_locked() {
5159+
// Test that we don't send tx_init_rbf when we've already sent splice_locked.
5160+
//
5161+
// Scenario: node 0 initiates an RBF and sends STFU, but before receiving the counterparty's
5162+
// STFU response, it mines enough blocks to send splice_locked (setting sent_funding_txid).
5163+
// When node 1's STFU arrives, the stfu() handler should detect that RBF is no longer valid
5164+
// and return WarnAndDisconnect instead of sending tx_init_rbf.
5165+
let chanmon_cfgs = create_chanmon_cfgs(2);
5166+
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
5167+
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
5168+
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
5169+
5170+
let node_id_0 = nodes[0].node.get_our_node_id();
5171+
let node_id_1 = nodes[1].node.get_our_node_id();
5172+
5173+
let initial_channel_value_sat = 100_000;
5174+
let (_, _, channel_id, _) =
5175+
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
5176+
5177+
let added_value = Amount::from_sat(50_000);
5178+
provide_utxo_reserves(&nodes, 2, added_value * 2);
5179+
5180+
// Complete a splice-in from node 0.
5181+
let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
5182+
let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
5183+
5184+
// Mine the splice tx on both nodes (not enough for splice_locked yet).
5185+
mine_transaction(&nodes[0], &splice_tx);
5186+
mine_transaction(&nodes[1], &splice_tx);
5187+
5188+
// Provide more UTXOs for the RBF attempt.
5189+
provide_utxo_reserves(&nodes, 2, added_value * 2);
5190+
5191+
// Initiate RBF from node 0.
5192+
let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
5193+
let _funding_contribution =
5194+
do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate);
5195+
5196+
// Node 0 sends STFU (can_initiate_rbf passes since no splice_locked yet).
5197+
let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
5198+
5199+
// Deliver STFU to node 1; extract node 1's STFU response but don't deliver it yet.
5200+
nodes[1].node.handle_stfu(node_id_0, &stfu_init);
5201+
let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
5202+
5203+
// Mine enough blocks on node 0 so it sends splice_locked (sets sent_funding_txid).
5204+
connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1);
5205+
let _splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1);
5206+
5207+
// Now deliver node 1's STFU to node 0. The stfu() handler should detect that RBF is no
5208+
// longer valid (we already sent splice_locked) and return WarnAndDisconnect.
5209+
nodes[0].node.handle_stfu(node_id_1, &stfu_ack);
5210+
5211+
let msg_events = nodes[0].node.get_and_clear_pending_msg_events();
5212+
assert_eq!(msg_events.len(), 1, "{msg_events:?}");
5213+
match &msg_events[0] {
5214+
MessageSendEvent::HandleError { action, .. } => {
5215+
assert_eq!(
5216+
*action,
5217+
msgs::ErrorAction::DisconnectPeerWithWarning {
5218+
msg: msgs::WarningMessage {
5219+
channel_id,
5220+
data: format!(
5221+
"Channel {} already sent splice_locked, cannot RBF",
5222+
channel_id,
5223+
),
5224+
},
5225+
}
5226+
);
5227+
},
5228+
_ => panic!("Expected HandleError, got {:?}", msg_events[0]),
5229+
}
5230+
5231+
// Node 0 should emit DiscardFunding + SpliceNegotiationFailed for the RBF contribution.
5232+
// The change output is filtered (same script_pubkey as the first splice's change output),
5233+
// but the input survives because it's a different UTXO from the first splice.
5234+
let events = nodes[0].node.get_and_clear_pending_events();
5235+
assert_eq!(events.len(), 2, "{events:?}");
5236+
match &events[0] {
5237+
Event::DiscardFunding {
5238+
funding_info: FundingInfo::Contribution { inputs, outputs },
5239+
..
5240+
} => {
5241+
assert!(!inputs.is_empty());
5242+
assert!(outputs.is_empty());
5243+
},
5244+
other => panic!("Expected DiscardFunding, got {:?}", other),
5245+
}
5246+
match &events[1] {
5247+
Event::SpliceNegotiationFailed { channel_id: cid, reason, .. } => {
5248+
assert_eq!(*cid, channel_id);
5249+
assert_eq!(*reason, NegotiationFailureReason::CannotInitiateRbf);
5250+
},
5251+
other => panic!("Expected SpliceNegotiationFailed, got {:?}", other),
5252+
}
5253+
}
5254+
51575255
#[test]
51585256
fn test_splice_zeroconf_no_rbf_feerate() {
51595257
// Test that splice_channel returns a FundingTemplate with min_rbf_feerate = None for a

0 commit comments

Comments
 (0)