@@ -660,9 +660,15 @@ pub fn splice_channel<'a, 'b, 'c, 'd>(
660660 ( splice_tx, new_funding_script)
661661}
662662
663+ pub struct SpliceLockedResult {
664+ pub stfu : Option < MessageSendEvent > ,
665+ pub node_a_discarded : Vec < ( Vec < bitcoin:: OutPoint > , Vec < TxOut > ) > ,
666+ pub node_b_discarded : Vec < ( Vec < bitcoin:: OutPoint > , Vec < TxOut > ) > ,
667+ }
668+
663669pub fn lock_splice_after_blocks < ' a , ' b , ' c , ' d > (
664670 node_a : & ' a Node < ' b , ' c , ' d > , node_b : & ' a Node < ' b , ' c , ' d > , num_blocks : u32 ,
665- ) -> Option < MessageSendEvent > {
671+ ) -> SpliceLockedResult {
666672 connect_blocks ( node_a, num_blocks) ;
667673 connect_blocks ( node_b, num_blocks) ;
668674
@@ -675,7 +681,7 @@ pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>(
675681pub fn lock_splice < ' a , ' b , ' c , ' d > (
676682 node_a : & ' a Node < ' b , ' c , ' d > , node_b : & ' a Node < ' b , ' c , ' d > ,
677683 splice_locked_for_node_b : & msgs:: SpliceLocked , is_0conf : bool , expected_discard_txids : & [ Txid ] ,
678- ) -> Option < MessageSendEvent > {
684+ ) -> SpliceLockedResult {
679685 let prev_funding_txid = node_a
680686 . chain_monitor
681687 . chain_monitor
@@ -712,29 +718,23 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
712718 }
713719 }
714720
715- let mut all_discard_txids = Vec :: new ( ) ;
716- let expected_num_events = 1 + expected_discard_txids . len ( ) ;
717- for node in [ node_a, node_b] {
721+ let mut node_a_discarded = Vec :: new ( ) ;
722+ let mut node_b_discarded = Vec :: new ( ) ;
723+ for ( idx , node) in [ node_a, node_b] . into_iter ( ) . enumerate ( ) {
718724 let events = node. node . get_and_clear_pending_events ( ) ;
719- assert_eq ! ( events. len ( ) , expected_num_events , " {events:?}") ;
725+ assert ! ( ! events. is_empty ( ) , "Expected at least ChannelReady, got {events:?}") ;
720726 assert ! ( matches!( events[ 0 ] , Event :: ChannelReady { .. } ) ) ;
721- let discard_txids: Vec < _ > = events[ 1 ..]
722- . iter ( )
723- . map ( |e| match e {
724- Event :: DiscardFunding { funding_info : FundingInfo :: Tx { transaction } , .. } => {
725- transaction. compute_txid ( )
726- } ,
727+ let discarded = if idx == 0 { & mut node_a_discarded } else { & mut node_b_discarded } ;
728+ for event in & events[ 1 ..] {
729+ match event {
727730 Event :: DiscardFunding {
728- funding_info : FundingInfo :: OutPoint { outpoint } , ..
729- } => outpoint. txid ,
730- other => panic ! ( "Expected DiscardFunding, got {:?}" , other) ,
731- } )
732- . collect ( ) ;
733- for txid in expected_discard_txids {
734- assert ! ( discard_txids. contains( txid) , "Missing DiscardFunding for txid {}" , txid) ;
735- }
736- if all_discard_txids. is_empty ( ) {
737- all_discard_txids = discard_txids;
731+ funding_info : FundingInfo :: Contribution { inputs, outputs } ,
732+ ..
733+ } => {
734+ discarded. push ( ( inputs. clone ( ) , outputs. clone ( ) ) ) ;
735+ } ,
736+ other => panic ! ( "Expected DiscardFunding with Contribution, got {:?}" , other) ,
737+ }
738738 }
739739 check_added_monitors ( node, 1 ) ;
740740 }
@@ -772,18 +772,18 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
772772 // old funding as it is no longer being tracked.
773773 for node in [ node_a, node_b] {
774774 node. chain_source . remove_watched_by_txid ( prev_funding_txid) ;
775- for txid in & all_discard_txids {
775+ for txid in expected_discard_txids {
776776 node. chain_source . remove_watched_by_txid ( * txid) ;
777777 }
778778 }
779779
780- node_a_stfu. or ( node_b_stfu)
780+ SpliceLockedResult { stfu : node_a_stfu. or ( node_b_stfu) , node_a_discarded , node_b_discarded }
781781}
782782
783783pub fn lock_rbf_splice_after_blocks < ' a , ' b , ' c , ' d > (
784784 node_a : & ' a Node < ' b , ' c , ' d > , node_b : & ' a Node < ' b , ' c , ' d > , tx : & Transaction , num_blocks : u32 ,
785785 expected_discard_txids : & [ Txid ] ,
786- ) -> Option < MessageSendEvent > {
786+ ) -> SpliceLockedResult {
787787 mine_transaction ( node_a, tx) ;
788788 mine_transaction ( node_b, tx) ;
789789
@@ -1377,7 +1377,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) {
13771377
13781378 mine_transaction ( & nodes[ 0 ] , & splice_tx) ;
13791379 mine_transaction ( & nodes[ 1 ] , & splice_tx) ;
1380- let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) ;
1380+ let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) . stfu ;
13811381 // Node 0 had called splice_channel (line above) but never funding_contributed, so no stfu
13821382 // is expected from node 0 at this point.
13831383 assert ! ( stfu. is_none( ) ) ;
@@ -1405,7 +1405,7 @@ fn test_initiating_splice_holds_stfu_with_pending_splice() {
14051405 // Mine and lock the splice.
14061406 mine_transaction ( & nodes[ 0 ] , & splice_tx) ;
14071407 mine_transaction ( & nodes[ 1 ] , & splice_tx) ;
1408- let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , 5 ) ;
1408+ let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , 5 ) . stfu ;
14091409 assert ! ( stfu. is_none( ) ) ;
14101410}
14111411
@@ -1641,7 +1641,7 @@ fn do_test_splice_tiebreak(
16411641 mine_transaction ( & nodes[ 1 ] , & tx) ;
16421642
16431643 // After splice_locked, node 1's preserved QuiescentAction triggers STFU for retry.
1644- let node_1_stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) ;
1644+ let node_1_stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) . stfu ;
16451645 let stfu_1 = if let Some ( MessageSendEvent :: SendStfu { msg, .. } ) = node_1_stfu {
16461646 assert ! ( msg. initiator) ;
16471647 msg
@@ -4683,13 +4683,119 @@ fn test_splice_rbf_acceptor_basic() {
46834683 expect_splice_pending_event ( & nodes[ 1 ] , & node_id_0) ;
46844684
46854685 // Step 11: Mine, lock, and verify DiscardFunding for the replaced splice candidate.
4686- lock_rbf_splice_after_blocks (
4686+ let result = lock_rbf_splice_after_blocks (
4687+ & nodes[ 0 ] ,
4688+ & nodes[ 1 ] ,
4689+ & rbf_tx,
4690+ ANTI_REORG_DELAY - 1 ,
4691+ & [ first_splice_tx. compute_txid ( ) ] ,
4692+ ) ;
4693+
4694+ // The test wallet reuses the same UTXO across RBF rounds (the wallet doesn't track
4695+ // in-flight spends), so all contributed inputs are in the promoted tx. No unique
4696+ // contributions to discard.
4697+ assert ! ( result. node_a_discarded. is_empty( ) ) ;
4698+ assert ! ( result. node_b_discarded. is_empty( ) ) ;
4699+ }
4700+
4701+ #[ test]
4702+ fn test_splice_rbf_discard_unique_contribution ( ) {
4703+ // Verify that DiscardFunding events contain the correct unique inputs and outputs when the
4704+ // RBF round uses different UTXOs than the initial splice. By clearing the wallet between
4705+ // rounds and providing fresh UTXOs, we force distinct inputs per round. Round 0 also
4706+ // includes a splice-out output with a unique script_pubkey not present in the RBF tx.
4707+ // When the RBF is promoted, round 0's inputs and splice-out output should appear in
4708+ // DiscardFunding. The change output is filtered because it shares a script_pubkey with the
4709+ // promoted tx's change output.
4710+ let chanmon_cfgs = create_chanmon_cfgs ( 2 ) ;
4711+ let node_cfgs = create_node_cfgs ( 2 , & chanmon_cfgs) ;
4712+ let node_chanmgrs = create_node_chanmgrs ( 2 , & node_cfgs, & [ None , None ] ) ;
4713+ let nodes = create_network ( 2 , & node_cfgs, & node_chanmgrs) ;
4714+
4715+ let node_id_0 = nodes[ 0 ] . node . get_our_node_id ( ) ;
4716+ let node_id_1 = nodes[ 1 ] . node . get_our_node_id ( ) ;
4717+
4718+ let initial_channel_value_sat = 100_000 ;
4719+ let ( _, _, channel_id, _) =
4720+ create_announced_chan_between_nodes_with_value ( & nodes, 0 , 1 , initial_channel_value_sat, 0 ) ;
4721+
4722+ let added_value = Amount :: from_sat ( 50_000 ) ;
4723+ provide_utxo_reserves ( & nodes, 2 , added_value * 2 ) ;
4724+
4725+ // Round 0: Splice-in-and-out from node 0 with a splice-out output.
4726+ let splice_out_output = TxOut {
4727+ value : Amount :: from_sat ( 5_000 ) ,
4728+ script_pubkey : ScriptBuf :: new_p2wpkh ( & WPubkeyHash :: all_zeros ( ) ) ,
4729+ } ;
4730+ let funding_contribution = do_initiate_splice_in_and_out (
4731+ & nodes[ 0 ] ,
4732+ & nodes[ 1 ] ,
4733+ channel_id,
4734+ added_value,
4735+ vec ! [ splice_out_output. clone( ) ] ,
4736+ ) ;
4737+ let round_0_inputs: Vec < _ > = funding_contribution. contributed_inputs ( ) . collect ( ) ;
4738+ assert ! ( !round_0_inputs. is_empty( ) ) ;
4739+
4740+ let ( first_splice_tx, new_funding_script) =
4741+ splice_channel ( & nodes[ 0 ] , & nodes[ 1 ] , channel_id, funding_contribution) ;
4742+
4743+ // Clear node 0's wallet so round 1 must use different UTXOs.
4744+ nodes[ 0 ] . wallet_source . clear_utxos ( ) ;
4745+ provide_utxo_reserves ( & nodes, 2 , added_value * 2 ) ;
4746+
4747+ // Round 1: RBF with fresh UTXOs, splice-in only (no splice-out output).
4748+ let rbf_feerate = FeeRate :: from_sat_per_kwu ( FEERATE_FLOOR_SATS_PER_KW as u64 + 25 ) ;
4749+ let funding_template = nodes[ 0 ] . node . splice_channel ( & channel_id, & node_id_1) . unwrap ( ) ;
4750+ let wallet = WalletSync :: new ( Arc :: clone ( & nodes[ 0 ] . wallet_source ) , nodes[ 0 ] . logger ) ;
4751+ let funding_contribution = funding_template
4752+ . without_prior_contribution ( rbf_feerate, FeeRate :: MAX )
4753+ . with_coin_selection_source_sync ( & wallet)
4754+ . add_value ( added_value)
4755+ . build ( )
4756+ . unwrap ( ) ;
4757+ nodes[ 0 ]
4758+ . node
4759+ . funding_contributed ( & channel_id, & node_id_1, funding_contribution. clone ( ) , None )
4760+ . unwrap ( ) ;
4761+ let round_1_inputs: Vec < _ > = funding_contribution. contributed_inputs ( ) . collect ( ) ;
4762+ assert_ne ! ( round_0_inputs, round_1_inputs, "Rounds must use different UTXOs" ) ;
4763+
4764+ complete_rbf_handshake ( & nodes[ 0 ] , & nodes[ 1 ] ) ;
4765+
4766+ complete_interactive_funding_negotiation (
4767+ & nodes[ 0 ] ,
4768+ & nodes[ 1 ] ,
4769+ channel_id,
4770+ funding_contribution,
4771+ new_funding_script. clone ( ) ,
4772+ ) ;
4773+
4774+ let ( rbf_tx, splice_locked) = sign_interactive_funding_tx ( & nodes[ 0 ] , & nodes[ 1 ] , false ) ;
4775+ assert ! ( splice_locked. is_none( ) ) ;
4776+
4777+ expect_splice_pending_event ( & nodes[ 0 ] , & node_id_1) ;
4778+ expect_splice_pending_event ( & nodes[ 1 ] , & node_id_0) ;
4779+
4780+ let result = lock_rbf_splice_after_blocks (
46874781 & nodes[ 0 ] ,
46884782 & nodes[ 1 ] ,
46894783 & rbf_tx,
46904784 ANTI_REORG_DELAY - 1 ,
46914785 & [ first_splice_tx. compute_txid ( ) ] ,
46924786 ) ;
4787+
4788+ // Node 0's round 0 inputs are NOT in the promoted tx (which uses round 1's fresh UTXOs),
4789+ // so they appear as unique contributions to discard. The splice-out output also survives
4790+ // because its script_pubkey is not in the promoted tx. The change output is filtered
4791+ // because it shares a script_pubkey with the promoted tx's change output.
4792+ assert_eq ! ( result. node_a_discarded. len( ) , 1 ) ;
4793+ let ( ref inputs, ref outputs) = result. node_a_discarded [ 0 ] ;
4794+ assert_eq ! ( * inputs, round_0_inputs) ;
4795+ assert_eq ! ( * outputs, vec![ splice_out_output] ) ;
4796+
4797+ // Node 1 (non-contributing acceptor) has no contributions to discard.
4798+ assert ! ( result. node_b_discarded. is_empty( ) ) ;
46934799}
46944800
46954801#[ test]
@@ -5623,13 +5729,18 @@ pub fn do_test_splice_rbf_tiebreak(
56235729 expect_splice_pending_event ( & nodes[ 1 ] , & node_id_0) ;
56245730
56255731 // Mine, lock, and verify DiscardFunding for the replaced splice candidate.
5626- lock_rbf_splice_after_blocks (
5732+ let result = lock_rbf_splice_after_blocks (
56275733 & nodes[ 0 ] ,
56285734 & nodes[ 1 ] ,
56295735 & rbf_tx,
56305736 ANTI_REORG_DELAY - 1 ,
56315737 & [ first_splice_tx. compute_txid ( ) ] ,
56325738 ) ;
5739+
5740+ // The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
5741+ // are in the promoted tx and nothing is unique to discard.
5742+ assert ! ( result. node_a_discarded. is_empty( ) ) ;
5743+ assert ! ( result. node_b_discarded. is_empty( ) ) ;
56335744 } else {
56345745 // Acceptor does not contribute — complete with only node 0's inputs/outputs.
56355746 complete_interactive_funding_negotiation_for_both (
@@ -5654,14 +5765,14 @@ pub fn do_test_splice_rbf_tiebreak(
56545765 // Mine, lock, and verify DiscardFunding for the replaced splice candidate.
56555766 // Node 1's QuiescentAction was preserved, so after splice_locked it re-initiates
56565767 // quiescence to retry its contribution in a future splice.
5657- let node_b_stfu = lock_rbf_splice_after_blocks (
5768+ let result = lock_rbf_splice_after_blocks (
56585769 & nodes[ 0 ] ,
56595770 & nodes[ 1 ] ,
56605771 & rbf_tx,
56615772 ANTI_REORG_DELAY - 1 ,
56625773 & [ first_splice_tx. compute_txid ( ) ] ,
56635774 ) ;
5664- let stfu_1 = if let Some ( MessageSendEvent :: SendStfu { msg, .. } ) = node_b_stfu {
5775+ let stfu_1 = if let Some ( MessageSendEvent :: SendStfu { msg, .. } ) = result . stfu {
56655776 msg
56665777 } else {
56675778 panic ! ( "Expected SendStfu from node 1" ) ;
@@ -5935,13 +6046,18 @@ fn test_splice_rbf_acceptor_recontributes() {
59356046 expect_splice_pending_event ( & nodes[ 1 ] , & node_id_0) ;
59366047
59376048 // Step 12: Mine, lock, and verify DiscardFunding for the replaced splice candidate.
5938- lock_rbf_splice_after_blocks (
6049+ let result = lock_rbf_splice_after_blocks (
59396050 & nodes[ 0 ] ,
59406051 & nodes[ 1 ] ,
59416052 & rbf_tx,
59426053 ANTI_REORG_DELAY - 1 ,
59436054 & [ first_splice_tx. compute_txid ( ) ] ,
59446055 ) ;
6056+
6057+ // The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
6058+ // are in the promoted tx and nothing is unique to discard.
6059+ assert ! ( result. node_a_discarded. is_empty( ) ) ;
6060+ assert ! ( result. node_b_discarded. is_empty( ) ) ;
59456061}
59466062
59476063#[ test]
@@ -6260,13 +6376,18 @@ fn test_splice_rbf_sequential() {
62606376 // --- Mine and lock the final RBF, verifying DiscardFunding for both replaced candidates. ---
62616377 let splice_tx_0_txid = splice_tx_0. compute_txid ( ) ;
62626378 let splice_tx_1_txid = splice_tx_1. compute_txid ( ) ;
6263- lock_rbf_splice_after_blocks (
6379+ let result = lock_rbf_splice_after_blocks (
62646380 & nodes[ 0 ] ,
62656381 & nodes[ 1 ] ,
62666382 & rbf_tx_final,
62676383 ANTI_REORG_DELAY - 1 ,
62686384 & [ splice_tx_0_txid, splice_tx_1_txid] ,
62696385 ) ;
6386+
6387+ // The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
6388+ // are in the promoted tx and nothing is unique to discard.
6389+ assert ! ( result. node_a_discarded. is_empty( ) ) ;
6390+ assert ! ( result. node_b_discarded. is_empty( ) ) ;
62706391}
62716392
62726393#[ test]
@@ -6856,7 +6977,7 @@ fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() {
68566977 // Mine and lock the pending splice → pending_splice is cleared.
68576978 mine_transaction ( & nodes[ 0 ] , & _splice_tx) ;
68586979 mine_transaction ( & nodes[ 1 ] , & _splice_tx) ;
6859- let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) ;
6980+ let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) . stfu ;
68606981
68616982 // STFU is sent during lock — the splice proceeds as a fresh splice (not RBF).
68626983 let stfu = match stfu {
@@ -6933,7 +7054,7 @@ fn test_funding_contributed_rbf_adjustment_insufficient_budget() {
69337054 // Mine and lock the pending splice → pending_splice is cleared.
69347055 mine_transaction ( & nodes[ 0 ] , & _splice_tx) ;
69357056 mine_transaction ( & nodes[ 1 ] , & _splice_tx) ;
6936- let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) ;
7057+ let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) . stfu ;
69377058
69387059 // STFU is sent during lock — the splice proceeds as a fresh splice (not RBF).
69397060 let stfu = match stfu {
0 commit comments