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