Skip to content

Commit 13ac606

Browse files
authored
Merge pull request #1087 from jkczyz/2026-09-remove-payment-pending-entry
Drop the pending-store entry when removing a payment
2 parents c309ccd + ce76c1f commit 13ac606

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)