@@ -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]
51665275fn test_splice_zeroconf_no_rbf_feerate ( ) {
51675276 // Test that splice_channel returns a FundingTemplate with min_rbf_feerate = None for a
0 commit comments