Skip to content

Commit 0e44736

Browse files
jkczyzclaude
andcommitted
f - Keep a confirmed funding record's txid and figures on late classification
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent cb99564 commit 0e44736

3 files changed

Lines changed: 135 additions & 34 deletions

File tree

src/payment/pending_payment_store.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -295,8 +295,8 @@ mod tests {
295295
"a full merge of a fresh classification downgrades a mirrored confirmation",
296296
);
297297

298-
// The narrow classification update merges the figures and candidates while preserving the
299-
// confirmation state wallet sync owns.
298+
// The narrow classification update merges the candidates while preserving the
299+
// confirmation state wallet sync owns and the confirmed candidate's figures.
300300
let mut merged = mirrored.clone();
301301
let narrow_update = PendingPaymentDetailsUpdate {
302302
id: payment_id,
@@ -313,7 +313,7 @@ mod tests {
313313
"a narrow classification update must not downgrade a mirrored confirmation",
314314
);
315315
assert_eq!(merged.candidates, candidates);
316-
assert_eq!(merged.details.amount_msat, Some(1_000));
317-
assert_eq!(merged.details.fee_paid_msat, Some(100));
316+
assert_eq!(merged.details.amount_msat, Some(2_000_000));
317+
assert_eq!(merged.details.fee_paid_msat, Some(999));
318318
}
319319
}

src/payment/store.rs

Lines changed: 128 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -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());

src/wallet/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1405,9 +1405,9 @@ impl Wallet {
14051405
// the write, and a full merge of the fresh Pending/Unconfirmed details would then
14061406
// downgrade the confirmation state the wallet-sync events own. `update_or_insert` holds
14071407
// the store's mutation lock across the whole decision: when a record exists — no matter
1408-
// when it appeared — only the classification (`tx_type`), the broadcast txid, and our
1409-
// contribution figures are merged, which the wallet can't recompute; otherwise the fresh
1410-
// details are inserted.
1408+
// when it appeared — only the classification (`tx_type`) and, while the record is still
1409+
// unconfirmed, the broadcast txid and our contribution figures are merged; otherwise the
1410+
// fresh details are inserted.
14111411
let update = PaymentDetailsUpdate::funding_reclassification(details.clone());
14121412
let pending_update = PendingPaymentDetailsUpdate {
14131413
id: update.id,

0 commit comments

Comments
 (0)