Skip to content

Commit b1c352c

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 0d93ebb commit b1c352c

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
@@ -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
(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
@@ -14210,6 +14210,16 @@ where
1421014210
};
1421114211

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

lightning/src/ln/splicing_tests.rs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5162,6 +5162,115 @@ fn test_splice_rbf_after_splice_locked() {
51625162
}
51635163
}
51645164

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

0 commit comments

Comments
 (0)