Skip to content

Commit 46096b0

Browse files
jkczyzclaude
andcommitted
Preserve funding-payment confirmation state on late reclassification
Funding broadcasts are classified into payment records off the broadcaster's queue, which can run after wallet sync has already recorded the transaction -- for instance when LDK re-broadcasts a still-pending funding transaction on restart. In that case the classification overwrote a record wallet sync had already advanced, downgrading a confirmed or graduated funding payment back to unconfirmed/pending. Merge only the classification and our contribution figures into an existing record, leaving the confirmation state that the wallet-sync events own in place. Raised by Codex in the review of #888. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent abbed3e commit 46096b0

2 files changed

Lines changed: 139 additions & 4 deletions

File tree

src/payment/store.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

717744
impl 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,

src/wallet/mod.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ use persist::KVStoreWalletPersister;
5656
use crate::config::Config;
5757
use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator};
5858
use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger};
59-
use crate::payment::store::ConfirmationStatus;
59+
use crate::payment::pending_payment_store::PendingPaymentDetailsUpdate;
60+
use crate::payment::store::{ConfirmationStatus, PaymentDetailsUpdate};
6061
use crate::payment::{
6162
FundingTxCandidate, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus,
6263
PendingPaymentDetails, TransactionType,
@@ -1398,9 +1399,28 @@ impl Wallet {
13981399
async fn persist_funding_payment(
13991400
&self, details: PaymentDetails, candidates: Vec<FundingTxCandidate>,
14001401
) -> Result<(), Error> {
1401-
self.payment_store.insert_or_update(details.clone()).await?;
1402-
let pending = PendingPaymentDetails::new(details, Vec::new(), candidates);
1403-
self.pending_payment_store.insert_or_update(pending).await?;
1402+
if !self.payment_store.contains_key(&details.id) {
1403+
// First time we record this funding payment: store it and index it for graduation.
1404+
self.payment_store.insert_or_update(details.clone()).await?;
1405+
let pending = PendingPaymentDetails::new(details, Vec::new(), candidates);
1406+
self.pending_payment_store.insert_or_update(pending).await?;
1407+
} else {
1408+
// An earlier candidate or a racing wallet sync already recorded this payment. Merge only
1409+
// the classification (`tx_type`) and our contribution figures, which the wallet can't
1410+
// recompute; the confirmation state is owned by wallet-sync events, so a late
1411+
// classification must not move it (which would downgrade an already-Confirmed/Succeeded
1412+
// record). `update` is a no-op when the entry is absent, so the pending index is not
1413+
// re-created for a payment the graduation path already removed.
1414+
let update = PaymentDetailsUpdate::funding_reclassification(details);
1415+
let pending_update = PendingPaymentDetailsUpdate {
1416+
id: update.id,
1417+
payment_update: Some(update.clone()),
1418+
conflicting_txids: None,
1419+
candidates,
1420+
};
1421+
self.payment_store.update(update).await?;
1422+
self.pending_payment_store.update(pending_update).await?;
1423+
}
14041424
Ok(())
14051425
}
14061426

0 commit comments

Comments
 (0)