@@ -683,9 +683,15 @@ pub fn splice_channel<'a, 'b, 'c, 'd>(
683683 ( splice_tx, new_funding_script)
684684}
685685
686+ pub struct SpliceLockedResult {
687+ pub stfu : Option < MessageSendEvent > ,
688+ pub node_a_discarded : Vec < ( Vec < bitcoin:: OutPoint > , Vec < TxOut > ) > ,
689+ pub node_b_discarded : Vec < ( Vec < bitcoin:: OutPoint > , Vec < TxOut > ) > ,
690+ }
691+
686692pub fn lock_splice_after_blocks < ' a , ' b , ' c , ' d > (
687693 node_a : & ' a Node < ' b , ' c , ' d > , node_b : & ' a Node < ' b , ' c , ' d > , num_blocks : u32 ,
688- ) -> Option < MessageSendEvent > {
694+ ) -> SpliceLockedResult {
689695 connect_blocks ( node_a, num_blocks) ;
690696 connect_blocks ( node_b, num_blocks) ;
691697
@@ -698,7 +704,7 @@ pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>(
698704pub fn lock_splice < ' a , ' b , ' c , ' d > (
699705 node_a : & ' a Node < ' b , ' c , ' d > , node_b : & ' a Node < ' b , ' c , ' d > ,
700706 splice_locked_for_node_b : & msgs:: SpliceLocked , is_0conf : bool , expected_discard_txids : & [ Txid ] ,
701- ) -> Option < MessageSendEvent > {
707+ ) -> SpliceLockedResult {
702708 let prev_funding_txid = node_a
703709 . chain_monitor
704710 . chain_monitor
@@ -735,29 +741,23 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
735741 }
736742 }
737743
738- let mut all_discard_txids = Vec :: new ( ) ;
739- let expected_num_events = 1 + expected_discard_txids . len ( ) ;
740- for node in [ node_a, node_b] {
744+ let mut node_a_discarded = Vec :: new ( ) ;
745+ let mut node_b_discarded = Vec :: new ( ) ;
746+ for ( idx , node) in [ node_a, node_b] . into_iter ( ) . enumerate ( ) {
741747 let events = node. node . get_and_clear_pending_events ( ) ;
742- assert_eq ! ( events. len ( ) , expected_num_events , " {events:?}") ;
748+ assert ! ( ! events. is_empty ( ) , "Expected at least ChannelReady, got {events:?}") ;
743749 assert ! ( matches!( events[ 0 ] , Event :: ChannelReady { .. } ) ) ;
744- let discard_txids: Vec < _ > = events[ 1 ..]
745- . iter ( )
746- . map ( |e| match e {
747- Event :: DiscardFunding { funding_info : FundingInfo :: Tx { transaction } , .. } => {
748- transaction. compute_txid ( )
749- } ,
750+ let discarded = if idx == 0 { & mut node_a_discarded } else { & mut node_b_discarded } ;
751+ for event in & events[ 1 ..] {
752+ match event {
750753 Event :: DiscardFunding {
751- funding_info : FundingInfo :: OutPoint { outpoint } , ..
752- } => outpoint. txid ,
753- other => panic ! ( "Expected DiscardFunding, got {:?}" , other) ,
754- } )
755- . collect ( ) ;
756- for txid in expected_discard_txids {
757- assert ! ( discard_txids. contains( txid) , "Missing DiscardFunding for txid {}" , txid) ;
758- }
759- if all_discard_txids. is_empty ( ) {
760- all_discard_txids = discard_txids;
754+ funding_info : FundingInfo :: Contribution { inputs, outputs } ,
755+ ..
756+ } => {
757+ discarded. push ( ( inputs. clone ( ) , outputs. clone ( ) ) ) ;
758+ } ,
759+ other => panic ! ( "Expected DiscardFunding with Contribution, got {:?}" , other) ,
760+ }
761761 }
762762 check_added_monitors ( node, 1 ) ;
763763 }
@@ -795,18 +795,18 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
795795 // old funding as it is no longer being tracked.
796796 for node in [ node_a, node_b] {
797797 node. chain_source . remove_watched_by_txid ( prev_funding_txid) ;
798- for txid in & all_discard_txids {
798+ for txid in expected_discard_txids {
799799 node. chain_source . remove_watched_by_txid ( * txid) ;
800800 }
801801 }
802802
803- node_a_stfu. or ( node_b_stfu)
803+ SpliceLockedResult { stfu : node_a_stfu. or ( node_b_stfu) , node_a_discarded , node_b_discarded }
804804}
805805
806806pub fn lock_rbf_splice_after_blocks < ' a , ' b , ' c , ' d > (
807807 node_a : & ' a Node < ' b , ' c , ' d > , node_b : & ' a Node < ' b , ' c , ' d > , tx : & Transaction , num_blocks : u32 ,
808808 expected_discard_txids : & [ Txid ] ,
809- ) -> Option < MessageSendEvent > {
809+ ) -> SpliceLockedResult {
810810 mine_transaction ( node_a, tx) ;
811811 mine_transaction ( node_b, tx) ;
812812
@@ -1400,7 +1400,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) {
14001400
14011401 mine_transaction ( & nodes[ 0 ] , & splice_tx) ;
14021402 mine_transaction ( & nodes[ 1 ] , & splice_tx) ;
1403- let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) ;
1403+ let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) . stfu ;
14041404 // Node 0 had called splice_channel (line above) but never funding_contributed, so no stfu
14051405 // is expected from node 0 at this point.
14061406 assert ! ( stfu. is_none( ) ) ;
@@ -1428,7 +1428,7 @@ fn test_initiating_splice_holds_stfu_with_pending_splice() {
14281428 // Mine and lock the splice.
14291429 mine_transaction ( & nodes[ 0 ] , & splice_tx) ;
14301430 mine_transaction ( & nodes[ 1 ] , & splice_tx) ;
1431- let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , 5 ) ;
1431+ let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , 5 ) . stfu ;
14321432 assert ! ( stfu. is_none( ) ) ;
14331433}
14341434
@@ -1664,7 +1664,7 @@ fn do_test_splice_tiebreak(
16641664 mine_transaction ( & nodes[ 1 ] , & tx) ;
16651665
16661666 // After splice_locked, node 1's preserved QuiescentAction triggers STFU for retry.
1667- let node_1_stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) ;
1667+ let node_1_stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) . stfu ;
16681668 let stfu_1 = if let Some ( MessageSendEvent :: SendStfu { msg, .. } ) = node_1_stfu {
16691669 assert ! ( msg. initiator) ;
16701670 msg
@@ -4712,13 +4712,124 @@ fn test_splice_rbf_acceptor_basic() {
47124712 expect_splice_pending_event ( & nodes[ 1 ] , & node_id_0) ;
47134713
47144714 // Step 11: Mine, lock, and verify DiscardFunding for the replaced splice candidate.
4715- lock_rbf_splice_after_blocks (
4715+ let result = lock_rbf_splice_after_blocks (
47164716 & nodes[ 0 ] ,
47174717 & nodes[ 1 ] ,
47184718 & rbf_tx,
47194719 ANTI_REORG_DELAY - 1 ,
47204720 & [ first_splice_tx. compute_txid ( ) ] ,
47214721 ) ;
4722+
4723+ // The test wallet reuses the same UTXO across RBF rounds (the wallet doesn't track
4724+ // in-flight spends), so all contributed inputs are in the promoted tx. No unique
4725+ // contributions to discard.
4726+ assert ! ( result. node_a_discarded. is_empty( ) ) ;
4727+ assert ! ( result. node_b_discarded. is_empty( ) ) ;
4728+ }
4729+
4730+ #[ test]
4731+ fn test_splice_rbf_discard_unique_contribution ( ) {
4732+ // Verify that DiscardFunding events contain the correct unique inputs and outputs when the
4733+ // RBF round uses different UTXOs than the initial splice. By clearing the wallet between
4734+ // rounds and providing fresh UTXOs, we force distinct inputs per round. Round 0 also
4735+ // includes a splice-out output with a unique script_pubkey not present in the RBF tx.
4736+ // When the RBF is promoted, round 0's inputs and splice-out output should appear in
4737+ // DiscardFunding. The change output is filtered because it shares a script_pubkey with the
4738+ // promoted tx's change output.
4739+ let chanmon_cfgs = create_chanmon_cfgs ( 2 ) ;
4740+ let node_cfgs = create_node_cfgs ( 2 , & chanmon_cfgs) ;
4741+ let node_chanmgrs = create_node_chanmgrs ( 2 , & node_cfgs, & [ None , None ] ) ;
4742+ let nodes = create_network ( 2 , & node_cfgs, & node_chanmgrs) ;
4743+
4744+ let node_id_0 = nodes[ 0 ] . node . get_our_node_id ( ) ;
4745+ let node_id_1 = nodes[ 1 ] . node . get_our_node_id ( ) ;
4746+
4747+ let initial_channel_value_sat = 100_000 ;
4748+ let ( _, _, channel_id, _) =
4749+ create_announced_chan_between_nodes_with_value ( & nodes, 0 , 1 , initial_channel_value_sat, 0 ) ;
4750+
4751+ let added_value = Amount :: from_sat ( 50_000 ) ;
4752+ provide_utxo_reserves ( & nodes, 2 , added_value * 2 ) ;
4753+
4754+ // Round 0: Splice-in-and-out from node 0 with a splice-out output.
4755+ let splice_out_output = TxOut {
4756+ value : Amount :: from_sat ( 5_000 ) ,
4757+ script_pubkey : ScriptBuf :: new_p2wpkh ( & WPubkeyHash :: all_zeros ( ) ) ,
4758+ } ;
4759+ let funding_contribution = do_initiate_splice_in_and_out (
4760+ & nodes[ 0 ] ,
4761+ & nodes[ 1 ] ,
4762+ channel_id,
4763+ added_value,
4764+ vec ! [ splice_out_output. clone( ) ] ,
4765+ ) ;
4766+ let round_0_inputs: Vec < _ > = funding_contribution. contributed_inputs ( ) . collect ( ) ;
4767+ assert ! ( !round_0_inputs. is_empty( ) ) ;
4768+
4769+ let ( first_splice_tx, new_funding_script) =
4770+ splice_channel ( & nodes[ 0 ] , & nodes[ 1 ] , channel_id, funding_contribution) ;
4771+
4772+ // Clear node 0's wallet so round 1 must use different UTXOs.
4773+ nodes[ 0 ] . wallet_source . clear_utxos ( ) ;
4774+ provide_utxo_reserves ( & nodes, 2 , added_value * 2 ) ;
4775+
4776+ // Round 1: RBF with fresh UTXOs, splice-in only (no splice-out output).
4777+ let rbf_feerate = FeeRate :: from_sat_per_kwu ( FEERATE_FLOOR_SATS_PER_KW as u64 + 25 ) ;
4778+ let funding_template = nodes[ 0 ] . node . splice_channel ( & channel_id, & node_id_1) . unwrap ( ) ;
4779+ let wallet = WalletSync :: new ( Arc :: clone ( & nodes[ 0 ] . wallet_source ) , nodes[ 0 ] . logger ) ;
4780+ let funding_contribution = funding_template
4781+ . without_prior_contribution ( rbf_feerate, FeeRate :: MAX )
4782+ . with_coin_selection_source_sync ( & wallet)
4783+ . add_value ( added_value)
4784+ . build ( )
4785+ . unwrap ( ) ;
4786+ nodes[ 0 ]
4787+ . node
4788+ . funding_contributed ( & channel_id, & node_id_1, funding_contribution. clone ( ) , None )
4789+ . unwrap ( ) ;
4790+ let round_1_inputs: Vec < _ > = funding_contribution. contributed_inputs ( ) . collect ( ) ;
4791+ assert_ne ! ( round_0_inputs, round_1_inputs, "Rounds must use different UTXOs" ) ;
4792+
4793+ complete_rbf_handshake ( & nodes[ 0 ] , & nodes[ 1 ] ) ;
4794+
4795+ complete_interactive_funding_negotiation (
4796+ & nodes[ 0 ] ,
4797+ & nodes[ 1 ] ,
4798+ channel_id,
4799+ funding_contribution,
4800+ new_funding_script. clone ( ) ,
4801+ ) ;
4802+
4803+ let ( rbf_tx, splice_locked) = sign_interactive_funding_tx (
4804+ & nodes[ 0 ] ,
4805+ & nodes[ 1 ] ,
4806+ false ,
4807+ Some ( first_splice_tx. compute_txid ( ) ) ,
4808+ ) ;
4809+ assert ! ( splice_locked. is_none( ) ) ;
4810+
4811+ expect_splice_pending_event ( & nodes[ 0 ] , & node_id_1) ;
4812+ expect_splice_pending_event ( & nodes[ 1 ] , & node_id_0) ;
4813+
4814+ let result = lock_rbf_splice_after_blocks (
4815+ & nodes[ 0 ] ,
4816+ & nodes[ 1 ] ,
4817+ & rbf_tx,
4818+ ANTI_REORG_DELAY - 1 ,
4819+ & [ first_splice_tx. compute_txid ( ) ] ,
4820+ ) ;
4821+
4822+ // Node 0's round 0 inputs are NOT in the promoted tx (which uses round 1's fresh UTXOs),
4823+ // so they appear as unique contributions to discard. The splice-out output also survives
4824+ // because its script_pubkey is not in the promoted tx. The change output is filtered
4825+ // because it shares a script_pubkey with the promoted tx's change output.
4826+ assert_eq ! ( result. node_a_discarded. len( ) , 1 ) ;
4827+ let ( ref inputs, ref outputs) = result. node_a_discarded [ 0 ] ;
4828+ assert_eq ! ( * inputs, round_0_inputs) ;
4829+ assert_eq ! ( * outputs, vec![ splice_out_output] ) ;
4830+
4831+ // Node 1 (non-contributing acceptor) has no contributions to discard.
4832+ assert ! ( result. node_b_discarded. is_empty( ) ) ;
47224833}
47234834
47244835#[ test]
@@ -5663,13 +5774,18 @@ pub fn do_test_splice_rbf_tiebreak(
56635774 expect_splice_pending_event ( & nodes[ 1 ] , & node_id_0) ;
56645775
56655776 // Mine, lock, and verify DiscardFunding for the replaced splice candidate.
5666- lock_rbf_splice_after_blocks (
5777+ let result = lock_rbf_splice_after_blocks (
56675778 & nodes[ 0 ] ,
56685779 & nodes[ 1 ] ,
56695780 & rbf_tx,
56705781 ANTI_REORG_DELAY - 1 ,
56715782 & [ first_splice_tx. compute_txid ( ) ] ,
56725783 ) ;
5784+
5785+ // The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
5786+ // are in the promoted tx and nothing is unique to discard.
5787+ assert ! ( result. node_a_discarded. is_empty( ) ) ;
5788+ assert ! ( result. node_b_discarded. is_empty( ) ) ;
56735789 } else {
56745790 // Acceptor does not contribute — complete with only node 0's inputs/outputs.
56755791 complete_interactive_funding_negotiation_for_both (
@@ -5698,14 +5814,14 @@ pub fn do_test_splice_rbf_tiebreak(
56985814 // Mine, lock, and verify DiscardFunding for the replaced splice candidate.
56995815 // Node 1's QuiescentAction was preserved, so after splice_locked it re-initiates
57005816 // quiescence to retry its contribution in a future splice.
5701- let node_b_stfu = lock_rbf_splice_after_blocks (
5817+ let result = lock_rbf_splice_after_blocks (
57025818 & nodes[ 0 ] ,
57035819 & nodes[ 1 ] ,
57045820 & rbf_tx,
57055821 ANTI_REORG_DELAY - 1 ,
57065822 & [ first_splice_tx. compute_txid ( ) ] ,
57075823 ) ;
5708- let stfu_1 = if let Some ( MessageSendEvent :: SendStfu { msg, .. } ) = node_b_stfu {
5824+ let stfu_1 = if let Some ( MessageSendEvent :: SendStfu { msg, .. } ) = result . stfu {
57095825 msg
57105826 } else {
57115827 panic ! ( "Expected SendStfu from node 1" ) ;
@@ -5985,13 +6101,18 @@ fn test_splice_rbf_acceptor_recontributes() {
59856101 expect_splice_pending_event ( & nodes[ 1 ] , & node_id_0) ;
59866102
59876103 // Step 12: Mine, lock, and verify DiscardFunding for the replaced splice candidate.
5988- lock_rbf_splice_after_blocks (
6104+ let result = lock_rbf_splice_after_blocks (
59896105 & nodes[ 0 ] ,
59906106 & nodes[ 1 ] ,
59916107 & rbf_tx,
59926108 ANTI_REORG_DELAY - 1 ,
59936109 & [ first_splice_tx. compute_txid ( ) ] ,
59946110 ) ;
6111+
6112+ // The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
6113+ // are in the promoted tx and nothing is unique to discard.
6114+ assert ! ( result. node_a_discarded. is_empty( ) ) ;
6115+ assert ! ( result. node_b_discarded. is_empty( ) ) ;
59956116}
59966117
59976118#[ test]
@@ -6314,13 +6435,18 @@ fn test_splice_rbf_sequential() {
63146435 // --- Mine and lock the final RBF, verifying DiscardFunding for both replaced candidates. ---
63156436 let splice_tx_0_txid = splice_tx_0. compute_txid ( ) ;
63166437 let splice_tx_1_txid = splice_tx_1. compute_txid ( ) ;
6317- lock_rbf_splice_after_blocks (
6438+ let result = lock_rbf_splice_after_blocks (
63186439 & nodes[ 0 ] ,
63196440 & nodes[ 1 ] ,
63206441 & rbf_tx_final,
63216442 ANTI_REORG_DELAY - 1 ,
63226443 & [ splice_tx_0_txid, splice_tx_1_txid] ,
63236444 ) ;
6445+
6446+ // The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
6447+ // are in the promoted tx and nothing is unique to discard.
6448+ assert ! ( result. node_a_discarded. is_empty( ) ) ;
6449+ assert ! ( result. node_b_discarded. is_empty( ) ) ;
63246450}
63256451
63266452#[ test]
@@ -6913,7 +7039,7 @@ fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() {
69137039 // Mine and lock the pending splice → pending_splice is cleared.
69147040 mine_transaction ( & nodes[ 0 ] , & _splice_tx) ;
69157041 mine_transaction ( & nodes[ 1 ] , & _splice_tx) ;
6916- let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) ;
7042+ let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) . stfu ;
69177043
69187044 // STFU is sent during lock — the splice proceeds as a fresh splice (not RBF).
69197045 let stfu = match stfu {
@@ -6990,7 +7116,7 @@ fn test_funding_contributed_rbf_adjustment_insufficient_budget() {
69907116 // Mine and lock the pending splice → pending_splice is cleared.
69917117 mine_transaction ( & nodes[ 0 ] , & _splice_tx) ;
69927118 mine_transaction ( & nodes[ 1 ] , & _splice_tx) ;
6993- let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) ;
7119+ let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) . stfu ;
69947120
69957121 // STFU is sent during lock — the splice proceeds as a fresh splice (not RBF).
69967122 let stfu = match stfu {
0 commit comments