@@ -712,6 +712,33 @@ impl PaymentDetailsUpdate {
712712 tx_type : None ,
713713 }
714714 }
715+
716+ /// Builds an update that merges a freshly-classified funding payment's classification
717+ /// (`tx_type`), broadcast txid, and our contribution figures (amount/fee) into an existing
718+ /// record, while leaving the top-level [`PaymentStatus`] and the on-chain
719+ /// [`ConfirmationStatus`] untouched.
720+ ///
721+ /// Funding classification runs off the broadcaster queue and can land *after* wallet sync has
722+ /// already advanced a record's confirmation state (e.g. when LDK re-broadcasts a still-pending
723+ /// funding transaction on restart, or when the counterparty's broadcast is observed first).
724+ /// Merging only the funding-specific fields keeps such a late classification from downgrading a
725+ /// `Confirmed`/`Succeeded` payment back to `Unconfirmed`/`Pending`; the confirmation state is
726+ /// owned by the wallet-sync events instead.
727+ ///
728+ /// The txid and figures are taken from the freshly broadcast (active) candidate. LDK only
729+ /// re-broadcasts the active/confirmed funding candidate, so for an already-confirmed record
730+ /// these equal what graduation stamped and the overwrite is a no-op; we rely on that invariant
731+ /// rather than gating the txid/amount/fee merge on the stored confirmation state.
732+ pub ( crate ) fn funding_reclassification ( details : PaymentDetails ) -> Self {
733+ let mut update = Self :: new ( details. id ) ;
734+ update. amount_msat = Some ( details. amount_msat ) ;
735+ update. fee_paid_msat = Some ( details. fee_paid_msat ) ;
736+ if let PaymentKind :: Onchain { txid, tx_type, .. } = details. kind {
737+ update. txid = Some ( txid) ;
738+ update. tx_type = Some ( tx_type) ;
739+ }
740+ update
741+ }
715742}
716743
717744impl From < & PaymentDetails > for PaymentDetailsUpdate {
@@ -1022,6 +1049,94 @@ mod tests {
10221049 }
10231050 }
10241051
1052+ #[ test]
1053+ fn funding_reclassification_does_not_downgrade_an_advanced_record ( ) {
1054+ use bitcoin:: hashes:: Hash ;
1055+ use std:: str:: FromStr ;
1056+
1057+ // A splice funding payment wallet sync has already advanced to Succeeded/Confirmed.
1058+ let txid = Txid :: from_byte_array ( [ 7u8 ; 32 ] ) ;
1059+ let id = PaymentId ( txid. to_byte_array ( ) ) ;
1060+ let tx_type = Some ( TransactionType :: InteractiveFunding {
1061+ channels : vec ! [ Channel {
1062+ counterparty_node_id: PublicKey :: from_str(
1063+ "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" ,
1064+ )
1065+ . unwrap( ) ,
1066+ channel_id: ChannelId ( [ 3u8 ; 32 ] ) ,
1067+ } ] ,
1068+ } ) ;
1069+ let advanced = PaymentDetails :: new (
1070+ id,
1071+ PaymentKind :: Onchain {
1072+ txid,
1073+ status : ConfirmationStatus :: Confirmed {
1074+ block_hash : BlockHash :: from_byte_array ( [ 8u8 ; 32 ] ) ,
1075+ height : 100 ,
1076+ timestamp : 1 ,
1077+ } ,
1078+ tx_type : tx_type. clone ( ) ,
1079+ } ,
1080+ Some ( 2_000_000 ) ,
1081+ Some ( 999 ) ,
1082+ PaymentDirection :: Outbound ,
1083+ PaymentStatus :: Succeeded ,
1084+ ) ;
1085+
1086+ // A fresh funding classification for the same payment is always Pending/Unconfirmed.
1087+ let fresh = PaymentDetails :: new (
1088+ id,
1089+ PaymentKind :: Onchain { txid, status : ConfirmationStatus :: Unconfirmed , tx_type } ,
1090+ Some ( 1_000_000 ) ,
1091+ Some ( 500 ) ,
1092+ PaymentDirection :: Outbound ,
1093+ PaymentStatus :: Pending ,
1094+ ) ;
1095+
1096+ // The naive full update `insert_or_update` applied before the fix downgrades both the
1097+ // top-level status and the on-chain confirmation status — the bug Codex flagged.
1098+ let mut downgraded = advanced. clone ( ) ;
1099+ downgraded. update ( ( & fresh) . into ( ) ) ;
1100+ assert_eq ! (
1101+ downgraded. status,
1102+ PaymentStatus :: Pending ,
1103+ "a full update from a fresh classification downgrades the top-level status" ,
1104+ ) ;
1105+ assert ! (
1106+ matches!(
1107+ downgraded. kind,
1108+ PaymentKind :: Onchain { status: ConfirmationStatus :: Unconfirmed , .. }
1109+ ) ,
1110+ "a full update from a fresh classification downgrades the confirmation status" ,
1111+ ) ;
1112+
1113+ // The narrowed reclassification update merges only the funding fields and preserves the
1114+ // advanced confirmation state that wallet sync owns.
1115+ let mut merged = advanced. clone ( ) ;
1116+ merged. update ( PaymentDetailsUpdate :: funding_reclassification ( fresh) ) ;
1117+ assert_eq ! (
1118+ merged. status,
1119+ PaymentStatus :: Succeeded ,
1120+ "reclassification must not downgrade the top-level status" ,
1121+ ) ;
1122+ assert ! (
1123+ matches!(
1124+ merged. kind,
1125+ PaymentKind :: Onchain {
1126+ status: ConfirmationStatus :: Confirmed { .. } ,
1127+ tx_type: Some ( TransactionType :: InteractiveFunding { .. } ) ,
1128+ ..
1129+ }
1130+ ) ,
1131+ "reclassification must preserve the confirmation status and keep the funding tx_type" ,
1132+ ) ;
1133+ // The contribution-derived figures from the fresh classification ARE merged in, replacing
1134+ // the existing record's: they are authoritative (the wallet can't recompute our share of a
1135+ // shared funding output), so the merge must carry them.
1136+ assert_eq ! ( merged. amount_msat, Some ( 1_000_000 ) ) ;
1137+ assert_eq ! ( merged. fee_paid_msat, Some ( 500 ) ) ;
1138+ }
1139+
10251140 #[ derive( Clone , Debug , PartialEq , Eq ) ]
10261141 struct LegacyBolt11JitKind {
10271142 hash : PaymentHash ,
0 commit comments