@@ -710,6 +710,33 @@ impl PaymentDetailsUpdate {
710710 tx_type : None ,
711711 }
712712 }
713+
714+ /// Builds an update that merges a freshly-classified funding payment's classification
715+ /// (`tx_type`), broadcast txid, and our contribution figures (amount/fee) into an existing
716+ /// record, while leaving the top-level [`PaymentStatus`] and the on-chain
717+ /// [`ConfirmationStatus`] untouched.
718+ ///
719+ /// Funding classification runs off the broadcaster queue and can land *after* wallet sync has
720+ /// already advanced a record's confirmation state (e.g. when LDK re-broadcasts a still-pending
721+ /// funding transaction on restart, or when the counterparty's broadcast is observed first).
722+ /// Merging only the funding-specific fields keeps such a late classification from downgrading a
723+ /// `Confirmed`/`Succeeded` payment back to `Unconfirmed`/`Pending`; the confirmation state is
724+ /// owned by the wallet-sync events instead.
725+ ///
726+ /// The txid and figures are taken from the freshly broadcast (active) candidate. LDK only
727+ /// re-broadcasts the active/confirmed funding candidate, so for an already-confirmed record
728+ /// these equal what graduation stamped and the overwrite is a no-op; we rely on that invariant
729+ /// rather than gating the txid/amount/fee merge on the stored confirmation state.
730+ pub ( crate ) fn funding_reclassification ( details : PaymentDetails ) -> Self {
731+ let mut update = Self :: new ( details. id ) ;
732+ update. amount_msat = Some ( details. amount_msat ) ;
733+ update. fee_paid_msat = Some ( details. fee_paid_msat ) ;
734+ if let PaymentKind :: Onchain { txid, tx_type, .. } = details. kind {
735+ update. txid = Some ( txid) ;
736+ update. tx_type = Some ( tx_type) ;
737+ }
738+ update
739+ }
713740}
714741
715742impl From < & PaymentDetails > for PaymentDetailsUpdate {
@@ -921,6 +948,94 @@ mod tests {
921948 assert_eq ! ( kind, PaymentKind :: read( & mut & * kind. encode( ) ) . unwrap( ) ) ;
922949 }
923950
951+ #[ test]
952+ fn funding_reclassification_does_not_downgrade_an_advanced_record ( ) {
953+ use bitcoin:: hashes:: Hash ;
954+ use std:: str:: FromStr ;
955+
956+ // A splice funding payment wallet sync has already advanced to Succeeded/Confirmed.
957+ let txid = Txid :: from_byte_array ( [ 7u8 ; 32 ] ) ;
958+ let id = PaymentId ( txid. to_byte_array ( ) ) ;
959+ let tx_type = Some ( TransactionType :: InteractiveFunding {
960+ channels : vec ! [ Channel {
961+ counterparty_node_id: PublicKey :: from_str(
962+ "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" ,
963+ )
964+ . unwrap( ) ,
965+ channel_id: ChannelId ( [ 3u8 ; 32 ] ) ,
966+ } ] ,
967+ } ) ;
968+ let advanced = PaymentDetails :: new (
969+ id,
970+ PaymentKind :: Onchain {
971+ txid,
972+ status : ConfirmationStatus :: Confirmed {
973+ block_hash : BlockHash :: from_byte_array ( [ 8u8 ; 32 ] ) ,
974+ height : 100 ,
975+ timestamp : 1 ,
976+ } ,
977+ tx_type : tx_type. clone ( ) ,
978+ } ,
979+ Some ( 2_000_000 ) ,
980+ Some ( 999 ) ,
981+ PaymentDirection :: Outbound ,
982+ PaymentStatus :: Succeeded ,
983+ ) ;
984+
985+ // A fresh funding classification for the same payment is always Pending/Unconfirmed.
986+ let fresh = PaymentDetails :: new (
987+ id,
988+ PaymentKind :: Onchain { txid, status : ConfirmationStatus :: Unconfirmed , tx_type } ,
989+ Some ( 1_000_000 ) ,
990+ Some ( 500 ) ,
991+ PaymentDirection :: Outbound ,
992+ PaymentStatus :: Pending ,
993+ ) ;
994+
995+ // The naive full update `insert_or_update` applied before the fix downgrades both the
996+ // top-level status and the on-chain confirmation status — the bug Codex flagged.
997+ let mut downgraded = advanced. clone ( ) ;
998+ downgraded. update ( ( & fresh) . into ( ) ) ;
999+ assert_eq ! (
1000+ downgraded. status,
1001+ PaymentStatus :: Pending ,
1002+ "a full update from a fresh classification downgrades the top-level status" ,
1003+ ) ;
1004+ assert ! (
1005+ matches!(
1006+ downgraded. kind,
1007+ PaymentKind :: Onchain { status: ConfirmationStatus :: Unconfirmed , .. }
1008+ ) ,
1009+ "a full update from a fresh classification downgrades the confirmation status" ,
1010+ ) ;
1011+
1012+ // The narrowed reclassification update merges only the funding fields and preserves the
1013+ // advanced confirmation state that wallet sync owns.
1014+ let mut merged = advanced. clone ( ) ;
1015+ merged. update ( PaymentDetailsUpdate :: funding_reclassification ( fresh) ) ;
1016+ assert_eq ! (
1017+ merged. status,
1018+ PaymentStatus :: Succeeded ,
1019+ "reclassification must not downgrade the top-level status" ,
1020+ ) ;
1021+ assert ! (
1022+ matches!(
1023+ merged. kind,
1024+ PaymentKind :: Onchain {
1025+ status: ConfirmationStatus :: Confirmed { .. } ,
1026+ tx_type: Some ( TransactionType :: InteractiveFunding { .. } ) ,
1027+ ..
1028+ }
1029+ ) ,
1030+ "reclassification must preserve the confirmation status and keep the funding tx_type" ,
1031+ ) ;
1032+ // The contribution-derived figures from the fresh classification ARE merged in, replacing
1033+ // the existing record's: they are authoritative (the wallet can't recompute our share of a
1034+ // shared funding output), so the merge must carry them.
1035+ assert_eq ! ( merged. amount_msat, Some ( 1_000_000 ) ) ;
1036+ assert_eq ! ( merged. fee_paid_msat, Some ( 500 ) ) ;
1037+ }
1038+
9241039 #[ derive( Clone , Debug , PartialEq , Eq ) ]
9251040 struct LegacyBolt11JitKind {
9261041 hash : PaymentHash ,
0 commit comments