Skip to content

Commit ee5e7a8

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 f43c892 commit ee5e7a8

3 files changed

Lines changed: 117 additions & 1 deletion

File tree

lightning/src/events/mod.rs

Lines changed: 9 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 {
@@ -165,7 +171,7 @@ impl NegotiationFailureReason {
165171
| Self::NegotiationError { .. }
166172
| Self::ContributionInvalid
167173
| Self::FeeRateTooLow => true,
168-
Self::LocallyAbandoned | Self::ChannelClosing => false,
174+
Self::LocallyAbandoned | Self::ChannelClosing | Self::CannotInitiateRbf => false,
169175
}
170176
}
171177
}
@@ -184,6 +190,7 @@ impl core::fmt::Display for NegotiationFailureReason {
184190

185191
Self::ChannelClosing => f.write_str("channel is closing"),
186192
Self::FeeRateTooLow => f.write_str("feerate too low for RBF"),
193+
Self::CannotInitiateRbf => f.write_str("cannot initiate RBF"),
187194
}
188195
}
189196
}
@@ -201,6 +208,7 @@ impl_writeable_tlv_based_enum_upgradable!(NegotiationFailureReason,
201208
(10, LocallyAbandoned) => {},
202209
(12, ChannelClosing) => {},
203210
(14, FeeRateTooLow) => {},
211+
(16, CannotInitiateRbf) => {},
204212
);
205213

206214
/// 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
@@ -14155,6 +14155,16 @@ where
1415514155
};
1415614156

1415714157
if self.pending_splice.is_some() {
14158+
if let Err(e) = self.can_initiate_rbf() {
14159+
let failed = self.splice_funding_failed_for(prior_contribution);
14160+
return Err((
14161+
ChannelError::WarnAndDisconnect(e),
14162+
QuiescentError::FailSplice(
14163+
failed,
14164+
NegotiationFailureReason::CannotInitiateRbf,
14165+
),
14166+
));
14167+
}
1415814168
let tx_init_rbf = self.send_tx_init_rbf(context);
1415914169
self.pending_splice.as_mut().unwrap()
1416014170
.contributions.push(prior_contribution);

lightning/src/ln/splicing_tests.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4871,6 +4871,104 @@ fn test_splice_rbf_after_splice_locked() {
48714871
}
48724872
}
48734873

4874+
#[test]
4875+
fn test_splice_rbf_stfu_after_splice_locked() {
4876+
// Test that we don't send tx_init_rbf when we've already sent splice_locked.
4877+
//
4878+
// Scenario: node 0 initiates an RBF and sends STFU, but before receiving the counterparty's
4879+
// STFU response, it mines enough blocks to send splice_locked (setting sent_funding_txid).
4880+
// When node 1's STFU arrives, the stfu() handler should detect that RBF is no longer valid
4881+
// and return WarnAndDisconnect instead of sending tx_init_rbf.
4882+
let chanmon_cfgs = create_chanmon_cfgs(2);
4883+
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
4884+
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
4885+
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
4886+
4887+
let node_id_0 = nodes[0].node.get_our_node_id();
4888+
let node_id_1 = nodes[1].node.get_our_node_id();
4889+
4890+
let initial_channel_value_sat = 100_000;
4891+
let (_, _, channel_id, _) =
4892+
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
4893+
4894+
let added_value = Amount::from_sat(50_000);
4895+
provide_utxo_reserves(&nodes, 2, added_value * 2);
4896+
4897+
// Complete a splice-in from node 0.
4898+
let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
4899+
let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
4900+
4901+
// Mine the splice tx on both nodes (not enough for splice_locked yet).
4902+
mine_transaction(&nodes[0], &splice_tx);
4903+
mine_transaction(&nodes[1], &splice_tx);
4904+
4905+
// Provide more UTXOs for the RBF attempt.
4906+
provide_utxo_reserves(&nodes, 2, added_value * 2);
4907+
4908+
// Initiate RBF from node 0.
4909+
let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
4910+
let funding_contribution =
4911+
do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate);
4912+
4913+
// Node 0 sends STFU (can_initiate_rbf passes since no splice_locked yet).
4914+
let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
4915+
4916+
// Deliver STFU to node 1; extract node 1's STFU response but don't deliver it yet.
4917+
nodes[1].node.handle_stfu(node_id_0, &stfu_init);
4918+
let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
4919+
4920+
// Mine enough blocks on node 0 so it sends splice_locked (sets sent_funding_txid).
4921+
connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1);
4922+
let _splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1);
4923+
4924+
// Now deliver node 1's STFU to node 0. The stfu() handler should detect that RBF is no
4925+
// longer valid (we already sent splice_locked) and return WarnAndDisconnect.
4926+
nodes[0].node.handle_stfu(node_id_1, &stfu_ack);
4927+
4928+
let msg_events = nodes[0].node.get_and_clear_pending_msg_events();
4929+
assert_eq!(msg_events.len(), 1, "{msg_events:?}");
4930+
match &msg_events[0] {
4931+
MessageSendEvent::HandleError { action, .. } => {
4932+
assert_eq!(
4933+
*action,
4934+
msgs::ErrorAction::DisconnectPeerWithWarning {
4935+
msg: msgs::WarningMessage {
4936+
channel_id,
4937+
data: format!(
4938+
"Channel {} already sent splice_locked, cannot RBF",
4939+
channel_id,
4940+
),
4941+
},
4942+
}
4943+
);
4944+
},
4945+
_ => panic!("Expected HandleError, got {:?}", msg_events[0]),
4946+
}
4947+
4948+
// Node 0 should emit DiscardFunding + SpliceNegotiationFailed for the RBF contribution.
4949+
// The change output is filtered (same script_pubkey as the first splice's change output),
4950+
// but the input survives because it's a different UTXO from the first splice.
4951+
let events = nodes[0].node.get_and_clear_pending_events();
4952+
assert_eq!(events.len(), 2, "{events:?}");
4953+
match &events[0] {
4954+
Event::DiscardFunding {
4955+
funding_info: FundingInfo::Contribution { inputs, outputs },
4956+
..
4957+
} => {
4958+
assert!(!inputs.is_empty());
4959+
assert!(outputs.is_empty());
4960+
},
4961+
other => panic!("Expected DiscardFunding, got {:?}", other),
4962+
}
4963+
match &events[1] {
4964+
Event::SpliceNegotiationFailed { channel_id: cid, reason, .. } => {
4965+
assert_eq!(*cid, channel_id);
4966+
assert_eq!(*reason, NegotiationFailureReason::CannotInitiateRbf);
4967+
},
4968+
other => panic!("Expected SpliceNegotiationFailed, got {:?}", other),
4969+
}
4970+
}
4971+
48744972
#[test]
48754973
fn test_splice_zeroconf_no_rbf_feerate() {
48764974
// Test that splice_channel returns a FundingTemplate with min_rbf_feerate = None for a

0 commit comments

Comments
 (0)