@@ -1827,6 +1827,7 @@ impl Wallet {
18271827 & self , _guard : & tokio:: sync:: MutexGuard < ' _ , ( ) > , details : PaymentDetails ,
18281828 candidates : Vec < FundingTxCandidate > ,
18291829 ) -> Result < ( ) , Error > {
1830+ let absorb_candidates = candidates. clone ( ) ;
18301831 // Everything this write does depends on the record's current state, so all of it must be
18311832 // decided inside the store's critical section. When a record exists — no matter when it
18321833 // appeared — only the classification (`tx_type`) and the figures of whichever candidate
@@ -1923,6 +1924,70 @@ impl Wallet {
19231924 }
19241925 } )
19251926 . await ?;
1927+
1928+ // With the candidate history recorded, duplicates wallet sync minted for rounds that were
1929+ // not yet candidates can be folded back into this record. Runs after both writes so the
1930+ // funding-status gate accepts the candidates it adopts, and under the same lock
1931+ // acquisition, so sync cannot interleave; a failure surfaces to the broadcast queue's
1932+ // classification retry, which re-runs this idempotently.
1933+ self . absorb_stray_candidate_records ( _guard, id, & absorb_candidates) . await ?;
1934+ Ok ( ( ) )
1935+ }
1936+
1937+ /// Absorbs stray records wallet sync minted for this funding payment's candidates before
1938+ /// they were classified. Sync re-keys an event for a round it cannot attribute to the
1939+ /// funding record — not yet a candidate, so the funding-status gate reports it foreign — to
1940+ /// the round's txid-derived id, minting an untyped duplicate whose pending entry then
1941+ /// shadows the funding record in [`Self::find_payment_by_txid`]'s direct probe. Once the
1942+ /// round is a recorded candidate, the duplicate's confirmation (if any) belongs on the
1943+ /// funding record: adopt it, then remove the stray and its pending entry.
1944+ ///
1945+ /// The caller must hold [`Self::funding_payment_update_lock`], per
1946+ /// [`Self::apply_funding_status_update_locked`]'s contract.
1947+ async fn absorb_stray_candidate_records (
1948+ & self , guard : & tokio:: sync:: MutexGuard < ' _ , ( ) > , id : PaymentId ,
1949+ candidates : & [ FundingTxCandidate ] ,
1950+ ) -> Result < ( ) , Error > {
1951+ for candidate in candidates {
1952+ let stray_id = PaymentId ( candidate. txid . to_byte_array ( ) ) ;
1953+ if stray_id == id {
1954+ continue ;
1955+ }
1956+ let stray = match self . payment_store . get ( & stray_id) {
1957+ Some ( stray) => stray,
1958+ None => continue ,
1959+ } ;
1960+ // Only a duplicate view of this candidate's transaction qualifies: an untyped record
1961+ // wallet sync minted, or one a funding-typed rebroadcast classified onto it. Anything
1962+ // else keyed by the txid-derived id is left alone.
1963+ let status = match & stray. kind {
1964+ PaymentKind :: Onchain {
1965+ txid,
1966+ status,
1967+ tx_type : None | Some ( TransactionType :: Funding { .. } ) ,
1968+ } if * txid == candidate. txid => status. clone ( ) ,
1969+ _ => continue ,
1970+ } ;
1971+ // Only a confirmation is worth adopting; an unconfirmed stray carries nothing the
1972+ // record needs — the actively-broadcast candidate stays the record's current txid.
1973+ if matches ! ( status, ConfirmationStatus :: Confirmed { .. } ) {
1974+ let outcome = self
1975+ . apply_funding_status_update_locked ( guard, id, candidate. txid , status)
1976+ . await ?;
1977+ debug_assert ! ( matches!( outcome, FundingStatusUpdate :: Applied ) ) ;
1978+ if !matches ! ( outcome, FundingStatusUpdate :: Applied ) {
1979+ // Adoption declined; keep the stray rather than discard its confirmation.
1980+ continue ;
1981+ }
1982+ }
1983+ log_debug ! (
1984+ self . logger,
1985+ "Absorbing stray payment record for funding candidate {}" ,
1986+ candidate. txid,
1987+ ) ;
1988+ self . payment_store . remove ( & stray_id) . await ?;
1989+ self . pending_payment_store . remove ( & stray_id) . await ?;
1990+ }
19261991 Ok ( ( ) )
19271992 }
19281993
@@ -4605,6 +4670,152 @@ mod tests {
46054670 loop_task. await . unwrap ( ) ;
46064671 }
46074672
4673+ /// Wallet sync can record a genuine replacement round before classification records it as a
4674+ /// candidate — e.g. the counterparty broadcast a round whose classification failed here and
4675+ /// is still being retried. The funding-status gate then routes the round's confirmation to a
4676+ /// stray record keyed by the round's txid, whose pending entry shadows the funding record in
4677+ /// `find_payment_by_txid`'s direct probe. Once the round's classification lands, it must
4678+ /// absorb the stray — adopt its confirmation and drop the duplicate — so a single record
4679+ /// tracks the splice.
4680+ #[ tokio:: test]
4681+ async fn classification_absorbs_stray_records_for_its_candidates ( ) {
4682+ let store: Arc < DynStore > = Arc :: new ( DynStoreWrapper ( InMemoryStore :: new ( ) ) ) ;
4683+ let wallet = new_test_wallet ( store, false ) . await ;
4684+
4685+ let funding_id = PaymentId ( [ 21u8 ; 32 ] ) ;
4686+ let txid1 = Txid :: from_byte_array ( [ 1u8 ; 32 ] ) ;
4687+ let txid2 = Txid :: from_byte_array ( [ 2u8 ; 32 ] ) ;
4688+
4689+ // Round 1 classified normally.
4690+ let round1 = vec ! [ FundingTxCandidate {
4691+ txid: txid1,
4692+ amount_msat: Some ( 1_000_000 ) ,
4693+ fee_paid_msat: Some ( 500 ) ,
4694+ } ] ;
4695+ let details = interactive_funding_details ( funding_id, txid1, Some ( 1_000_000 ) , Some ( 500 ) ) ;
4696+ wallet. persist_funding_payment ( details, round1) . await . unwrap ( ) ;
4697+
4698+ // Wallet sync recorded round 2's confirmation while the round was not yet a candidate: a
4699+ // stray untyped record under the txid-derived id, plus its pending entry.
4700+ let stray_id = PaymentId ( txid2. to_byte_array ( ) ) ;
4701+ let stray = PaymentDetails :: new (
4702+ stray_id,
4703+ PaymentKind :: Onchain { txid : txid2, status : confirmed_status ( ) , tx_type : None } ,
4704+ Some ( 999_000 ) ,
4705+ Some ( 999 ) ,
4706+ PaymentDirection :: Outbound ,
4707+ PaymentStatus :: Pending ,
4708+ ) ;
4709+ wallet. payment_store . insert_or_update ( stray. clone ( ) ) . await . unwrap ( ) ;
4710+ wallet
4711+ . pending_payment_store
4712+ . insert_or_update ( PendingPaymentDetails :: new ( stray, Vec :: new ( ) , Vec :: new ( ) ) )
4713+ . await
4714+ . unwrap ( ) ;
4715+ assert_eq ! ( wallet. find_payment_by_txid( txid2) , Some ( stray_id) ) ;
4716+
4717+ // Round 2's classification lands (e.g. retried after a persistence failure).
4718+ let rounds = vec ! [
4719+ FundingTxCandidate {
4720+ txid: txid1,
4721+ amount_msat: Some ( 1_000_000 ) ,
4722+ fee_paid_msat: Some ( 500 ) ,
4723+ } ,
4724+ FundingTxCandidate {
4725+ txid: txid2,
4726+ amount_msat: Some ( 1_000_000 ) ,
4727+ fee_paid_msat: Some ( 400 ) ,
4728+ } ,
4729+ ] ;
4730+ let details = interactive_funding_details ( funding_id, txid2, Some ( 1_000_000 ) , Some ( 400 ) ) ;
4731+ wallet. persist_funding_payment ( details, rounds) . await . unwrap ( ) ;
4732+
4733+ // One record: the funding record carries the stray's confirmation and the confirmed
4734+ // candidate's figures; the stray and its pending entry are gone, so the round's txid
4735+ // resolves to the funding record again.
4736+ let payments = wallet. payment_store . list_filter ( |_| true ) ;
4737+ assert_eq ! ( payments. len( ) , 1 , "the stray duplicate must be absorbed" ) ;
4738+ let payment = & payments[ 0 ] ;
4739+ assert_eq ! ( payment. id, funding_id) ;
4740+ assert_eq ! ( payment. amount_msat, Some ( 1_000_000 ) ) ;
4741+ assert_eq ! ( payment. fee_paid_msat, Some ( 400 ) ) ;
4742+ match & payment. kind {
4743+ PaymentKind :: Onchain {
4744+ txid,
4745+ status : ConfirmationStatus :: Confirmed { .. } ,
4746+ tx_type : Some ( TransactionType :: InteractiveFunding { .. } ) ,
4747+ } => assert_eq ! ( * txid, txid2) ,
4748+ kind => panic ! ( "unexpected kind {:?}" , kind) ,
4749+ }
4750+ assert ! ( wallet. pending_payment_store. get( & stray_id) . is_none( ) ) ;
4751+ assert_eq ! ( wallet. find_payment_by_txid( txid2) , Some ( funding_id) ) ;
4752+ }
4753+
4754+ /// A stray for an *unconfirmed* round carries no state the funding record needs: absorbing
4755+ /// it removes the duplicate without touching the record's active txid or figures, and the
4756+ /// round's txid maps back to the funding record through its candidate history.
4757+ #[ tokio:: test]
4758+ async fn classification_drops_unconfirmed_strays_without_adopting_their_txid ( ) {
4759+ let store: Arc < DynStore > = Arc :: new ( DynStoreWrapper ( InMemoryStore :: new ( ) ) ) ;
4760+ let wallet = new_test_wallet ( store, false ) . await ;
4761+
4762+ let funding_id = PaymentId ( [ 21u8 ; 32 ] ) ;
4763+ let txid1 = Txid :: from_byte_array ( [ 1u8 ; 32 ] ) ;
4764+ let txid2 = Txid :: from_byte_array ( [ 2u8 ; 32 ] ) ;
4765+
4766+ // Wallet sync saw round 1 — still unconfirmed — before any classification ran.
4767+ let stray_id = PaymentId ( txid1. to_byte_array ( ) ) ;
4768+ let stray = PaymentDetails :: new (
4769+ stray_id,
4770+ PaymentKind :: Onchain {
4771+ txid : txid1,
4772+ status : ConfirmationStatus :: Unconfirmed ,
4773+ tx_type : None ,
4774+ } ,
4775+ Some ( 999_000 ) ,
4776+ Some ( 999 ) ,
4777+ PaymentDirection :: Outbound ,
4778+ PaymentStatus :: Pending ,
4779+ ) ;
4780+ wallet. payment_store . insert_or_update ( stray. clone ( ) ) . await . unwrap ( ) ;
4781+ wallet
4782+ . pending_payment_store
4783+ . insert_or_update ( PendingPaymentDetails :: new ( stray, Vec :: new ( ) , Vec :: new ( ) ) )
4784+ . await
4785+ . unwrap ( ) ;
4786+
4787+ // Round 2 is the active broadcast; its classification lists both rounds.
4788+ let rounds = vec ! [
4789+ FundingTxCandidate {
4790+ txid: txid1,
4791+ amount_msat: Some ( 1_000_000 ) ,
4792+ fee_paid_msat: Some ( 500 ) ,
4793+ } ,
4794+ FundingTxCandidate {
4795+ txid: txid2,
4796+ amount_msat: Some ( 1_000_000 ) ,
4797+ fee_paid_msat: Some ( 400 ) ,
4798+ } ,
4799+ ] ;
4800+ let details = interactive_funding_details ( funding_id, txid2, Some ( 1_000_000 ) , Some ( 400 ) ) ;
4801+ wallet. persist_funding_payment ( details, rounds) . await . unwrap ( ) ;
4802+
4803+ let payments = wallet. payment_store . list_filter ( |_| true ) ;
4804+ assert_eq ! ( payments. len( ) , 1 , "the stray duplicate must be absorbed" ) ;
4805+ let payment = & payments[ 0 ] ;
4806+ assert_eq ! ( payment. id, funding_id) ;
4807+ // The record keeps tracking the actively-broadcast round; a stray that never confirmed
4808+ // has nothing to adopt.
4809+ match & payment. kind {
4810+ PaymentKind :: Onchain { txid, status : ConfirmationStatus :: Unconfirmed , .. } => {
4811+ assert_eq ! ( * txid, txid2)
4812+ } ,
4813+ kind => panic ! ( "unexpected kind {:?}" , kind) ,
4814+ }
4815+ assert_eq ! ( payment. fee_paid_msat, Some ( 400 ) ) ;
4816+ assert_eq ! ( wallet. find_payment_by_txid( txid1) , Some ( funding_id) ) ;
4817+ }
4818+
46084819 /// Barrier test, classification-first ordering: wallet sync's confirmation handling must
46094820 /// wait for classification's two-store write pair. Classification is parked between its
46104821 /// payment-store and pending-store writes (the torn window) and only then is the
0 commit comments