Skip to content

Commit ce76c1f

Browse files
jkczyzclaude
andcommitted
Drop the pending-store entry when removing a payment
Node::remove_payment removed only the payment-store record. For a still-pending on-chain payment this left an orphaned pending-store entry that kept resolving the payment's txids (current, conflicting, and RBF candidates), routing later wallet-sync events to a record that no longer exists: a replacement event for one of those txids then hits a debug assertion (and fails the sync in release builds). Nothing ever cleaned the entry up afterwards, since graduation only removes entries whose record is still live. Remove the pending entry along with the record, so a removed payment's txids no longer resolve. A replacement event for them is then skipped; other wallet events treat the transaction like any it observes without a record and may recreate one under a txid-derived id. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent b1337d2 commit ce76c1f

2 files changed

Lines changed: 93 additions & 3 deletions

File tree

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2160,7 +2160,7 @@ impl Node {
21602160

21612161
/// Remove the payment with the given id from the store.
21622162
pub fn remove_payment(&self, payment_id: &PaymentId) -> Result<(), Error> {
2163-
self.runtime.block_on(self.payment_store.remove(&payment_id))
2163+
self.runtime.block_on(self.wallet.remove_payment(payment_id))
21642164
}
21652165

21662166
/// Retrieves an overview of all known balances.

src/wallet/mod.rs

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1810,8 +1810,8 @@ impl Wallet {
18101810
let payment_store = Arc::clone(&self.payment_store);
18111811
self.pending_payment_store
18121812
.mutate_async(&id, move |existing| async move {
1813-
// The record was written above and payment records are never removed, so absence
1814-
// means the write failed out; fall back to the fresh details.
1813+
// The record was written above and removal serializes on the cross-store lock held
1814+
// here, so absence means the write failed out; fall back to the fresh details.
18151815
let recorded = payment_store.get(&id).await?.unwrap_or(details);
18161816
Ok(match existing {
18171817
// The inserted entry embeds the post-write record rather than the fresh
@@ -1910,6 +1910,20 @@ impl Wallet {
19101910
PendingPaymentDetails::new(payment, conflicting_txids, Vec::new())
19111911
}
19121912

1913+
/// Removes the payment with the given id from the payment store, along with any pending-store
1914+
/// entry indexing its txids. An orphaned entry would keep resolving those txids to the removed
1915+
/// record — routing later wallet-sync events to a payment that no longer exists — and nothing
1916+
/// would ever clean it up, since graduation only removes entries whose record is still live.
1917+
pub(crate) async fn remove_payment(&self, payment_id: &PaymentId) -> Result<(), Error> {
1918+
// Hold the cross-store lock so the two-store removal cannot interleave with a sync arm's
1919+
// or classification's resolve-then-write sequence. The pending entry goes first: a failure
1920+
// in between then leaves an unindexed record (benign, and the retry removes it) rather
1921+
// than an entry indexing a removed record.
1922+
let _guard = self.funding_payment_update_lock.lock().await;
1923+
self.pending_payment_store.remove(payment_id).await?;
1924+
self.payment_store.remove(payment_id).await
1925+
}
1926+
19131927
async fn find_payment_by_txid(&self, target_txid: Txid) -> Result<Option<PaymentId>, Error> {
19141928
let direct_payment_id = PaymentId(target_txid.to_byte_array());
19151929
if self.pending_payment_store.contains_key(&direct_payment_id).await? {
@@ -3960,6 +3974,82 @@ mod tests {
39603974
assert_eq!(wallet.find_payment_by_txid(txid2).await.unwrap(), Some(payment_id));
39613975
}
39623976

3977+
/// Removing a payment must also drop its pending-store entry. The entry indexes the
3978+
/// payment's txids (current, conflicting, and candidates), so leaving it behind keeps
3979+
/// resolving those txids to the removed record — routing later wallet events to a payment
3980+
/// that no longer exists — and nothing else ever cleans it up, since graduation only
3981+
/// removes entries whose record is still live.
3982+
#[tokio::test]
3983+
async fn remove_payment_drops_pending_entry() {
3984+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
3985+
let wallet = new_test_wallet(store, false).await;
3986+
3987+
let txid = Txid::from_byte_array([1u8; 32]);
3988+
let conflicting_txid = Txid::from_byte_array([2u8; 32]);
3989+
let payment_id = PaymentId(txid.to_byte_array());
3990+
3991+
// A Pending outbound on-chain payment with a recorded conflict (e.g. an RBF round).
3992+
let details = PaymentDetails::new(
3993+
payment_id,
3994+
PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, tx_type: None },
3995+
Some(1_000),
3996+
Some(100),
3997+
PaymentDirection::Outbound,
3998+
PaymentStatus::Pending,
3999+
);
4000+
wallet.payment_store.insert_or_update(details.clone()).await.unwrap();
4001+
let entry = PendingPaymentDetails::new(details, vec![conflicting_txid], Vec::new());
4002+
wallet.pending_payment_store.insert_or_update(entry).await.unwrap();
4003+
4004+
wallet.remove_payment(&payment_id).await.unwrap();
4005+
4006+
assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_none());
4007+
assert!(wallet.pending_payment_store.get(&payment_id).await.unwrap().is_none());
4008+
assert_eq!(wallet.find_payment_by_txid(txid).await.unwrap(), None);
4009+
assert_eq!(wallet.find_payment_by_txid(conflicting_txid).await.unwrap(), None);
4010+
4011+
// A replacement event for the removed transaction must skip rather than resolve to the
4012+
// removed record: the `TxReplaced` arm asserts the resolved record exists.
4013+
let event = WalletEvent::TxReplaced {
4014+
txid,
4015+
tx: Arc::new(dummy_tx()),
4016+
conflicts: vec![(0, conflicting_txid)],
4017+
};
4018+
wallet.update_payment_store(vec![event]).await.unwrap();
4019+
assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_none());
4020+
}
4021+
4022+
/// Payments without a pending-store entry — lightning payments, and on-chain payments that
4023+
/// already graduated — must remove cleanly: the unconditional pending-store removal relies
4024+
/// on removing a missing key being a no-op.
4025+
#[tokio::test]
4026+
async fn remove_payment_without_pending_entry() {
4027+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
4028+
let wallet = new_test_wallet(store, false).await;
4029+
4030+
let payment_id = PaymentId([9u8; 32]);
4031+
let details = PaymentDetails::new(
4032+
payment_id,
4033+
PaymentKind::Bolt11 {
4034+
hash: lightning_types::payment::PaymentHash([0u8; 32]),
4035+
preimage: None,
4036+
secret: None,
4037+
counterparty_skimmed_fee_msat: None,
4038+
},
4039+
Some(1_000),
4040+
None,
4041+
PaymentDirection::Outbound,
4042+
PaymentStatus::Succeeded,
4043+
);
4044+
wallet.payment_store.insert_or_update(details).await.unwrap();
4045+
4046+
wallet.remove_payment(&payment_id).await.unwrap();
4047+
assert!(wallet.payment_store.get(&payment_id).await.unwrap().is_none());
4048+
4049+
// Removing an id known to neither store is also a no-op rather than an error.
4050+
wallet.remove_payment(&PaymentId([8u8; 32])).await.unwrap();
4051+
}
4052+
39634053
/// A funding-typed broadcast that doesn't touch the on-chain wallet must not be recorded.
39644054
/// LDK re-broadcasts a promoted-but-unconfirmed 0conf splice through its generic funding
39654055
/// path, so a splice the interactive-funding classification deliberately declined — no local

0 commit comments

Comments
 (0)