Skip to content

Commit 33f1c2d

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 f2e44fd commit 33f1c2d

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
@@ -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

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

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,
@@ -1343,9 +1344,28 @@ impl Wallet {
13431344
async fn persist_funding_payment(
13441345
&self, details: PaymentDetails, candidates: Vec<FundingTxCandidate>,
13451346
) -> Result<(), Error> {
1346-
self.payment_store.insert_or_update(details.clone()).await?;
1347-
let pending = PendingPaymentDetails::new(details, Vec::new(), candidates);
1348-
self.pending_payment_store.insert_or_update(pending).await?;
1347+
if !self.payment_store.contains_key(&details.id) {
1348+
// First time we record this funding payment: store it and index it for graduation.
1349+
self.payment_store.insert_or_update(details.clone()).await?;
1350+
let pending = PendingPaymentDetails::new(details, Vec::new(), candidates);
1351+
self.pending_payment_store.insert_or_update(pending).await?;
1352+
} else {
1353+
// An earlier candidate or a racing wallet sync already recorded this payment. Merge only
1354+
// the classification (`tx_type`) and our contribution figures, which the wallet can't
1355+
// recompute; the confirmation state is owned by wallet-sync events, so a late
1356+
// classification must not move it (which would downgrade an already-Confirmed/Succeeded
1357+
// record). `update` is a no-op when the entry is absent, so the pending index is not
1358+
// re-created for a payment the graduation path already removed.
1359+
let update = PaymentDetailsUpdate::funding_reclassification(details);
1360+
let pending_update = PendingPaymentDetailsUpdate {
1361+
id: update.id,
1362+
payment_update: Some(update.clone()),
1363+
conflicting_txids: None,
1364+
candidates,
1365+
};
1366+
self.payment_store.update(update).await?;
1367+
self.pending_payment_store.update(pending_update).await?;
1368+
}
13491369
Ok(())
13501370
}
13511371

0 commit comments

Comments
 (0)