@@ -675,9 +675,15 @@ pub fn splice_channel<'a, 'b, 'c, 'd>(
675675 ( splice_tx, new_funding_script)
676676}
677677
678+ pub struct SpliceLockedResult {
679+ pub stfu : Option < MessageSendEvent > ,
680+ pub node_a_discarded : Vec < ( Vec < bitcoin:: OutPoint > , Vec < TxOut > ) > ,
681+ pub node_b_discarded : Vec < ( Vec < bitcoin:: OutPoint > , Vec < TxOut > ) > ,
682+ }
683+
678684pub fn lock_splice_after_blocks < ' a , ' b , ' c , ' d > (
679685 node_a : & ' a Node < ' b , ' c , ' d > , node_b : & ' a Node < ' b , ' c , ' d > , num_blocks : u32 ,
680- ) -> Option < MessageSendEvent > {
686+ ) -> SpliceLockedResult {
681687 connect_blocks ( node_a, num_blocks) ;
682688 connect_blocks ( node_b, num_blocks) ;
683689
@@ -690,7 +696,7 @@ pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>(
690696pub fn lock_splice < ' a , ' b , ' c , ' d > (
691697 node_a : & ' a Node < ' b , ' c , ' d > , node_b : & ' a Node < ' b , ' c , ' d > ,
692698 splice_locked_for_node_b : & msgs:: SpliceLocked , is_0conf : bool , expected_discard_txids : & [ Txid ] ,
693- ) -> Option < MessageSendEvent > {
699+ ) -> SpliceLockedResult {
694700 let prev_funding_txid = node_a
695701 . chain_monitor
696702 . chain_monitor
@@ -727,29 +733,23 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
727733 }
728734 }
729735
730- let mut all_discard_txids = Vec :: new ( ) ;
731- let expected_num_events = 1 + expected_discard_txids . len ( ) ;
732- for node in [ node_a, node_b] {
736+ let mut node_a_discarded = Vec :: new ( ) ;
737+ let mut node_b_discarded = Vec :: new ( ) ;
738+ for ( idx , node) in [ node_a, node_b] . into_iter ( ) . enumerate ( ) {
733739 let events = node. node . get_and_clear_pending_events ( ) ;
734- assert_eq ! ( events. len ( ) , expected_num_events , " {events:?}") ;
740+ assert ! ( ! events. is_empty ( ) , "Expected at least ChannelReady, got {events:?}") ;
735741 assert ! ( matches!( events[ 0 ] , Event :: ChannelReady { .. } ) ) ;
736- let discard_txids: Vec < _ > = events[ 1 ..]
737- . iter ( )
738- . map ( |e| match e {
739- Event :: DiscardFunding { funding_info : FundingInfo :: Tx { transaction } , .. } => {
740- transaction. compute_txid ( )
741- } ,
742+ let discarded = if idx == 0 { & mut node_a_discarded } else { & mut node_b_discarded } ;
743+ for event in & events[ 1 ..] {
744+ match event {
742745 Event :: DiscardFunding {
743- funding_info : FundingInfo :: OutPoint { outpoint } , ..
744- } => outpoint. txid ,
745- other => panic ! ( "Expected DiscardFunding, got {:?}" , other) ,
746- } )
747- . collect ( ) ;
748- for txid in expected_discard_txids {
749- assert ! ( discard_txids. contains( txid) , "Missing DiscardFunding for txid {}" , txid) ;
750- }
751- if all_discard_txids. is_empty ( ) {
752- all_discard_txids = discard_txids;
746+ funding_info : FundingInfo :: Contribution { inputs, outputs } ,
747+ ..
748+ } => {
749+ discarded. push ( ( inputs. clone ( ) , outputs. clone ( ) ) ) ;
750+ } ,
751+ other => panic ! ( "Expected DiscardFunding with Contribution, got {:?}" , other) ,
752+ }
753753 }
754754 check_added_monitors ( node, 1 ) ;
755755 }
@@ -787,18 +787,18 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
787787 // old funding as it is no longer being tracked.
788788 for node in [ node_a, node_b] {
789789 node. chain_source . remove_watched_by_txid ( prev_funding_txid) ;
790- for txid in & all_discard_txids {
790+ for txid in expected_discard_txids {
791791 node. chain_source . remove_watched_by_txid ( * txid) ;
792792 }
793793 }
794794
795- node_a_stfu. or ( node_b_stfu)
795+ SpliceLockedResult { stfu : node_a_stfu. or ( node_b_stfu) , node_a_discarded , node_b_discarded }
796796}
797797
798798pub fn lock_rbf_splice_after_blocks < ' a , ' b , ' c , ' d > (
799799 node_a : & ' a Node < ' b , ' c , ' d > , node_b : & ' a Node < ' b , ' c , ' d > , tx : & Transaction , num_blocks : u32 ,
800800 expected_discard_txids : & [ Txid ] ,
801- ) -> Option < MessageSendEvent > {
801+ ) -> SpliceLockedResult {
802802 mine_transaction ( node_a, tx) ;
803803 mine_transaction ( node_b, tx) ;
804804
@@ -1468,7 +1468,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) {
14681468
14691469 mine_transaction ( & nodes[ 0 ] , & splice_tx) ;
14701470 mine_transaction ( & nodes[ 1 ] , & splice_tx) ;
1471- let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) ;
1471+ let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) . stfu ;
14721472 // Node 0 had called splice_channel (line above) but never funding_contributed, so no stfu
14731473 // is expected from node 0 at this point.
14741474 assert ! ( stfu. is_none( ) ) ;
@@ -1496,7 +1496,7 @@ fn test_initiating_splice_holds_stfu_with_pending_splice() {
14961496 // Mine and lock the splice.
14971497 mine_transaction ( & nodes[ 0 ] , & splice_tx) ;
14981498 mine_transaction ( & nodes[ 1 ] , & splice_tx) ;
1499- let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , 5 ) ;
1499+ let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , 5 ) . stfu ;
15001500 assert ! ( stfu. is_none( ) ) ;
15011501}
15021502
@@ -1732,7 +1732,7 @@ fn do_test_splice_tiebreak(
17321732 mine_transaction ( & nodes[ 1 ] , & tx) ;
17331733
17341734 // After splice_locked, node 1's preserved QuiescentAction triggers STFU for retry.
1735- let node_1_stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) ;
1735+ let node_1_stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) . stfu ;
17361736 let stfu_1 = if let Some ( MessageSendEvent :: SendStfu { msg, .. } ) = node_1_stfu {
17371737 assert ! ( msg. initiator) ;
17381738 msg
@@ -4561,13 +4561,109 @@ fn test_splice_rbf_acceptor_basic() {
45614561 expect_splice_pending_event ( & nodes[ 1 ] , & node_id_0) ;
45624562
45634563 // Step 11: Mine, lock, and verify DiscardFunding for the replaced splice candidate.
4564- lock_rbf_splice_after_blocks (
4564+ let result = lock_rbf_splice_after_blocks (
45654565 & nodes[ 0 ] ,
45664566 & nodes[ 1 ] ,
45674567 & rbf_tx,
45684568 ANTI_REORG_DELAY - 1 ,
45694569 & [ first_splice_tx. compute_txid ( ) ] ,
45704570 ) ;
4571+
4572+ // The test wallet reuses the same UTXO across RBF rounds (the wallet doesn't track
4573+ // in-flight spends), so all contributed inputs are in the promoted tx. No unique
4574+ // contributions to discard.
4575+ assert ! ( result. node_a_discarded. is_empty( ) ) ;
4576+ assert ! ( result. node_b_discarded. is_empty( ) ) ;
4577+ }
4578+
4579+ #[ test]
4580+ fn test_splice_rbf_discard_unique_contribution ( ) {
4581+ // Verify that DiscardFunding events contain the correct unique inputs and outputs when the
4582+ // RBF round uses different UTXOs than the initial splice. By clearing the wallet between
4583+ // rounds and providing fresh UTXOs, we force distinct inputs per round. Round 0 also
4584+ // includes a splice-out output with a unique script_pubkey not present in the RBF tx.
4585+ // When the RBF is promoted, round 0's inputs and splice-out output should appear in
4586+ // DiscardFunding. The change output is filtered because it shares a script_pubkey with the
4587+ // promoted tx's change output.
4588+ let chanmon_cfgs = create_chanmon_cfgs ( 2 ) ;
4589+ let node_cfgs = create_node_cfgs ( 2 , & chanmon_cfgs) ;
4590+ let node_chanmgrs = create_node_chanmgrs ( 2 , & node_cfgs, & [ None , None ] ) ;
4591+ let nodes = create_network ( 2 , & node_cfgs, & node_chanmgrs) ;
4592+
4593+ let node_id_0 = nodes[ 0 ] . node . get_our_node_id ( ) ;
4594+ let node_id_1 = nodes[ 1 ] . node . get_our_node_id ( ) ;
4595+
4596+ let initial_channel_value_sat = 100_000 ;
4597+ let ( _, _, channel_id, _) =
4598+ create_announced_chan_between_nodes_with_value ( & nodes, 0 , 1 , initial_channel_value_sat, 0 ) ;
4599+
4600+ let added_value = Amount :: from_sat ( 50_000 ) ;
4601+ provide_utxo_reserves ( & nodes, 2 , added_value * 2 ) ;
4602+
4603+ // Round 0: Splice-in-and-out from node 0 with a splice-out output.
4604+ let splice_out_output = TxOut {
4605+ value : Amount :: from_sat ( 5_000 ) ,
4606+ script_pubkey : ScriptBuf :: new_p2wpkh ( & WPubkeyHash :: all_zeros ( ) ) ,
4607+ } ;
4608+ let funding_contribution = do_initiate_splice_in_and_out (
4609+ & nodes[ 0 ] ,
4610+ & nodes[ 1 ] ,
4611+ channel_id,
4612+ added_value,
4613+ vec ! [ splice_out_output. clone( ) ] ,
4614+ ) ;
4615+ let round_0_inputs: Vec < _ > = funding_contribution. contributed_inputs ( ) . collect ( ) ;
4616+ assert ! ( !round_0_inputs. is_empty( ) ) ;
4617+
4618+ let ( first_splice_tx, new_funding_script) =
4619+ splice_channel ( & nodes[ 0 ] , & nodes[ 1 ] , channel_id, funding_contribution) ;
4620+
4621+ // Clear node 0's wallet so round 1 must use different UTXOs.
4622+ nodes[ 0 ] . wallet_source . clear_utxos ( ) ;
4623+ provide_utxo_reserves ( & nodes, 2 , added_value * 2 ) ;
4624+
4625+ // Round 1: RBF with fresh UTXOs, splice-in only (no splice-out output).
4626+ let rbf_feerate = FeeRate :: from_sat_per_kwu ( FEERATE_FLOOR_SATS_PER_KW as u64 + 25 ) ;
4627+ let funding_contribution =
4628+ do_initiate_rbf_splice_in ( & nodes[ 0 ] , & nodes[ 1 ] , channel_id, added_value, rbf_feerate) ;
4629+ let round_1_inputs: Vec < _ > = funding_contribution. contributed_inputs ( ) . collect ( ) ;
4630+ assert_ne ! ( round_0_inputs, round_1_inputs, "Rounds must use different UTXOs" ) ;
4631+
4632+ complete_rbf_handshake ( & nodes[ 0 ] , & nodes[ 1 ] ) ;
4633+
4634+ complete_interactive_funding_negotiation (
4635+ & nodes[ 0 ] ,
4636+ & nodes[ 1 ] ,
4637+ channel_id,
4638+ funding_contribution,
4639+ new_funding_script. clone ( ) ,
4640+ ) ;
4641+
4642+ let ( rbf_tx, splice_locked) = sign_interactive_funding_tx ( & nodes[ 0 ] , & nodes[ 1 ] , false ) ;
4643+ assert ! ( splice_locked. is_none( ) ) ;
4644+
4645+ expect_splice_pending_event ( & nodes[ 0 ] , & node_id_1) ;
4646+ expect_splice_pending_event ( & nodes[ 1 ] , & node_id_0) ;
4647+
4648+ let result = lock_rbf_splice_after_blocks (
4649+ & nodes[ 0 ] ,
4650+ & nodes[ 1 ] ,
4651+ & rbf_tx,
4652+ ANTI_REORG_DELAY - 1 ,
4653+ & [ first_splice_tx. compute_txid ( ) ] ,
4654+ ) ;
4655+
4656+ // Node 0's round 0 inputs are NOT in the promoted tx (which uses round 1's fresh UTXOs),
4657+ // so they appear as unique contributions to discard. The splice-out output also survives
4658+ // because its script_pubkey is not in the promoted tx. The change output is filtered
4659+ // because it shares a script_pubkey with the promoted tx's change output.
4660+ assert_eq ! ( result. node_a_discarded. len( ) , 1 ) ;
4661+ let ( ref inputs, ref outputs) = result. node_a_discarded [ 0 ] ;
4662+ assert_eq ! ( * inputs, round_0_inputs) ;
4663+ assert_eq ! ( * outputs, vec![ splice_out_output] ) ;
4664+
4665+ // Node 1 (non-contributing acceptor) has no contributions to discard.
4666+ assert ! ( result. node_b_discarded. is_empty( ) ) ;
45714667}
45724668
45734669#[ test]
@@ -5321,13 +5417,18 @@ pub fn do_test_splice_rbf_tiebreak(
53215417 expect_splice_pending_event ( & nodes[ 1 ] , & node_id_0) ;
53225418
53235419 // Mine, lock, and verify DiscardFunding for the replaced splice candidate.
5324- lock_rbf_splice_after_blocks (
5420+ let result = lock_rbf_splice_after_blocks (
53255421 & nodes[ 0 ] ,
53265422 & nodes[ 1 ] ,
53275423 & rbf_tx,
53285424 ANTI_REORG_DELAY - 1 ,
53295425 & [ first_splice_tx. compute_txid ( ) ] ,
53305426 ) ;
5427+
5428+ // The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
5429+ // are in the promoted tx and nothing is unique to discard.
5430+ assert ! ( result. node_a_discarded. is_empty( ) ) ;
5431+ assert ! ( result. node_b_discarded. is_empty( ) ) ;
53315432 } else {
53325433 // Acceptor does not contribute — complete with only node 0's inputs/outputs.
53335434 complete_interactive_funding_negotiation_for_both (
@@ -5352,14 +5453,14 @@ pub fn do_test_splice_rbf_tiebreak(
53525453 // Mine, lock, and verify DiscardFunding for the replaced splice candidate.
53535454 // Node 1's QuiescentAction was preserved, so after splice_locked it re-initiates
53545455 // quiescence to retry its contribution in a future splice.
5355- let node_b_stfu = lock_rbf_splice_after_blocks (
5456+ let result = lock_rbf_splice_after_blocks (
53565457 & nodes[ 0 ] ,
53575458 & nodes[ 1 ] ,
53585459 & rbf_tx,
53595460 ANTI_REORG_DELAY - 1 ,
53605461 & [ first_splice_tx. compute_txid ( ) ] ,
53615462 ) ;
5362- let stfu_1 = if let Some ( MessageSendEvent :: SendStfu { msg, .. } ) = node_b_stfu {
5463+ let stfu_1 = if let Some ( MessageSendEvent :: SendStfu { msg, .. } ) = result . stfu {
53635464 msg
53645465 } else {
53655466 panic ! ( "Expected SendStfu from node 1" ) ;
@@ -5618,13 +5719,18 @@ fn test_splice_rbf_acceptor_recontributes() {
56185719 expect_splice_pending_event ( & nodes[ 1 ] , & node_id_0) ;
56195720
56205721 // Step 12: Mine, lock, and verify DiscardFunding for the replaced splice candidate.
5621- lock_rbf_splice_after_blocks (
5722+ let result = lock_rbf_splice_after_blocks (
56225723 & nodes[ 0 ] ,
56235724 & nodes[ 1 ] ,
56245725 & rbf_tx,
56255726 ANTI_REORG_DELAY - 1 ,
56265727 & [ first_splice_tx. compute_txid ( ) ] ,
56275728 ) ;
5729+
5730+ // The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
5731+ // are in the promoted tx and nothing is unique to discard.
5732+ assert ! ( result. node_a_discarded. is_empty( ) ) ;
5733+ assert ! ( result. node_b_discarded. is_empty( ) ) ;
56285734}
56295735
56305736#[ test]
@@ -5941,13 +6047,18 @@ fn test_splice_rbf_sequential() {
59416047 // --- Mine and lock the final RBF, verifying DiscardFunding for both replaced candidates. ---
59426048 let splice_tx_0_txid = splice_tx_0. compute_txid ( ) ;
59436049 let splice_tx_1_txid = splice_tx_1. compute_txid ( ) ;
5944- lock_rbf_splice_after_blocks (
6050+ let result = lock_rbf_splice_after_blocks (
59456051 & nodes[ 0 ] ,
59466052 & nodes[ 1 ] ,
59476053 & rbf_tx_final,
59486054 ANTI_REORG_DELAY - 1 ,
59496055 & [ splice_tx_0_txid, splice_tx_1_txid] ,
59506056 ) ;
6057+
6058+ // The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
6059+ // are in the promoted tx and nothing is unique to discard.
6060+ assert ! ( result. node_a_discarded. is_empty( ) ) ;
6061+ assert ! ( result. node_b_discarded. is_empty( ) ) ;
59516062}
59526063
59536064#[ test]
@@ -6309,7 +6420,7 @@ fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() {
63096420 // Mine and lock the pending splice → pending_splice is cleared.
63106421 mine_transaction ( & nodes[ 0 ] , & _splice_tx) ;
63116422 mine_transaction ( & nodes[ 1 ] , & _splice_tx) ;
6312- let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) ;
6423+ let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) . stfu ;
63136424
63146425 // STFU is sent during lock — the splice proceeds as a fresh splice (not RBF).
63156426 let stfu = match stfu {
@@ -6386,7 +6497,7 @@ fn test_funding_contributed_rbf_adjustment_insufficient_budget() {
63866497 // Mine and lock the pending splice → pending_splice is cleared.
63876498 mine_transaction ( & nodes[ 0 ] , & _splice_tx) ;
63886499 mine_transaction ( & nodes[ 1 ] , & _splice_tx) ;
6389- let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) ;
6500+ let stfu = lock_splice_after_blocks ( & nodes[ 0 ] , & nodes[ 1 ] , ANTI_REORG_DELAY - 1 ) . stfu ;
63906501
63916502 // STFU is sent during lock — the splice proceeds as a fresh splice (not RBF).
63926503 let stfu = match stfu {
0 commit comments