@@ -2810,20 +2810,23 @@ impl Wallet {
28102810 return Ok ( Some ( direct_payment_id) ) ;
28112811 }
28122812
2813- if let Some ( replaced_details) = self
2813+ let owns = |p : & PendingPaymentDetails | {
2814+ matches ! ( p. details. kind, PaymentKind :: Onchain { txid, .. } if txid == target_txid)
2815+ // A middle RBF round is not the record's current txid and may never have
2816+ // received a `TxReplaced` event of its own, so map any of its candidate
2817+ // txids (an earlier RBF round may confirm) back to the record.
2818+ || p. candidate ( target_txid) . is_some ( )
2819+ } ;
2820+ let matches = self
28142821 . pending_payment_store
2815- . list_filter ( |p| {
2816- matches ! ( p. details. kind, PaymentKind :: Onchain { txid, .. } if txid == target_txid)
2817- || p. conflicting_txids . contains ( & target_txid)
2818- // A middle RBF round is not the record's current txid and may never have
2819- // received a `TxReplaced` event of its own, so map any of its candidate
2820- // txids (an earlier RBF round may confirm) back to the record.
2821- || p. candidate ( target_txid) . is_some ( )
2822- } )
2823- . await
2824- . first ( )
2825- {
2826- return Ok ( Some ( replaced_details. details . id ) ) ;
2822+ . list_filter ( |p| owns ( p) || p. conflicting_txids . contains ( & target_txid) )
2823+ . await ;
2824+ // An entry lists the transactions that replaced its own, so a transaction another entry
2825+ // records as its own (a splice round that replaced a close, say) matches both. The entry
2826+ // that owns it is its record; the conflict listing is only how a replaced round of a
2827+ // record with no candidates (an ordinary payment's RBF history) maps back to its record.
2828+ if let Some ( entry) = matches. iter ( ) . find ( |p| owns ( p) ) . or ( matches. first ( ) ) {
2829+ return Ok ( Some ( entry. details . id ) ) ;
28272830 }
28282831
28292832 Ok ( None )
@@ -6358,6 +6361,138 @@ mod tests {
63586361 }
63596362 }
63606363
6364+ /// The mirror image of [`funding_record_does_not_adopt_a_conflicting_close`]: a cooperative
6365+ /// close that a splice round replaces lists the round among its conflicting txids, so the
6366+ /// round's confirmation resolves to the close's entry as readily as to the splice's, which
6367+ /// records the round as its own. It must land on the splice's record whichever entry the
6368+ /// pending cache lists first: the close's record is not the round's, and merging the round
6369+ /// into it leaves the splice's payment pending for good. Several closes and several fresh
6370+ /// wallets, each with its own cache order, make the splice's entry unlikely to come first
6371+ /// every time.
6372+ #[ tokio:: test]
6373+ async fn close_record_does_not_adopt_a_conflicting_splice_round ( ) {
6374+ let secp = bitcoin:: secp256k1:: Secp256k1 :: new ( ) ;
6375+ let counterparty_node_id = bitcoin:: secp256k1:: PublicKey :: from_secret_key (
6376+ & secp,
6377+ & bitcoin:: secp256k1:: SecretKey :: from_slice ( & [ 1u8 ; 32 ] ) . unwrap ( ) ,
6378+ ) ;
6379+ let channel_id = lightning:: ln:: types:: ChannelId :: from_bytes ( [ 4u8 ; 32 ] ) ;
6380+
6381+ for _ in 0 ..12 {
6382+ let store: Arc < DynStore > = Arc :: new ( DynStoreWrapper ( InMemoryStore :: new ( ) ) ) ;
6383+ let wallet = new_test_wallet ( store, false ) . await ;
6384+
6385+ // The round pays a wallet address, so the wallet's view of it carries figures of its own.
6386+ let script_pubkey = wallet
6387+ . inner
6388+ . lock ( )
6389+ . unwrap ( )
6390+ . reveal_next_address ( KeychainKind :: External )
6391+ . address
6392+ . script_pubkey ( ) ;
6393+ let splice_tx = Transaction {
6394+ version : bitcoin:: transaction:: Version :: TWO ,
6395+ lock_time : LockTime :: ZERO ,
6396+ input : vec ! [ bitcoin:: TxIn {
6397+ previous_output: bitcoin:: OutPoint {
6398+ txid: Txid :: from_byte_array( [ 3u8 ; 32 ] ) ,
6399+ vout: 0 ,
6400+ } ,
6401+ script_sig: bitcoin:: ScriptBuf :: new( ) ,
6402+ sequence: bitcoin:: Sequence :: MAX ,
6403+ witness: bitcoin:: Witness :: new( ) ,
6404+ } ] ,
6405+ output : vec ! [ TxOut { value: Amount :: from_sat( 90_000 ) , script_pubkey } ] ,
6406+ } ;
6407+ let splice_txid = splice_tx. compute_txid ( ) ;
6408+ // Keyed away from the round's txid, as a later round of a splice is.
6409+ let payment_id = PaymentId ( [ 21u8 ; 32 ] ) ;
6410+ let candidates = vec ! [ FundingTxCandidate {
6411+ txid: splice_txid,
6412+ amount_msat: Some ( 1_000_000 ) ,
6413+ fee_paid_msat: Some ( 500 ) ,
6414+ awaiting_broadcast: false ,
6415+ } ] ;
6416+ let details =
6417+ interactive_funding_details ( payment_id, splice_txid, Some ( 1_000_000 ) , Some ( 500 ) ) ;
6418+ wallet. persist_funding_payment ( details, candidates) . await . unwrap ( ) ;
6419+
6420+ // Each close was recorded at broadcast, seen unconfirmed, then replaced by the round:
6421+ // what the `TxReplaced` arm leaves behind.
6422+ let close_txids: Vec < Txid > =
6423+ ( 7u8 ..11 ) . map ( |byte| Txid :: from_byte_array ( [ byte; 32 ] ) ) . collect ( ) ;
6424+ for close_txid in & close_txids {
6425+ let close_details = PaymentDetails :: new (
6426+ PaymentId ( close_txid. to_byte_array ( ) ) ,
6427+ PaymentKind :: Onchain {
6428+ txid : * close_txid,
6429+ status : ConfirmationStatus :: Unconfirmed ,
6430+ tx_type : Some ( TransactionType :: CooperativeClose {
6431+ counterparty_node_id,
6432+ channel_id,
6433+ } ) ,
6434+ } ,
6435+ Some ( 50_000_000 ) ,
6436+ Some ( 1_000 ) ,
6437+ PaymentDirection :: Inbound ,
6438+ PaymentStatus :: Pending ,
6439+ ) ;
6440+ wallet. payment_store . insert_or_update ( close_details. clone ( ) ) . await . unwrap ( ) ;
6441+ let entry =
6442+ PendingPaymentDetails :: new ( close_details, vec ! [ splice_txid] , Vec :: new ( ) ) ;
6443+ wallet. pending_payment_store . insert_or_update ( entry) . await . unwrap ( ) ;
6444+ }
6445+
6446+ assert_eq ! (
6447+ wallet. find_payment_by_txid( splice_txid) . await . unwrap( ) ,
6448+ Some ( payment_id) ,
6449+ "the round resolved to a record that only lists it as a conflict"
6450+ ) ;
6451+
6452+ let event = WalletEvent :: TxConfirmed {
6453+ txid : splice_txid,
6454+ tx : Arc :: new ( splice_tx) ,
6455+ block_time : confirmed_block_time ( 5 ) ,
6456+ old_block_time : None ,
6457+ } ;
6458+ wallet. update_payment_store ( vec ! [ event] ) . await . unwrap ( ) ;
6459+
6460+ let funding = wallet. payment_store . get ( & payment_id) . await . unwrap ( ) . unwrap ( ) ;
6461+ match & funding. kind {
6462+ PaymentKind :: Onchain { txid, status, tx_type } => {
6463+ assert_eq ! ( * txid, splice_txid) ;
6464+ assert ! ( matches!( status, ConfirmationStatus :: Confirmed { .. } ) ) ;
6465+ assert ! ( matches!( tx_type, Some ( TransactionType :: InteractiveFunding { .. } ) ) ) ;
6466+ } ,
6467+ kind => panic ! ( "unexpected kind {:?}" , kind) ,
6468+ }
6469+ assert_eq ! ( funding. amount_msat, Some ( 1_000_000 ) ) ;
6470+ assert_eq ! ( funding. fee_paid_msat, Some ( 500 ) ) ;
6471+
6472+ for close_txid in & close_txids {
6473+ let close = wallet
6474+ . payment_store
6475+ . get ( & PaymentId ( close_txid. to_byte_array ( ) ) )
6476+ . await
6477+ . unwrap ( )
6478+ . unwrap ( ) ;
6479+ match & close. kind {
6480+ PaymentKind :: Onchain { txid, status, tx_type } => {
6481+ assert_eq ! (
6482+ * txid, * close_txid,
6483+ "the close's record adopted the round's txid"
6484+ ) ;
6485+ assert ! ( matches!( status, ConfirmationStatus :: Unconfirmed ) ) ;
6486+ assert ! ( matches!( tx_type, Some ( TransactionType :: CooperativeClose { .. } ) ) ) ;
6487+ } ,
6488+ kind => panic ! ( "unexpected kind {:?}" , kind) ,
6489+ }
6490+ assert_eq ! ( close. amount_msat, Some ( 50_000_000 ) ) ;
6491+ assert_eq ! ( close. fee_paid_msat, Some ( 1_000 ) ) ;
6492+ }
6493+ }
6494+ }
6495+
63616496 /// Continues the story above: once the conflicting close confirms through the anti-reorg
63626497 /// depth, the splice's funding transaction can never confirm — its shared input is spent for
63636498 /// good. The record must fail rather than stay `Pending` forever, and removing the pending
0 commit comments