@@ -1810,8 +1810,8 @@ impl Wallet {
18101810 let payment_store = Arc :: clone ( & self . payment_store ) ;
18111811 self . pending_payment_store
18121812 . mutate_async ( & id, move |existing| async move {
1813- // The record was written above and payment records are never removed, so absence
1814- // means the write failed out; fall back to the fresh details.
1813+ // The record was written above and removal serializes on the cross-store lock held
1814+ // here, so absence means the write failed out; fall back to the fresh details.
18151815 let recorded = payment_store. get ( & id) . await ?. unwrap_or ( details) ;
18161816 Ok ( match existing {
18171817 // The inserted entry embeds the post-write record rather than the fresh
@@ -1910,6 +1910,20 @@ impl Wallet {
19101910 PendingPaymentDetails :: new ( payment, conflicting_txids, Vec :: new ( ) )
19111911 }
19121912
1913+ /// Removes the payment with the given id from the payment store, along with any pending-store
1914+ /// entry indexing its txids. An orphaned entry would keep resolving those txids to the removed
1915+ /// record — routing later wallet-sync events to a payment that no longer exists — and nothing
1916+ /// would ever clean it up, since graduation only removes entries whose record is still live.
1917+ pub ( crate ) async fn remove_payment ( & self , payment_id : & PaymentId ) -> Result < ( ) , Error > {
1918+ // Hold the cross-store lock so the two-store removal cannot interleave with a sync arm's
1919+ // or classification's resolve-then-write sequence. The pending entry goes first: a failure
1920+ // in between then leaves an unindexed record (benign, and the retry removes it) rather
1921+ // than an entry indexing a removed record.
1922+ let _guard = self . funding_payment_update_lock . lock ( ) . await ;
1923+ self . pending_payment_store . remove ( payment_id) . await ?;
1924+ self . payment_store . remove ( payment_id) . await
1925+ }
1926+
19131927 async fn find_payment_by_txid ( & self , target_txid : Txid ) -> Result < Option < PaymentId > , Error > {
19141928 let direct_payment_id = PaymentId ( target_txid. to_byte_array ( ) ) ;
19151929 if self . pending_payment_store . contains_key ( & direct_payment_id) . await ? {
@@ -3960,6 +3974,82 @@ mod tests {
39603974 assert_eq ! ( wallet. find_payment_by_txid( txid2) . await . unwrap( ) , Some ( payment_id) ) ;
39613975 }
39623976
3977+ /// Removing a payment must also drop its pending-store entry. The entry indexes the
3978+ /// payment's txids (current, conflicting, and candidates), so leaving it behind keeps
3979+ /// resolving those txids to the removed record — routing later wallet events to a payment
3980+ /// that no longer exists — and nothing else ever cleans it up, since graduation only
3981+ /// removes entries whose record is still live.
3982+ #[ tokio:: test]
3983+ async fn remove_payment_drops_pending_entry ( ) {
3984+ let store: Arc < DynStore > = Arc :: new ( DynStoreWrapper ( InMemoryStore :: new ( ) ) ) ;
3985+ let wallet = new_test_wallet ( store, false ) . await ;
3986+
3987+ let txid = Txid :: from_byte_array ( [ 1u8 ; 32 ] ) ;
3988+ let conflicting_txid = Txid :: from_byte_array ( [ 2u8 ; 32 ] ) ;
3989+ let payment_id = PaymentId ( txid. to_byte_array ( ) ) ;
3990+
3991+ // A Pending outbound on-chain payment with a recorded conflict (e.g. an RBF round).
3992+ let details = PaymentDetails :: new (
3993+ payment_id,
3994+ PaymentKind :: Onchain { txid, status : ConfirmationStatus :: Unconfirmed , tx_type : None } ,
3995+ Some ( 1_000 ) ,
3996+ Some ( 100 ) ,
3997+ PaymentDirection :: Outbound ,
3998+ PaymentStatus :: Pending ,
3999+ ) ;
4000+ wallet. payment_store . insert_or_update ( details. clone ( ) ) . await . unwrap ( ) ;
4001+ let entry = PendingPaymentDetails :: new ( details, vec ! [ conflicting_txid] , Vec :: new ( ) ) ;
4002+ wallet. pending_payment_store . insert_or_update ( entry) . await . unwrap ( ) ;
4003+
4004+ wallet. remove_payment ( & payment_id) . await . unwrap ( ) ;
4005+
4006+ assert ! ( wallet. payment_store. get( & payment_id) . await . unwrap( ) . is_none( ) ) ;
4007+ assert ! ( wallet. pending_payment_store. get( & payment_id) . await . unwrap( ) . is_none( ) ) ;
4008+ assert_eq ! ( wallet. find_payment_by_txid( txid) . await . unwrap( ) , None ) ;
4009+ assert_eq ! ( wallet. find_payment_by_txid( conflicting_txid) . await . unwrap( ) , None ) ;
4010+
4011+ // A replacement event for the removed transaction must skip rather than resolve to the
4012+ // removed record: the `TxReplaced` arm asserts the resolved record exists.
4013+ let event = WalletEvent :: TxReplaced {
4014+ txid,
4015+ tx : Arc :: new ( dummy_tx ( ) ) ,
4016+ conflicts : vec ! [ ( 0 , conflicting_txid) ] ,
4017+ } ;
4018+ wallet. update_payment_store ( vec ! [ event] ) . await . unwrap ( ) ;
4019+ assert ! ( wallet. payment_store. get( & payment_id) . await . unwrap( ) . is_none( ) ) ;
4020+ }
4021+
4022+ /// Payments without a pending-store entry — lightning payments, and on-chain payments that
4023+ /// already graduated — must remove cleanly: the unconditional pending-store removal relies
4024+ /// on removing a missing key being a no-op.
4025+ #[ tokio:: test]
4026+ async fn remove_payment_without_pending_entry ( ) {
4027+ let store: Arc < DynStore > = Arc :: new ( DynStoreWrapper ( InMemoryStore :: new ( ) ) ) ;
4028+ let wallet = new_test_wallet ( store, false ) . await ;
4029+
4030+ let payment_id = PaymentId ( [ 9u8 ; 32 ] ) ;
4031+ let details = PaymentDetails :: new (
4032+ payment_id,
4033+ PaymentKind :: Bolt11 {
4034+ hash : lightning_types:: payment:: PaymentHash ( [ 0u8 ; 32 ] ) ,
4035+ preimage : None ,
4036+ secret : None ,
4037+ counterparty_skimmed_fee_msat : None ,
4038+ } ,
4039+ Some ( 1_000 ) ,
4040+ None ,
4041+ PaymentDirection :: Outbound ,
4042+ PaymentStatus :: Succeeded ,
4043+ ) ;
4044+ wallet. payment_store . insert_or_update ( details) . await . unwrap ( ) ;
4045+
4046+ wallet. remove_payment ( & payment_id) . await . unwrap ( ) ;
4047+ assert ! ( wallet. payment_store. get( & payment_id) . await . unwrap( ) . is_none( ) ) ;
4048+
4049+ // Removing an id known to neither store is also a no-op rather than an error.
4050+ wallet. remove_payment ( & PaymentId ( [ 8u8 ; 32 ] ) ) . await . unwrap ( ) ;
4051+ }
4052+
39634053 /// A funding-typed broadcast that doesn't touch the on-chain wallet must not be recorded.
39644054 /// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding
39654055 /// path, so a splice the interactive-funding classification deliberately declined — no local
0 commit comments