Skip to content

Commit ffbb8fe

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 b44076e commit ffbb8fe

4 files changed

Lines changed: 130 additions & 1 deletion

File tree

fuzz/src/chanmon_consistency.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,7 @@ fn assert_action_timeout_awaiting_response(action: &msgs::ErrorAction) {
930930
action,
931931
msgs::ErrorAction::DisconnectPeerWithWarning { msg }
932932
if msg.data.contains("Disconnecting due to timeout awaiting response")
933+
|| msg.data.contains("already sent splice_locked, cannot RBF")
933934
),
934935
"Expected timeout disconnect, got: {:?}",
935936
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
(11, LocallyAbandoned) => {},
203211
(13, ChannelClosing) => {},
204212
(15, FeeRateTooLow) => {},
213+
(17, 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
@@ -14328,6 +14328,16 @@ where
1432814328
};
1432914329

1433014330
if self.pending_splice.is_some() {
14331+
if let Err(e) = self.can_initiate_rbf() {
14332+
let failed = self.splice_funding_failed_for(prior_contribution);
14333+
return Err((
14334+
ChannelError::WarnAndDisconnect(e),
14335+
QuiescentError::FailSplice(
14336+
failed,
14337+
NegotiationFailureReason::CannotInitiateRbf,
14338+
),
14339+
));
14340+
}
1433114341
let tx_init_rbf = self.send_tx_init_rbf(context);
1433214342
self.pending_splice.as_mut().unwrap()
1433314343
.contributions.push(prior_contribution);

lightning/src/ln/splicing_tests.rs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5198,6 +5198,115 @@ fn test_splice_rbf_after_splice_locked() {
51985198
}
51995199
}
52005200

5201+
#[test]
5202+
fn test_splice_rbf_stfu_after_splice_locked() {
5203+
// Test that we don't send tx_init_rbf when we've already sent splice_locked.
5204+
//
5205+
// Scenario: node 0 initiates an RBF and sends STFU, but before receiving the counterparty's
5206+
// STFU response, it mines enough blocks to send splice_locked (setting sent_funding_txid).
5207+
// When node 1's STFU arrives, the stfu() handler should detect that RBF is no longer valid
5208+
// and return WarnAndDisconnect instead of sending tx_init_rbf.
5209+
let chanmon_cfgs = create_chanmon_cfgs(2);
5210+
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
5211+
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
5212+
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
5213+
5214+
let node_id_0 = nodes[0].node.get_our_node_id();
5215+
let node_id_1 = nodes[1].node.get_our_node_id();
5216+
5217+
let initial_channel_value_sat = 100_000;
5218+
let (_, _, channel_id, _) =
5219+
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
5220+
5221+
let added_value = Amount::from_sat(50_000);
5222+
provide_utxo_reserves(&nodes, 2, added_value * 2);
5223+
5224+
// Complete a splice-in from node 0.
5225+
let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
5226+
let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
5227+
5228+
// Mine the splice tx on both nodes (not enough for splice_locked yet).
5229+
mine_transaction(&nodes[0], &splice_tx);
5230+
mine_transaction(&nodes[1], &splice_tx);
5231+
5232+
// Provide more UTXOs for the RBF attempt.
5233+
provide_utxo_reserves(&nodes, 2, added_value * 2);
5234+
5235+
// Initiate RBF from node 0 with fresh inputs so the RBF round has a unique input that
5236+
// survives filtering when the failure cleanup runs.
5237+
let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
5238+
let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
5239+
let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
5240+
let funding_contribution = funding_template
5241+
.without_prior_contribution(rbf_feerate, FeeRate::MAX)
5242+
.with_coin_selection_source_sync(&wallet)
5243+
.add_value(added_value)
5244+
.build()
5245+
.unwrap();
5246+
nodes[0]
5247+
.node
5248+
.funding_contributed(&channel_id, &node_id_1, funding_contribution.clone(), None)
5249+
.unwrap();
5250+
5251+
// Node 0 sends STFU (can_initiate_rbf passes since no splice_locked yet).
5252+
let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
5253+
5254+
// Deliver STFU to node 1; extract node 1's STFU response but don't deliver it yet.
5255+
nodes[1].node.handle_stfu(node_id_0, &stfu_init);
5256+
let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
5257+
5258+
// Mine enough blocks on node 0 so it sends splice_locked (sets sent_funding_txid).
5259+
connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1);
5260+
let _splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1);
5261+
5262+
// Now deliver node 1's STFU to node 0. The stfu() handler should detect that RBF is no
5263+
// longer valid (we already sent splice_locked) and return WarnAndDisconnect.
5264+
nodes[0].node.handle_stfu(node_id_1, &stfu_ack);
5265+
5266+
let msg_events = nodes[0].node.get_and_clear_pending_msg_events();
5267+
assert_eq!(msg_events.len(), 1, "{msg_events:?}");
5268+
match &msg_events[0] {
5269+
MessageSendEvent::HandleError { action, .. } => {
5270+
assert_eq!(
5271+
*action,
5272+
msgs::ErrorAction::DisconnectPeerWithWarning {
5273+
msg: msgs::WarningMessage {
5274+
channel_id,
5275+
data: format!(
5276+
"Channel {} already sent splice_locked, cannot RBF",
5277+
channel_id,
5278+
),
5279+
},
5280+
}
5281+
);
5282+
},
5283+
_ => panic!("Expected HandleError, got {:?}", msg_events[0]),
5284+
}
5285+
5286+
// Node 0 should emit DiscardFunding + SpliceNegotiationFailed for the RBF contribution.
5287+
// The change output is filtered (same script_pubkey as the first splice's change output),
5288+
// but the input survives because it's a different UTXO from the first splice.
5289+
let events = nodes[0].node.get_and_clear_pending_events();
5290+
assert_eq!(events.len(), 2, "{events:?}");
5291+
match &events[0] {
5292+
Event::DiscardFunding {
5293+
funding_info: FundingInfo::Contribution { inputs, outputs },
5294+
..
5295+
} => {
5296+
assert!(!inputs.is_empty());
5297+
assert!(outputs.is_empty());
5298+
},
5299+
other => panic!("Expected DiscardFunding, got {:?}", other),
5300+
}
5301+
match &events[1] {
5302+
Event::SpliceNegotiationFailed { channel_id: cid, reason, .. } => {
5303+
assert_eq!(*cid, channel_id);
5304+
assert_eq!(*reason, NegotiationFailureReason::CannotInitiateRbf);
5305+
},
5306+
other => panic!("Expected SpliceNegotiationFailed, got {:?}", other),
5307+
}
5308+
}
5309+
52015310
#[test]
52025311
fn test_splice_zeroconf_no_rbf_feerate() {
52035312
// Test that splice_channel returns a FundingTemplate with min_rbf_feerate = None for a

0 commit comments

Comments
 (0)