From 52905ac48f2d134e6bb6083083706501f16b33cd Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Jun 2026 10:59:12 +0200 Subject: [PATCH 1/7] Bump BDK wallet dependencies Update the direct BDK wallet stack to the latest crate releases. This lets follow-up wallet event code use the upstream BDK API. It also preserves temporary transaction cleanup after BDK removed its cancel_tx helper. Co-Authored-By: HAL 9000 --- Cargo.toml | 6 +++--- src/wallet/mod.rs | 24 ++++++++++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index bed984f07..c0bb243b9 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,10 +54,10 @@ lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } lightning-dns-resolver = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } -bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } -bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} +bdk_chain = { version = "0.23.3", default-features = false, features = ["std"] } +bdk_esplora = { version = "0.22.2", default-features = false, features = ["async-https-rustls", "tokio"]} bdk_electrum = { version = "0.24.0", default-features = false, features = ["use-rustls-ring"]} -bdk_wallet = { version = "2.3.0", default-features = false, features = ["std", "keys-bip39"]} +bdk_wallet = { version = "3.0.0", default-features = false, features = ["std", "keys-bip39"]} bitreq = { version = "0.3", default-features = false, features = ["async-https", "json-using-serde"] } rustls = { version = "0.23", default-features = false } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 76f2aa9ce..9f3ba9c42 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -13,10 +13,9 @@ use std::sync::{Arc, Mutex}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; use bdk_wallet::descriptor::ExtendedDescriptor; use bdk_wallet::error::{BuildFeeBumpError, CreateTxError}; -use bdk_wallet::event::WalletEvent; #[allow(deprecated)] use bdk_wallet::SignOptions; -use bdk_wallet::{Balance, KeychainKind, PersistedWallet, Update}; +use bdk_wallet::{Balance, KeychainKind, PersistedWallet, Update, WalletEvent}; use bitcoin::address::NetworkUnchecked; use bitcoin::blockdata::constants::WITNESS_SCALE_FACTOR; use bitcoin::blockdata::locktime::absolute::LockTime; @@ -517,7 +516,7 @@ impl Wallet { let mut locked_wallet = self.inner.lock().expect("lock"); let mut locked_persister = self.persister.lock().expect("lock"); - locked_wallet.cancel_tx(tx); + Self::cancel_tx_inner(&mut locked_wallet, tx); self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed @@ -526,6 +525,19 @@ impl Wallet { Ok(()) } + fn cancel_tx_inner( + locked_wallet: &mut PersistedWallet, tx: &Transaction, + ) { + for txout in &tx.output { + if let Some((keychain, index)) = + locked_wallet.derivation_of_spk(txout.script_pubkey.clone()) + { + // This mirrors the removed BDK helper: it only frees superficial usage marks. + locked_wallet.unmark_used(keychain, index); + } + } + } + pub(crate) fn get_balances( &self, total_anchor_channels_reserve_sats: u64, ) -> Result<(u64, u64), Error> { @@ -678,7 +690,7 @@ impl Wallet { None, )?; - locked_wallet.cancel_tx(&tmp_psbt.unsigned_tx); + Self::cancel_tx_inner(&mut locked_wallet, &tmp_psbt.unsigned_tx); Ok(max_amount) } @@ -708,7 +720,7 @@ impl Wallet { Some(&shared_input), )?; - locked_wallet.cancel_tx(&tmp_psbt.unsigned_tx); + Self::cancel_tx_inner(&mut locked_wallet, &tmp_psbt.unsigned_tx); Ok(splice_amount) } @@ -764,7 +776,7 @@ impl Wallet { e })?; - locked_wallet.cancel_tx(&tmp_psbt.unsigned_tx); + Self::cancel_tx_inner(&mut locked_wallet, &tmp_psbt.unsigned_tx); let mut tx_builder = locked_wallet.build_tx(); tx_builder From 82feeed8aa85bc845df7d6b372d166a6bde63c93 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Jun 2026 11:00:31 +0200 Subject: [PATCH 2/7] Use BDK mempool wallet events Use BDK's wallet event helper for mempool updates. This removes the local event diffing copy now that BDK exposes the needed event API. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 132 +++------------------------------------------- 1 file changed, 7 insertions(+), 125 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 9f3ba9c42..1b7383137 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -185,29 +185,13 @@ impl Wallet { let mut locked_wallet = self.inner.lock().expect("lock"); - let chain_tip1 = locked_wallet.latest_checkpoint().block_id(); - let wallet_txs1 = locked_wallet - .transactions() - .map(|wtx| (wtx.tx_node.txid, (wtx.tx_node.tx.clone(), wtx.chain_position))) - .collect::, bdk_chain::ChainPosition), - >>(); - - locked_wallet.apply_unconfirmed_txs(unconfirmed_txs); - locked_wallet.apply_evicted_txs(evicted_txids); - - let chain_tip2 = locked_wallet.latest_checkpoint().block_id(); - let wallet_txs2 = locked_wallet - .transactions() - .map(|wtx| (wtx.tx_node.txid, (wtx.tx_node.tx.clone(), wtx.chain_position))) - .collect::, bdk_chain::ChainPosition), - >>(); - - let events = - wallet_events(&mut *locked_wallet, chain_tip1, chain_tip2, wallet_txs1, wallet_txs2); + let events = locked_wallet + .events_helper(|wallet| -> Result<(), std::convert::Infallible> { + wallet.apply_unconfirmed_txs(unconfirmed_txs); + wallet.apply_evicted_txs(evicted_txids); + Ok(()) + }) + .expect("applying mempool updates cannot fail"); self.update_payment_store(&mut *locked_wallet, events).map_err(|e| { log_error!(self.logger, "Failed to update payment store: {}", e); @@ -1767,105 +1751,3 @@ fn ldk_to_bdk_satisfaction_weight(ldk_satisfaction_weight: u64) -> Weight { .saturating_sub(EMPTY_SCRIPT_SIG_WEIGHT + EMPTY_WITNESS_COUNT_WEIGHT), ) } - -// FIXME/TODO: This is copied-over from bdk_wallet and only used to generate `WalletEvent`s after -// applying mempool transactions. We should drop this when BDK offers to generate events for -// mempool transactions natively. -pub(crate) fn wallet_events( - wallet: &mut bdk_wallet::Wallet, chain_tip1: bdk_chain::BlockId, - chain_tip2: bdk_chain::BlockId, - wallet_txs1: std::collections::BTreeMap< - Txid, - (Arc, bdk_chain::ChainPosition), - >, - wallet_txs2: std::collections::BTreeMap< - Txid, - (Arc, bdk_chain::ChainPosition), - >, -) -> Vec { - let mut events: Vec = Vec::new(); - - if chain_tip1 != chain_tip2 { - events.push(WalletEvent::ChainTipChanged { old_tip: chain_tip1, new_tip: chain_tip2 }); - } - - wallet_txs2.iter().for_each(|(txid2, (tx2, cp2))| { - if let Some((tx1, cp1)) = wallet_txs1.get(txid2) { - assert_eq!(tx1.compute_txid(), *txid2); - match (cp1, cp2) { - ( - bdk_chain::ChainPosition::Unconfirmed { .. }, - bdk_chain::ChainPosition::Confirmed { anchor, .. }, - ) => { - events.push(WalletEvent::TxConfirmed { - txid: *txid2, - tx: tx2.clone(), - block_time: *anchor, - old_block_time: None, - }); - }, - ( - bdk_chain::ChainPosition::Confirmed { anchor, .. }, - bdk_chain::ChainPosition::Unconfirmed { .. }, - ) => { - events.push(WalletEvent::TxUnconfirmed { - txid: *txid2, - tx: tx2.clone(), - old_block_time: Some(*anchor), - }); - }, - ( - bdk_chain::ChainPosition::Confirmed { anchor: anchor1, .. }, - bdk_chain::ChainPosition::Confirmed { anchor: anchor2, .. }, - ) => { - if *anchor1 != *anchor2 { - events.push(WalletEvent::TxConfirmed { - txid: *txid2, - tx: tx2.clone(), - block_time: *anchor2, - old_block_time: Some(*anchor1), - }); - } - }, - ( - bdk_chain::ChainPosition::Unconfirmed { .. }, - bdk_chain::ChainPosition::Unconfirmed { .. }, - ) => { - // do nothing if still unconfirmed - }, - } - } else { - match cp2 { - bdk_chain::ChainPosition::Confirmed { anchor, .. } => { - events.push(WalletEvent::TxConfirmed { - txid: *txid2, - tx: tx2.clone(), - block_time: *anchor, - old_block_time: None, - }); - }, - bdk_chain::ChainPosition::Unconfirmed { .. } => { - events.push(WalletEvent::TxUnconfirmed { - txid: *txid2, - tx: tx2.clone(), - old_block_time: None, - }); - }, - } - } - }); - - // find tx that are no longer canonical - wallet_txs1.iter().for_each(|(txid1, (tx1, _))| { - if !wallet_txs2.contains_key(txid1) { - let conflicts = wallet.tx_graph().direct_conflicts(tx1).collect::>(); - if !conflicts.is_empty() { - events.push(WalletEvent::TxReplaced { txid: *txid1, tx: tx1.clone(), conflicts }); - } else { - events.push(WalletEvent::TxDropped { txid: *txid1, tx: tx1.clone() }); - } - } - }); - - events -} From dfe1263fadeaed7a89b965504d77b4cbb22aa04d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 15:48:09 +0200 Subject: [PATCH 3/7] Track reorged on-chain payments as pending Move affected on-chain payments back to pending when BDK reports that their transaction is unconfirmed again. This keeps payment history aligned with wallet events after a reorg. It does not update payment records directly from disconnected-block notifications. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 2 +- tests/integration_tests_rust.rs | 78 +++++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 1b7383137..3f6a09c79 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -345,7 +345,7 @@ impl Wallet { } } }, - WalletEvent::TxUnconfirmed { txid, tx, old_block_time: None } => { + WalletEvent::TxUnconfirmed { txid, tx, .. } => { let payment_id = self .find_payment_by_txid(txid) .unwrap_or_else(|| PaymentId(txid.to_byte_array())); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 309d5bf4d..53da82838 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -21,10 +21,11 @@ use common::{ expect_channel_pending_event, expect_channel_ready_event, expect_channel_ready_events, expect_event, expect_payment_claimable_event, expect_payment_received_event, expect_payment_successful_event, expect_splice_negotiated_event, generate_blocks_and_wait, - generate_listening_addresses, open_channel, open_channel_push_amt, open_channel_with_all, - premine_and_distribute_funds, premine_blocks, prepare_rbf, random_chain_source, random_config, - setup_bitcoind_and_electrsd, setup_builder, setup_node, setup_two_nodes, splice_in_with_all, - wait_for_tx, TestChainSource, TestConfig, TestStoreType, TestSyncStore, + generate_listening_addresses, invalidate_blocks, open_channel, open_channel_push_amt, + open_channel_with_all, premine_and_distribute_funds, premine_blocks, prepare_rbf, + random_chain_source, random_config, setup_bitcoind_and_electrsd, setup_builder, setup_node, + setup_two_nodes, splice_in_with_all, wait_for_block, wait_for_tx, TestChainSource, TestConfig, + TestStoreType, TestSyncStore, }; use electrsd::corepc_node::Node as BitcoinD; use electrsd::ElectrsD; @@ -42,6 +43,7 @@ use lightning::routing::router::RouteParametersConfig; use lightning_invoice::{Bolt11InvoiceDescription, Description}; use lightning_types::payment::{PaymentHash, PaymentPreimage}; use log::LevelFilter; +use serde_json::json; #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_full_cycle() { @@ -577,6 +579,74 @@ async fn onchain_send_receive() { assert_eq!(node_b_payments.len(), 5); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn reorged_onchain_payment_returns_to_unconfirmed() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 500_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let amount_to_send_sats = 100_000; + let txid = + node_b.onchain_payment().send_to_address(&addr_a, amount_to_send_sats, None).unwrap(); + wait_for_tx(&electrsd.client, txid).await; + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let payment_id = PaymentId(txid.to_byte_array()); + for node in [&node_a, &node_b] { + let payment = node.payment(&payment_id).unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + match payment.kind { + PaymentKind::Onchain { status, .. } => { + assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + }, + _ => panic!("Unexpected payment kind"), + } + } + + let original_height = + bitcoind.client.get_blockchain_info().expect("failed to get blockchain info").blocks; + invalidate_blocks(&bitcoind.client, 1); + let replacement_address = bitcoind.client.new_address().expect("failed to get new address"); + for _ in 0..2 { + let _res: serde_json::Value = bitcoind + .client + .call("generateblock", &[json!(replacement_address.to_string()), json!([])]) + .expect("failed to generate empty block"); + } + wait_for_block(&electrsd.client, original_height as usize + 1).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + for node in [&node_a, &node_b] { + let payment = node.payment(&payment_id).unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + match payment.kind { + PaymentKind::Onchain { status, .. } => { + assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + }, + _ => panic!("Unexpected payment kind"), + } + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn onchain_send_all_retains_reserve() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From 11e4c7d9d8dbad0cd4310730d5a0a22c485bf217 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 15:48:55 +0200 Subject: [PATCH 4/7] Group pending payment storage constants Keep pending payment namespace constants next to the primary payment store constants. This keeps related persistence keys discoverable together. Co-Authored-By: HAL 9000 --- src/io/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/io/mod.rs b/src/io/mod.rs index e16a99975..a01aa59a8 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -29,6 +29,10 @@ pub(crate) const PEER_INFO_PERSISTENCE_KEY: &str = "peers"; pub(crate) const PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "payments"; pub(crate) const PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +/// The pending payment information will be persisted under this prefix. +pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "pending_payments"; +pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; + /// The node metrics will be persisted under this key. pub(crate) const NODE_METRICS_PRIMARY_NAMESPACE: &str = ""; pub(crate) const NODE_METRICS_SECONDARY_NAMESPACE: &str = ""; @@ -80,7 +84,3 @@ pub(crate) const BDK_WALLET_INDEXER_KEY: &str = "indexer"; /// /// [`StaticInvoice`]: lightning::offers::static_invoice::StaticInvoice pub(crate) const STATIC_INVOICE_STORE_PRIMARY_NAMESPACE: &str = "static_invoices"; - -/// The pending payment information will be persisted under this prefix. -pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "pending_payments"; -pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; From 5b0f459d43adf0708500cfff8a64145963c76f21 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 15:49:47 +0200 Subject: [PATCH 5/7] Keep pending payment details internal Stop exporting the pending payment index record from the public payment module. The pending index is an internal persistence detail and should not become public API before this ships. Co-Authored-By: HAL 9000 --- src/payment/mod.rs | 2 +- src/payment/pending_payment_store.rs | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 71daa48b0..ee53ed7f8 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -20,7 +20,7 @@ pub use bolt11::Bolt11Payment; pub(crate) use bolt11::PaymentMetadata; pub use bolt12::Bolt12Payment; pub use onchain::OnchainPayment; -pub use pending_payment_store::PendingPaymentDetails; +pub(crate) use pending_payment_store::PendingPaymentDetails; pub use spontaneous::SpontaneousPayment; pub use store::{ ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind, diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index eb72f89ec..4ff497334 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -26,11 +26,6 @@ impl PendingPaymentDetails { pub(crate) fn new(details: PaymentDetails, conflicting_txids: Vec) -> Self { Self { details, conflicting_txids } } - - /// Convert to finalized payment for the main payment store - pub fn into_payment_details(self) -> PaymentDetails { - self.details - } } impl_writeable_tlv_based!(PendingPaymentDetails, { From dc8884ea8fa275261c1c3e57125154f6d0e18d3c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 15:51:23 +0200 Subject: [PATCH 6/7] Co-locate pending payment indexes Move the pending payment index record into the payment store module. This keeps the primary payment record and its pending index within the same persistence boundary. Co-Authored-By: HAL 9000 --- src/payment/mod.rs | 3 +- src/payment/pending_payment_store.rs | 89 ---------------------------- src/payment/store.rs | 75 +++++++++++++++++++++++ 3 files changed, 76 insertions(+), 91 deletions(-) delete mode 100644 src/payment/pending_payment_store.rs diff --git a/src/payment/mod.rs b/src/payment/mod.rs index ee53ed7f8..8e2aa00ee 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -11,7 +11,6 @@ pub(crate) mod asynchronous; mod bolt11; mod bolt12; mod onchain; -pub(crate) mod pending_payment_store; mod spontaneous; pub(crate) mod store; mod unified; @@ -20,8 +19,8 @@ pub use bolt11::Bolt11Payment; pub(crate) use bolt11::PaymentMetadata; pub use bolt12::Bolt12Payment; pub use onchain::OnchainPayment; -pub(crate) use pending_payment_store::PendingPaymentDetails; pub use spontaneous::SpontaneousPayment; +pub(crate) use store::PendingPaymentDetails; pub use store::{ ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs deleted file mode 100644 index 4ff497334..000000000 --- a/src/payment/pending_payment_store.rs +++ /dev/null @@ -1,89 +0,0 @@ -// This file is Copyright its original authors, visible in version control history. -// -// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in -// accordance with one or both of these licenses. - -use bitcoin::Txid; -use lightning::impl_writeable_tlv_based; -use lightning::ln::channelmanager::PaymentId; - -use crate::data_store::{StorableObject, StorableObjectUpdate}; -use crate::payment::store::PaymentDetailsUpdate; -use crate::payment::PaymentDetails; - -/// Represents a pending payment -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PendingPaymentDetails { - /// The full payment details - pub details: PaymentDetails, - /// Transaction IDs that have replaced or conflict with this payment. - pub conflicting_txids: Vec, -} - -impl PendingPaymentDetails { - pub(crate) fn new(details: PaymentDetails, conflicting_txids: Vec) -> Self { - Self { details, conflicting_txids } - } -} - -impl_writeable_tlv_based!(PendingPaymentDetails, { - (0, details, required), - (2, conflicting_txids, optional_vec), -}); - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct PendingPaymentDetailsUpdate { - pub id: PaymentId, - pub payment_update: Option, - pub conflicting_txids: Option>, -} - -impl StorableObject for PendingPaymentDetails { - type Id = PaymentId; - type Update = PendingPaymentDetailsUpdate; - - fn id(&self) -> Self::Id { - self.details.id - } - - fn update(&mut self, update: Self::Update) -> bool { - let mut updated = false; - - // Update the underlying payment details if present - if let Some(payment_update) = update.payment_update { - updated |= self.details.update(payment_update); - } - - if let Some(new_conflicting_txids) = update.conflicting_txids { - if self.conflicting_txids != new_conflicting_txids { - self.conflicting_txids = new_conflicting_txids; - updated = true; - } - } - - updated - } - - fn to_update(&self) -> Self::Update { - self.into() - } -} - -impl StorableObjectUpdate for PendingPaymentDetailsUpdate { - fn id(&self) -> ::Id { - self.id - } -} - -impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { - fn from(value: &PendingPaymentDetails) -> Self { - let conflicting_txids = if value.conflicting_txids.is_empty() { - None - } else { - Some(value.conflicting_txids.clone()) - }; - Self { id: value.id(), payment_update: Some(value.details.to_update()), conflicting_txids } - } -} diff --git a/src/payment/store.rs b/src/payment/store.rs index f80ab6f8a..03c1b7ed6 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -586,6 +586,81 @@ impl StorableObjectUpdate for PaymentDetailsUpdate { } } +/// Represents a pending payment +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PendingPaymentDetails { + /// The full payment details + pub details: PaymentDetails, + /// Transaction IDs that have replaced or conflict with this payment. + pub conflicting_txids: Vec, +} + +impl PendingPaymentDetails { + pub(crate) fn new(details: PaymentDetails, conflicting_txids: Vec) -> Self { + Self { details, conflicting_txids } + } +} + +impl_writeable_tlv_based!(PendingPaymentDetails, { + (0, details, required), + (2, conflicting_txids, optional_vec), +}); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PendingPaymentDetailsUpdate { + pub id: PaymentId, + pub payment_update: Option, + pub conflicting_txids: Option>, +} + +impl StorableObject for PendingPaymentDetails { + type Id = PaymentId; + type Update = PendingPaymentDetailsUpdate; + + fn id(&self) -> Self::Id { + self.details.id + } + + fn update(&mut self, update: Self::Update) -> bool { + let mut updated = false; + + // Update the underlying payment details if present + if let Some(payment_update) = update.payment_update { + updated |= self.details.update(payment_update); + } + + if let Some(new_conflicting_txids) = update.conflicting_txids { + if self.conflicting_txids != new_conflicting_txids { + self.conflicting_txids = new_conflicting_txids; + updated = true; + } + } + + updated + } + + fn to_update(&self) -> Self::Update { + self.into() + } +} + +impl StorableObjectUpdate for PendingPaymentDetailsUpdate { + fn id(&self) -> ::Id { + self.id + } +} + +impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { + fn from(value: &PendingPaymentDetails) -> Self { + let conflicting_txids = if value.conflicting_txids.is_empty() { + None + } else { + Some(value.conflicting_txids.clone()) + }; + Self { id: value.id(), payment_update: Some(value.details.to_update()), conflicting_txids } + } +} + #[cfg(test)] mod tests { use lightning::util::ser::{Readable, Writeable}; From 70654b2d398e4351146af4a15d0b2a985a8558b8 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 15:56:58 +0200 Subject: [PATCH 7/7] Store compact pending payment index records Persist only the payment id, current txid, and conflict txids in the pending payment index. This avoids duplicating payment state before the format ships and keeps RBF lookup aliases intact across replacement events. Co-Authored-By: HAL 9000 --- src/payment/store.rs | 52 ++++++++++++++----------- src/wallet/mod.rs | 91 ++++++++++++++++++++++++++++---------------- 2 files changed, 87 insertions(+), 56 deletions(-) diff --git a/src/payment/store.rs b/src/payment/store.rs index 03c1b7ed6..80623411d 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -589,28 +589,31 @@ impl StorableObjectUpdate for PaymentDetailsUpdate { /// Represents a pending payment #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PendingPaymentDetails { - /// The full payment details - pub details: PaymentDetails, + /// The payment id tracked in the main payment store. + pub payment_id: PaymentId, + /// The canonical transaction id currently associated with the payment. + pub txid: Txid, /// Transaction IDs that have replaced or conflict with this payment. pub conflicting_txids: Vec, } impl PendingPaymentDetails { - pub(crate) fn new(details: PaymentDetails, conflicting_txids: Vec) -> Self { - Self { details, conflicting_txids } + pub(crate) fn new(payment_id: PaymentId, txid: Txid, conflicting_txids: Vec) -> Self { + Self { payment_id, txid, conflicting_txids } } } impl_writeable_tlv_based!(PendingPaymentDetails, { - (0, details, required), - (2, conflicting_txids, optional_vec), + (0, payment_id, required), + (2, txid, required), + (4, conflicting_txids, optional_vec), }); #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PendingPaymentDetailsUpdate { - pub id: PaymentId, - pub payment_update: Option, - pub conflicting_txids: Option>, + pub payment_id: PaymentId, + pub txid: Txid, + pub conflicting_txids: Vec, } impl StorableObject for PendingPaymentDetails { @@ -618,20 +621,24 @@ impl StorableObject for PendingPaymentDetails { type Update = PendingPaymentDetailsUpdate; fn id(&self) -> Self::Id { - self.details.id + self.payment_id } fn update(&mut self, update: Self::Update) -> bool { let mut updated = false; - // Update the underlying payment details if present - if let Some(payment_update) = update.payment_update { - updated |= self.details.update(payment_update); + if self.txid != update.txid { + let old_txid = self.txid; + self.txid = update.txid; + if old_txid != self.txid && !self.conflicting_txids.contains(&old_txid) { + self.conflicting_txids.push(old_txid); + } + updated = true; } - if let Some(new_conflicting_txids) = update.conflicting_txids { - if self.conflicting_txids != new_conflicting_txids { - self.conflicting_txids = new_conflicting_txids; + for txid in update.conflicting_txids { + if txid != self.txid && !self.conflicting_txids.contains(&txid) { + self.conflicting_txids.push(txid); updated = true; } } @@ -646,18 +653,17 @@ impl StorableObject for PendingPaymentDetails { impl StorableObjectUpdate for PendingPaymentDetailsUpdate { fn id(&self) -> ::Id { - self.id + self.payment_id } } impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { fn from(value: &PendingPaymentDetails) -> Self { - let conflicting_txids = if value.conflicting_txids.is_empty() { - None - } else { - Some(value.conflicting_txids.clone()) - }; - Self { id: value.id(), payment_update: Some(value.details.to_update()), conflicting_txids } + Self { + payment_id: value.id(), + txid: value.txid, + conflicting_txids: value.conflicting_txids.clone(), + } } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 3f6a09c79..4c6f7524f 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -270,48 +270,62 @@ impl Wallet { if payment_status == PaymentStatus::Pending { let pending_payment = - self.create_pending_payment_from_tx(payment, Vec::new()); + self.create_pending_payment_from_tx(payment_id, txid, Vec::new()); self.runtime.block_on( self.pending_payment_store.insert_or_update(pending_payment), )?; + } else { + self.runtime.block_on(self.pending_payment_store.remove(&payment_id))?; } }, WalletEvent::ChainTipChanged { new_tip, .. } => { let pending_payments: Vec = - self.pending_payment_store.list_filter(|p| { - debug_assert!( - p.details.status == PaymentStatus::Pending, - "Non-pending payment {:?} found in pending store", - p.details.id, - ); - p.details.status == PaymentStatus::Pending - && matches!(p.details.kind, PaymentKind::Onchain { .. }) - }); + self.pending_payment_store.list_filter(|_| true); let mut unconfirmed_outbound_txids: Vec = Vec::new(); - for mut payment in pending_payments { - match payment.details.kind { + for pending_payment in pending_payments { + let Some(mut payment) = self.payment_store.get(&pending_payment.payment_id) + else { + self.runtime.block_on( + self.pending_payment_store.remove(&pending_payment.payment_id), + )?; + continue; + }; + + debug_assert!( + payment.status == PaymentStatus::Pending, + "Non-pending payment {:?} found in pending store", + payment.id, + ); + if payment.status != PaymentStatus::Pending { + self.runtime.block_on( + self.pending_payment_store.remove(&pending_payment.payment_id), + )?; + continue; + } + + match &payment.kind { PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { height, .. }, .. } => { - let payment_id = payment.details.id; - if new_tip.height >= height + ANTI_REORG_DELAY - 1 { - payment.details.status = PaymentStatus::Succeeded; + if new_tip.height >= *height + ANTI_REORG_DELAY - 1 { + payment.status = PaymentStatus::Succeeded; + self.runtime + .block_on(self.payment_store.insert_or_update(payment))?; self.runtime.block_on( - self.payment_store.insert_or_update(payment.details), + self.pending_payment_store + .remove(&pending_payment.payment_id), )?; - self.runtime - .block_on(self.pending_payment_store.remove(&payment_id))?; } }, PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, - } if payment.details.direction == PaymentDirection::Outbound => { - unconfirmed_outbound_txids.push(txid); + } if payment.direction == PaymentDirection::Outbound => { + unconfirmed_outbound_txids.push(*txid); }, _ => {}, } @@ -359,7 +373,7 @@ impl Wallet { ConfirmationStatus::Unconfirmed, ); let pending_payment = - self.create_pending_payment_from_tx(payment.clone(), Vec::new()); + self.create_pending_payment_from_tx(payment_id, txid, Vec::new()); self.runtime.block_on(self.payment_store.insert_or_update(payment))?; self.runtime .block_on(self.pending_payment_store.insert_or_update(pending_payment))?; @@ -389,8 +403,22 @@ impl Wallet { ); let payment = self.payment_store.get(&payment_id).ok_or(Error::InvalidPaymentId)?; - let pending_payment_details = - self.create_pending_payment_from_tx(payment, conflict_txids.clone()); + let payment_txid = match &payment.kind { + PaymentKind::Onchain { txid, .. } => *txid, + _ => { + log_error!( + self.logger, + "Payment {:?} is not on-chain during WalletEvent::TxReplaced", + payment_id, + ); + continue; + }, + }; + let pending_payment_details = self.create_pending_payment_from_tx( + payment_id, + payment_txid, + conflict_txids, + ); self.runtime.block_on( self.pending_payment_store.insert_or_update(pending_payment_details), @@ -409,7 +437,7 @@ impl Wallet { ConfirmationStatus::Unconfirmed, ); let pending_payment = - self.create_pending_payment_from_tx(payment.clone(), Vec::new()); + self.create_pending_payment_from_tx(payment_id, txid, Vec::new()); self.runtime.block_on(self.payment_store.insert_or_update(payment))?; self.runtime .block_on(self.pending_payment_store.insert_or_update(pending_payment))?; @@ -1207,9 +1235,9 @@ impl Wallet { } fn create_pending_payment_from_tx( - &self, payment: PaymentDetails, conflicting_txids: Vec, + &self, payment_id: PaymentId, txid: Txid, conflicting_txids: Vec, ) -> PendingPaymentDetails { - PendingPaymentDetails::new(payment, conflicting_txids) + PendingPaymentDetails::new(payment_id, txid, conflicting_txids) } fn find_payment_by_txid(&self, target_txid: Txid) -> Option { @@ -1220,13 +1248,10 @@ impl Wallet { if let Some(replaced_details) = self .pending_payment_store - .list_filter(|p| { - matches!(p.details.kind, PaymentKind::Onchain { txid, .. } if txid == target_txid) - || p.conflicting_txids.contains(&target_txid) - }) + .list_filter(|p| p.txid == target_txid || p.conflicting_txids.contains(&target_txid)) .first() { - return Some(replaced_details.details.id); + return Some(replaced_details.payment_id); } None @@ -1428,11 +1453,11 @@ impl Wallet { ); let pending_payment_store = - self.create_pending_payment_from_tx(new_payment.clone(), Vec::new()); + self.create_pending_payment_from_tx(new_payment.id, new_txid, vec![txid]); + self.runtime.block_on(self.payment_store.insert_or_update(new_payment))?; self.runtime .block_on(self.pending_payment_store.insert_or_update(pending_payment_store))?; - self.runtime.block_on(self.payment_store.insert_or_update(new_payment))?; log_info!(self.logger, "RBF successful: replaced {} with {}", txid, new_txid);