@@ -243,12 +243,24 @@ impl StorableObject for PaymentDetails {
243243 }
244244 }
245245
246- if let Some ( amount_opt) = update. amount_msat {
247- update_if_necessary ! ( self . amount_msat, amount_opt) ;
248- }
246+ // Once an on-chain record is confirmed, its txid and figures describe the candidate that
247+ // confirmed, which need not be the last one broadcast. An update that doesn't assert the
248+ // confirmation state was built without knowing it — e.g. a late funding classification
249+ // whose candidate lost to the counterparty's broadcast — so it must not move them.
250+ let keep_confirmed_figures = update. confirmation_status . is_none ( )
251+ && matches ! (
252+ self . kind,
253+ PaymentKind :: Onchain { status: ConfirmationStatus :: Confirmed { .. } , .. }
254+ ) ;
255+
256+ if !keep_confirmed_figures {
257+ if let Some ( amount_opt) = update. amount_msat {
258+ update_if_necessary ! ( self . amount_msat, amount_opt) ;
259+ }
249260
250- if let Some ( fee_paid_msat_opt) = update. fee_paid_msat {
251- update_if_necessary ! ( self . fee_paid_msat, fee_paid_msat_opt) ;
261+ if let Some ( fee_paid_msat_opt) = update. fee_paid_msat {
262+ update_if_necessary ! ( self . fee_paid_msat, fee_paid_msat_opt) ;
263+ }
252264 }
253265
254266 if let Some ( skimmed_fee_msat) = update. counterparty_skimmed_fee_msat {
@@ -278,7 +290,7 @@ impl StorableObject for PaymentDetails {
278290
279291 if let Some ( tx_id) = update. txid {
280292 match self . kind {
281- PaymentKind :: Onchain { ref mut txid, .. } => {
293+ PaymentKind :: Onchain { ref mut txid, .. } if !keep_confirmed_figures => {
282294 update_if_necessary ! ( * txid, tx_id) ;
283295 } ,
284296 _ => { } ,
@@ -719,16 +731,16 @@ impl PaymentDetailsUpdate {
719731 /// [`ConfirmationStatus`] untouched.
720732 ///
721733 /// 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.
734+ /// already advanced a record's confirmation state (e.g. when the counterparty's broadcast of
735+ /// the funding transaction is observed first). Merging only the funding-specific fields keeps
736+ /// such a late classification from downgrading a `Confirmed`/`Succeeded` payment back to
737+ /// `Unconfirmed`/`Pending`; the confirmation state is owned by the wallet-sync events instead.
727738 ///
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.
739+ /// The txid and figures are taken from the freshly broadcast (active) candidate, so they only
740+ /// apply while the record is unconfirmed. Once a candidate confirms, the record's txid and
741+ /// figures describe that candidate — which need not be the one being classified (e.g. the
742+ /// counterparty broadcast an earlier candidate and it won) — and [`PaymentDetails::update`]
743+ /// leaves them in place for updates like this one that don't carry a confirmation state.
732744 pub ( crate ) fn funding_reclassification ( details : PaymentDetails ) -> Self {
733745 let mut update = Self :: new ( details. id ) ;
734746 update. amount_msat = Some ( details. amount_msat ) ;
@@ -1130,11 +1142,95 @@ mod tests {
11301142 ) ,
11311143 "reclassification must preserve the confirmation status and keep the funding tx_type" ,
11321144 ) ;
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 ) ) ;
1145+ // The confirmed record's figures describe the candidate that confirmed, so the late
1146+ // classification must not replace them either.
1147+ assert_eq ! ( merged. amount_msat, Some ( 2_000_000 ) ) ;
1148+ assert_eq ! ( merged. fee_paid_msat, Some ( 999 ) ) ;
1149+ }
1150+
1151+ #[ test]
1152+ fn funding_reclassification_keeps_confirmed_candidate_figures ( ) {
1153+ use bitcoin:: hashes:: Hash ;
1154+ use std:: str:: FromStr ;
1155+
1156+ // A funding payment whose first candidate wallet sync has already seen confirm — e.g. the
1157+ // counterparty's broadcast of it was picked up before our own later candidate was
1158+ // classified. The record is unclassified (created by the sync fallthrough).
1159+ let confirmed_txid = Txid :: from_byte_array ( [ 7u8 ; 32 ] ) ;
1160+ let id = PaymentId ( confirmed_txid. to_byte_array ( ) ) ;
1161+ let confirmed = PaymentDetails :: new (
1162+ id,
1163+ PaymentKind :: Onchain {
1164+ txid : confirmed_txid,
1165+ status : ConfirmationStatus :: Confirmed {
1166+ block_hash : BlockHash :: from_byte_array ( [ 8u8 ; 32 ] ) ,
1167+ height : 100 ,
1168+ timestamp : 1 ,
1169+ } ,
1170+ tx_type : None ,
1171+ } ,
1172+ Some ( 2_000_000 ) ,
1173+ Some ( 999 ) ,
1174+ PaymentDirection :: Outbound ,
1175+ PaymentStatus :: Pending ,
1176+ ) ;
1177+
1178+ // Our own, different (e.g. fee-bumped) candidate is classified late.
1179+ let late_txid = Txid :: from_byte_array ( [ 9u8 ; 32 ] ) ;
1180+ let tx_type = Some ( TransactionType :: InteractiveFunding {
1181+ channels : vec ! [ Channel {
1182+ counterparty_node_id: PublicKey :: from_str(
1183+ "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" ,
1184+ )
1185+ . unwrap( ) ,
1186+ channel_id: ChannelId ( [ 3u8 ; 32 ] ) ,
1187+ } ] ,
1188+ } ) ;
1189+ let late = PaymentDetails :: new (
1190+ id,
1191+ PaymentKind :: Onchain {
1192+ txid : late_txid,
1193+ status : ConfirmationStatus :: Unconfirmed ,
1194+ tx_type,
1195+ } ,
1196+ Some ( 1_000_000 ) ,
1197+ Some ( 500 ) ,
1198+ PaymentDirection :: Outbound ,
1199+ PaymentStatus :: Pending ,
1200+ ) ;
1201+
1202+ // The confirmed record's txid and figures describe the candidate that confirmed; the late
1203+ // classification must not replace them with an unconfirmed candidate's. The
1204+ // classification itself (`tx_type`) still lands.
1205+ let mut classified = confirmed. clone ( ) ;
1206+ classified. update ( PaymentDetailsUpdate :: funding_reclassification ( late. clone ( ) ) ) ;
1207+ assert ! (
1208+ matches!(
1209+ classified. kind,
1210+ PaymentKind :: Onchain {
1211+ txid,
1212+ tx_type: Some ( TransactionType :: InteractiveFunding { .. } ) ,
1213+ ..
1214+ } if txid == confirmed_txid
1215+ ) ,
1216+ "a late classification must set the tx_type but not replace a confirmed record's txid" ,
1217+ ) ;
1218+ assert_eq ! ( classified. amount_msat, Some ( 2_000_000 ) ) ;
1219+ assert_eq ! ( classified. fee_paid_msat, Some ( 999 ) ) ;
1220+
1221+ // While the record is still unconfirmed, the freshly broadcast candidate is the active
1222+ // one, so its txid and figures do replace the stored ones (RBF rotation).
1223+ let mut unconfirmed = confirmed. clone ( ) ;
1224+ if let PaymentKind :: Onchain { ref mut status, .. } = unconfirmed. kind {
1225+ * status = ConfirmationStatus :: Unconfirmed ;
1226+ }
1227+ unconfirmed. update ( PaymentDetailsUpdate :: funding_reclassification ( late) ) ;
1228+ assert ! (
1229+ matches!( unconfirmed. kind, PaymentKind :: Onchain { txid, .. } if txid == late_txid) ,
1230+ "classifying a new candidate of an unconfirmed record rotates the txid" ,
1231+ ) ;
1232+ assert_eq ! ( unconfirmed. amount_msat, Some ( 1_000_000 ) ) ;
1233+ assert_eq ! ( unconfirmed. fee_paid_msat, Some ( 500 ) ) ;
11381234 }
11391235
11401236 #[ tokio:: test]
@@ -1159,7 +1255,8 @@ mod tests {
11591255 channel_id: ChannelId ( [ 3u8 ; 32 ] ) ,
11601256 } ] ,
11611257 } ) ;
1162- // A funding payment wallet sync has already advanced to Succeeded/Confirmed.
1258+ // A funding payment wallet sync has already recorded (unclassified, via the default
1259+ // on-chain path) and advanced to Succeeded/Confirmed.
11631260 let advanced = PaymentDetails :: new (
11641261 id,
11651262 PaymentKind :: Onchain {
@@ -1169,7 +1266,7 @@ mod tests {
11691266 height : 100 ,
11701267 timestamp : 1 ,
11711268 } ,
1172- tx_type : tx_type . clone ( ) ,
1269+ tx_type : None ,
11731270 } ,
11741271 Some ( 2_000_000 ) ,
11751272 Some ( 999 ) ,
@@ -1210,8 +1307,8 @@ mod tests {
12101307 ) ;
12111308
12121309 // `update_or_insert` applies only the narrow reclassification when a record exists — no
1213- // matter when it appeared — preserving the confirmation state wallet sync owns while
1214- // still merging the contribution-derived figures.
1310+ // matter when it appeared — setting the `tx_type` while preserving the confirmation
1311+ // state wallet sync owns and the confirmed candidate's figures.
12151312 let store = new_store ( vec ! [ advanced. clone( ) ] ) ;
12161313 let update = PaymentDetailsUpdate :: funding_reclassification ( fresh. clone ( ) ) ;
12171314 assert_eq ! (
@@ -1222,10 +1319,14 @@ mod tests {
12221319 assert_eq ! ( merged. status, PaymentStatus :: Succeeded ) ;
12231320 assert ! ( matches!(
12241321 merged. kind,
1225- PaymentKind :: Onchain { status: ConfirmationStatus :: Confirmed { .. } , .. }
1322+ PaymentKind :: Onchain {
1323+ status: ConfirmationStatus :: Confirmed { .. } ,
1324+ tx_type: Some ( TransactionType :: InteractiveFunding { .. } ) ,
1325+ ..
1326+ }
12261327 ) ) ;
1227- assert_eq ! ( merged. amount_msat, Some ( 1_000_000 ) ) ;
1228- assert_eq ! ( merged. fee_paid_msat, Some ( 500 ) ) ;
1328+ assert_eq ! ( merged. amount_msat, Some ( 2_000_000 ) ) ;
1329+ assert_eq ! ( merged. fee_paid_msat, Some ( 999 ) ) ;
12291330
12301331 // And it inserts the fresh details when no record exists yet.
12311332 let store = new_store ( Vec :: new ( ) ) ;
0 commit comments