From c570e77bb49f48ccaaec625d6c0ba5108055df86 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 26 Feb 2026 12:54:22 +0100 Subject: [PATCH 1/8] Bump LDK dependency for `Bolt11Invoice` serialization Switch all `lightning*` dependencies to `tnull/rust-lightning` branch `2025-11-lsps1-refactor` which adds `Writeable`/`Readable` impls for `Bolt11Invoice`, `Offer`, and `Refund`. Also adapt to `LiquidityManager` API changes: the `ChainSource` generic parameter was removed and `LiquidityServiceConfig` gained an `lsps1_service_config` field. Generated with the help of AI (Claude Code). Co-Authored-By: HAL 9000 --- Cargo.toml | 49 ++++++++++++++++++++++++------------------------ src/builder.rs | 1 - src/liquidity.rs | 15 +++++++-------- src/types.rs | 1 - 4 files changed, 32 insertions(+), 34 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2e224720d..25c57aa64 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,17 +39,17 @@ default = [] #lightning-liquidity = { version = "0.2.0", features = ["std"] } #lightning-macros = { version = "0.2.0" } -lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "98501d6e5134228c41460dcf786ab53337e41245", features = ["std"] } -lightning-types = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "98501d6e5134228c41460dcf786ab53337e41245" } -lightning-invoice = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "98501d6e5134228c41460dcf786ab53337e41245", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "98501d6e5134228c41460dcf786ab53337e41245" } -lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "98501d6e5134228c41460dcf786ab53337e41245", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "98501d6e5134228c41460dcf786ab53337e41245" } -lightning-rapid-gossip-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "98501d6e5134228c41460dcf786ab53337e41245" } -lightning-block-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "98501d6e5134228c41460dcf786ab53337e41245", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "98501d6e5134228c41460dcf786ab53337e41245", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "98501d6e5134228c41460dcf786ab53337e41245", features = ["std"] } -lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "98501d6e5134228c41460dcf786ab53337e41245" } +lightning = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor", features = ["std"] } +lightning-types = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor" } +lightning-invoice = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor" } +lightning-persister = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor" } +lightning-rapid-gossip-sync = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor" } +lightning-block-sync = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor", features = ["std"] } +lightning-macros = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor" } 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"]} @@ -84,7 +84,7 @@ bitcoin-payment-instructions = { git = "https://github.com/jkczyz/bitcoin-paymen winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "98501d6e5134228c41460dcf786ab53337e41245", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor", features = ["std", "_test_utils"] } rand = { version = "0.9.2", default-features = false, features = ["std", "thread_rng", "os_rng"] } proptest = "1.0.0" regex = "1.5.6" @@ -170,15 +170,16 @@ harness = false #vss-client-ng = { path = "../vss-client" } #vss-client-ng = { git = "https://github.com/lightningdevkit/vss-client", branch = "main" } # -#[patch."https://github.com/lightningdevkit/rust-lightning"] -#lightning = { path = "../rust-lightning/lightning" } -#lightning-types = { path = "../rust-lightning/lightning-types" } -#lightning-invoice = { path = "../rust-lightning/lightning-invoice" } -#lightning-net-tokio = { path = "../rust-lightning/lightning-net-tokio" } -#lightning-persister = { path = "../rust-lightning/lightning-persister" } -#lightning-background-processor = { path = "../rust-lightning/lightning-background-processor" } -#lightning-rapid-gossip-sync = { path = "../rust-lightning/lightning-rapid-gossip-sync" } -#lightning-block-sync = { path = "../rust-lightning/lightning-block-sync" } -#lightning-transaction-sync = { path = "../rust-lightning/lightning-transaction-sync" } -#lightning-liquidity = { path = "../rust-lightning/lightning-liquidity" } -#lightning-macros = { path = "../rust-lightning/lightning-macros" } +[patch."https://github.com/lightningdevkit/rust-lightning"] +lightning = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor" } +lightning-types = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor" } +lightning-invoice = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor" } +lightning-net-tokio = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor" } +lightning-persister = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor" } +lightning-background-processor = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor" } +lightning-rapid-gossip-sync = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor" } +lightning-block-sync = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor" } +lightning-transaction-sync = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor" } +lightning-liquidity = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor" } +lightning-macros = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor" } +possiblyrandom = { git = "https://github.com/tnull/rust-lightning", branch = "2025-11-lsps1-refactor" } diff --git a/src/builder.rs b/src/builder.rs index a2ea9aea7..1353fe808 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1619,7 +1619,6 @@ fn build_with_store_internal( Arc::clone(&wallet), Arc::clone(&channel_manager), Arc::clone(&keys_manager), - Arc::clone(&chain_source), Arc::clone(&tx_broadcaster), Arc::clone(&kv_store), Arc::clone(&config), diff --git a/src/liquidity.rs b/src/liquidity.rs index c22df0898..9cbeb9fe8 100644 --- a/src/liquidity.rs +++ b/src/liquidity.rs @@ -39,7 +39,6 @@ use lightning_types::payment::PaymentHash; use tokio::sync::oneshot; use crate::builder::BuildError; -use crate::chain::ChainSource; use crate::connection::ConnectionManager; use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; use crate::runtime::Runtime; @@ -154,7 +153,6 @@ where wallet: Arc, channel_manager: Arc, keys_manager: Arc, - chain_source: Arc, tx_broadcaster: Arc, kv_store: Arc, config: Arc, @@ -167,8 +165,7 @@ where { pub(crate) fn new( wallet: Arc, channel_manager: Arc, keys_manager: Arc, - chain_source: Arc, tx_broadcaster: Arc, kv_store: Arc, - config: Arc, logger: L, + tx_broadcaster: Arc, kv_store: Arc, config: Arc, logger: L, ) -> Self { let lsps1_client = None; let lsps2_client = None; @@ -180,7 +177,6 @@ where wallet, channel_manager, keys_manager, - chain_source, tx_broadcaster, kv_store, config, @@ -238,7 +234,12 @@ where let lsps2_service_config = Some(s.ldk_service_config.clone()); let lsps5_service_config = None; let advertise_service = s.service_config.advertise_service; - LiquidityServiceConfig { lsps2_service_config, lsps5_service_config, advertise_service } + LiquidityServiceConfig { + lsps1_service_config: None, + lsps2_service_config, + lsps5_service_config, + advertise_service, + } }); let lsps1_client_config = self.lsps1_client.as_ref().map(|s| s.ldk_client_config.clone()); @@ -255,8 +256,6 @@ where Arc::clone(&self.keys_manager), Arc::clone(&self.keys_manager), Arc::clone(&self.channel_manager), - Some(Arc::clone(&self.chain_source)), - None, Arc::clone(&self.kv_store), Arc::clone(&self.tx_broadcaster), liquidity_service_config, diff --git a/src/types.rs b/src/types.rs index c5ff07756..b2cf35d59 100644 --- a/src/types.rs +++ b/src/types.rs @@ -231,7 +231,6 @@ pub(crate) type LiquidityManager = lightning_liquidity::LiquidityManager< Arc, Arc, Arc, - Arc, Arc, DefaultTimeProvider, Arc, From 64512f6a27659565f6a837afa143ec4a8c3a9921 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 26 Feb 2026 12:55:30 +0100 Subject: [PATCH 2/8] Add core `PaymentMetadataStore` types Introduce `MetadataId`, `PaymentMetadataKind`, `PaymentMetadataEntry`, and `PaymentMetadataEntryUpdate` in a new `payment/metadata_store` module. These types form the foundation of a persistence-backed metadata store keyed by an opaque `MetadataId` that decouples metadata lifecycle from `PaymentId`. `PaymentMetadataKind` can hold `Bolt11Invoice`s, BOLT12 `Offer`s/ `Refund`s, and `LSPFeeLimits`. All variants use `impl_writeable_tlv_based_enum!` for serialization, leveraging the new `Writeable`/`Readable` impls added in the previous dependency bump. Generated with the help of AI (Claude Code). Co-Authored-By: HAL 9000 --- src/payment/metadata_store.rs | 115 ++++++++++++++++++++++++++++++++++ src/payment/mod.rs | 1 + 2 files changed, 116 insertions(+) create mode 100644 src/payment/metadata_store.rs diff --git a/src/payment/metadata_store.rs b/src/payment/metadata_store.rs new file mode 100644 index 000000000..6cc49f62f --- /dev/null +++ b/src/payment/metadata_store.rs @@ -0,0 +1,115 @@ +// 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 lightning::impl_writeable_tlv_based; +use lightning::impl_writeable_tlv_based_enum; +use lightning::ln::channelmanager::PaymentId; +use lightning::offers::offer::Offer as LdkOffer; +use lightning::offers::refund::Refund as LdkRefund; +use lightning_invoice::Bolt11Invoice as LdkBolt11Invoice; + +use crate::data_store::{StorableObject, StorableObjectId, StorableObjectUpdate}; +use crate::hex_utils; +use crate::payment::store::LSPFeeLimits; + +/// An opaque identifier for a payment metadata entry. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub(crate) struct MetadataId { + pub id: [u8; 32], +} + +impl StorableObjectId for MetadataId { + fn encode_to_hex_str(&self) -> String { + hex_utils::to_string(&self.id) + } +} + +impl_writeable_tlv_based!(MetadataId, { (0, id, required) }); + +/// The kind of metadata stored in a [`PaymentMetadataEntry`]. +#[derive(Clone, Debug)] +pub(crate) enum PaymentMetadataKind { + /// A BOLT 11 invoice. + Bolt11Invoice { invoice: LdkBolt11Invoice }, + /// A BOLT 12 offer. + Bolt12Offer { offer: LdkOffer }, + /// A BOLT 12 refund. + Bolt12Refund { refund: LdkRefund }, + /// LSP fee limits for a JIT channel payment. + LSPFeeLimits { limits: LSPFeeLimits }, +} + +impl_writeable_tlv_based_enum!(PaymentMetadataKind, + (0, Bolt11Invoice) => { + (0, invoice, required), + }, + (2, Bolt12Offer) => { + (0, offer, required), + }, + (4, Bolt12Refund) => { + (0, refund, required), + }, + (6, LSPFeeLimits) => { + (0, limits, required), + } +); + +/// A metadata entry associating a [`PaymentMetadataKind`] with one or more payments. +#[derive(Clone, Debug)] +pub(crate) struct PaymentMetadataEntry { + /// The unique identifier for this metadata entry. + pub id: MetadataId, + /// The kind of metadata. + pub kind: PaymentMetadataKind, + /// The payment IDs associated with this metadata. + pub payment_ids: Vec, +} + +impl_writeable_tlv_based!(PaymentMetadataEntry, { + (0, id, required), + (2, kind, required), + (4, payment_ids, optional_vec), +}); + +/// An update to a [`PaymentMetadataEntry`]. +#[derive(Clone, Debug)] +pub(crate) struct PaymentMetadataEntryUpdate { + pub id: MetadataId, + pub payment_ids: Option>, +} + +impl StorableObject for PaymentMetadataEntry { + type Id = MetadataId; + type Update = PaymentMetadataEntryUpdate; + + fn id(&self) -> Self::Id { + self.id + } + + fn update(&mut self, update: Self::Update) -> bool { + let mut updated = false; + + if let Some(new_payment_ids) = update.payment_ids { + if self.payment_ids != new_payment_ids { + self.payment_ids = new_payment_ids; + updated = true; + } + } + + updated + } + + fn to_update(&self) -> Self::Update { + PaymentMetadataEntryUpdate { id: self.id, payment_ids: Some(self.payment_ids.clone()) } + } +} + +impl StorableObjectUpdate for PaymentMetadataEntryUpdate { + fn id(&self) -> ::Id { + self.id + } +} diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 42b5aff3b..3429bd785 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod asynchronous; mod bolt11; mod bolt12; +pub(crate) mod metadata_store; mod onchain; pub(crate) mod pending_payment_store; mod spontaneous; From 6da535492b22fe7733f5fdf72088bd260c16cc0d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 26 Feb 2026 12:56:27 +0100 Subject: [PATCH 3/8] Add `PaymentMetadataStore` wrapper and persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the `PaymentMetadataStore` struct that wraps a `DataStore` with a reverse index from `PaymentId` to `MetadataId`. This enables efficient lookup of metadata entries by payment ID. API methods: - `insert` / `get` / `remove` — basic CRUD with reverse index upkeep - `add_payment_id` — associate additional payment IDs with an entry - `get_for_payment_id` — reverse index lookup - `get_lsp_fee_limits_for_payment_id` — convenience for LSP fee checks - `remove_payment_id` — clean up reverse index when payments are removed Also add the `payment_metadata` namespace constants in `io/mod.rs`. Generated with the help of AI (Claude Code). Co-Authored-By: HAL 9000 --- src/io/mod.rs | 4 + src/payment/metadata_store.rs | 155 +++++++++++++++++++++++++++++++++- 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/src/io/mod.rs b/src/io/mod.rs index e080d39f7..439193027 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -82,3 +82,7 @@ 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 = ""; + +/// The payment metadata will be persisted under this prefix. +pub(crate) const PAYMENT_METADATA_PERSISTENCE_PRIMARY_NAMESPACE: &str = "payment_metadata"; +pub(crate) const PAYMENT_METADATA_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; diff --git a/src/payment/metadata_store.rs b/src/payment/metadata_store.rs index 6cc49f62f..7da9f8799 100644 --- a/src/payment/metadata_store.rs +++ b/src/payment/metadata_store.rs @@ -5,6 +5,9 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + use lightning::impl_writeable_tlv_based; use lightning::impl_writeable_tlv_based_enum; use lightning::ln::channelmanager::PaymentId; @@ -12,9 +15,16 @@ use lightning::offers::offer::Offer as LdkOffer; use lightning::offers::refund::Refund as LdkRefund; use lightning_invoice::Bolt11Invoice as LdkBolt11Invoice; -use crate::data_store::{StorableObject, StorableObjectId, StorableObjectUpdate}; +use crate::data_store::{DataStore, StorableObject, StorableObjectId, StorableObjectUpdate}; use crate::hex_utils; +use crate::io::{ + PAYMENT_METADATA_PERSISTENCE_PRIMARY_NAMESPACE, + PAYMENT_METADATA_PERSISTENCE_SECONDARY_NAMESPACE, +}; +use crate::logger::{log_error, LdkLogger, Logger}; use crate::payment::store::LSPFeeLimits; +use crate::types::DynStore; +use crate::Error; /// An opaque identifier for a payment metadata entry. #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] @@ -113,3 +123,146 @@ impl StorableObjectUpdate for PaymentMetadataEntryUpdate { self.id } } + +/// A store for payment metadata, backed by a [`DataStore`]. +/// +/// Maintains a reverse index from [`PaymentId`] to [`MetadataId`] for efficient lookups. +pub(crate) struct PaymentMetadataStore { + inner: DataStore>, + reverse_index: Mutex>>, +} + +impl PaymentMetadataStore { + pub(crate) fn new( + entries: Vec, kv_store: Arc, logger: Arc, + ) -> Self { + let mut reverse_index: HashMap> = HashMap::new(); + for entry in &entries { + for payment_id in &entry.payment_ids { + reverse_index.entry(*payment_id).or_default().insert(entry.id); + } + } + + let inner = DataStore::new( + entries, + PAYMENT_METADATA_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), + PAYMENT_METADATA_PERSISTENCE_SECONDARY_NAMESPACE.to_string(), + kv_store, + logger, + ); + + Self { inner, reverse_index: Mutex::new(reverse_index) } + } + + /// Insert a new metadata entry and update the reverse index. + pub(crate) fn insert(&self, entry: PaymentMetadataEntry) -> Result { + let id = entry.id; + let payment_ids = entry.payment_ids.clone(); + + self.inner.insert(entry)?; + + let mut locked_index = self.reverse_index.lock().unwrap(); + for payment_id in payment_ids { + locked_index.entry(payment_id).or_default().insert(id); + } + + Ok(id) + } + + /// Associate an additional [`PaymentId`] with an existing metadata entry. + pub(crate) fn add_payment_id( + &self, metadata_id: MetadataId, payment_id: PaymentId, + ) -> Result<(), Error> { + if let Some(mut entry) = self.inner.get(&metadata_id) { + if !entry.payment_ids.contains(&payment_id) { + entry.payment_ids.push(payment_id); + let update = PaymentMetadataEntryUpdate { + id: metadata_id, + payment_ids: Some(entry.payment_ids), + }; + self.inner.update(update)?; + self.reverse_index + .lock() + .unwrap() + .entry(payment_id) + .or_default() + .insert(metadata_id); + } + Ok(()) + } else { + Err(Error::PersistenceFailed) + } + } + + /// Get a metadata entry by its ID. + pub(crate) fn get(&self, metadata_id: &MetadataId) -> Option { + self.inner.get(metadata_id) + } + + /// Get all metadata entries associated with a given payment ID. + pub(crate) fn get_for_payment_id(&self, payment_id: &PaymentId) -> Vec { + let locked_index = self.reverse_index.lock().unwrap(); + if let Some(metadata_ids) = locked_index.get(payment_id) { + metadata_ids.iter().filter_map(|mid| self.inner.get(mid)).collect() + } else { + Vec::new() + } + } + + /// Convenience method to get the [`LSPFeeLimits`] for a given payment ID, if any. + pub(crate) fn get_lsp_fee_limits_for_payment_id( + &self, payment_id: &PaymentId, + ) -> Option { + let entries = self.get_for_payment_id(payment_id); + for entry in entries { + if let PaymentMetadataKind::LSPFeeLimits { limits } = entry.kind { + return Some(limits); + } + } + None + } + + /// Remove a metadata entry and clean up the reverse index. + pub(crate) fn remove(&self, metadata_id: &MetadataId) -> Result<(), Error> { + if let Some(entry) = self.inner.get(metadata_id) { + let mut locked_index = self.reverse_index.lock().unwrap(); + for payment_id in &entry.payment_ids { + if let Some(set) = locked_index.get_mut(payment_id) { + set.remove(metadata_id); + if set.is_empty() { + locked_index.remove(payment_id); + } + } + } + } + self.inner.remove(metadata_id) + } + + /// Remove a [`PaymentId`] from all associated metadata entries. + /// + /// This should be called when a payment store entry is removed to keep the reverse index + /// consistent. If a metadata entry's `payment_ids` becomes empty after removal, it is + /// **not** automatically deleted (the metadata may still be useful). + pub(crate) fn remove_payment_id(&self, payment_id: &PaymentId) -> Result<(), Error> { + let metadata_ids = { + let mut locked_index = self.reverse_index.lock().unwrap(); + match locked_index.remove(payment_id) { + Some(ids) => ids, + None => return Ok(()), + } + }; + + for metadata_id in metadata_ids { + if let Some(mut entry) = self.inner.get(&metadata_id) { + entry.payment_ids.retain(|id| id != payment_id); + let update = PaymentMetadataEntryUpdate { + id: metadata_id, + payment_ids: Some(entry.payment_ids), + }; + self.inner.update(update)?; + } + } + + Ok(()) + } +} From 0478d42c61a06130278caa96dfaf8f0a77974f11 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 26 Feb 2026 13:00:16 +0100 Subject: [PATCH 4/8] Add unit tests for `PaymentMetadataStore` Add comprehensive tests covering: - Serialization roundtrip for `LSPFeeLimits` variant - Basic insert / get / remove lifecycle - Reverse index correctness with multiple payment IDs - `add_payment_id` adds associations correctly - `get_lsp_fee_limits_for_payment_id` returns correct limits - `remove_payment_id` cleans up the reverse index - Persistence roundtrip (insert, reconstruct from same KVStore) Generated with the help of AI (Claude Code). Co-Authored-By: HAL 9000 --- src/payment/metadata_store.rs | 249 +++++++++++++++++++++++++++++++++- 1 file changed, 248 insertions(+), 1 deletion(-) diff --git a/src/payment/metadata_store.rs b/src/payment/metadata_store.rs index 7da9f8799..ef0d3f994 100644 --- a/src/payment/metadata_store.rs +++ b/src/payment/metadata_store.rs @@ -21,7 +21,7 @@ use crate::io::{ PAYMENT_METADATA_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_METADATA_PERSISTENCE_SECONDARY_NAMESPACE, }; -use crate::logger::{log_error, LdkLogger, Logger}; +use crate::logger::Logger; use crate::payment::store::LSPFeeLimits; use crate::types::DynStore; use crate::Error; @@ -266,3 +266,250 @@ impl PaymentMetadataStore { Ok(()) } } + +#[cfg(test)] +mod tests { + use lightning::util::ser::{Readable, Writeable}; + use lightning::util::test_utils::TestLogger; + + use super::*; + use crate::io::test_utils::InMemoryStore; + use crate::types::DynStoreWrapper; + + fn make_store() -> (Arc, Arc) { + let kv_store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(Logger::new_log_facade()); + (kv_store, logger) + } + + fn make_metadata_id(val: u8) -> MetadataId { + MetadataId { id: [val; 32] } + } + + fn make_payment_id(val: u8) -> PaymentId { + PaymentId([val; 32]) + } + + #[test] + fn serialization_roundtrip_lsp_fee_limits() { + let limits = LSPFeeLimits { + max_total_opening_fee_msat: Some(1000), + max_proportional_opening_fee_ppm_msat: Some(500), + }; + let kind = PaymentMetadataKind::LSPFeeLimits { limits }; + let entry = PaymentMetadataEntry { + id: make_metadata_id(1), + kind, + payment_ids: vec![make_payment_id(10), make_payment_id(11)], + }; + + let encoded = entry.encode(); + let decoded = PaymentMetadataEntry::read(&mut &*encoded).unwrap(); + + assert_eq!(entry.id, decoded.id); + assert_eq!(entry.payment_ids, decoded.payment_ids); + match decoded.kind { + PaymentMetadataKind::LSPFeeLimits { limits: decoded_limits } => { + assert_eq!(limits, decoded_limits); + }, + _ => panic!("Expected LSPFeeLimits variant"), + } + } + + #[test] + fn insert_get_remove_lifecycle() { + let (kv_store, logger) = make_store(); + let store = PaymentMetadataStore::new(Vec::new(), kv_store, logger); + + let mid = make_metadata_id(1); + let pid = make_payment_id(10); + let limits = LSPFeeLimits { + max_total_opening_fee_msat: Some(2000), + max_proportional_opening_fee_ppm_msat: None, + }; + let entry = PaymentMetadataEntry { + id: mid, + kind: PaymentMetadataKind::LSPFeeLimits { limits }, + payment_ids: vec![pid], + }; + + // Insert + let returned_id = store.insert(entry).unwrap(); + assert_eq!(returned_id, mid); + + // Get + let retrieved = store.get(&mid).unwrap(); + assert_eq!(retrieved.id, mid); + assert_eq!(retrieved.payment_ids, vec![pid]); + + // Get by payment ID + let entries = store.get_for_payment_id(&pid); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].id, mid); + + // Remove + store.remove(&mid).unwrap(); + assert!(store.get(&mid).is_none()); + assert!(store.get_for_payment_id(&pid).is_empty()); + } + + #[test] + fn reverse_index_multiple_payment_ids() { + let (kv_store, logger) = make_store(); + let store = PaymentMetadataStore::new(Vec::new(), kv_store, logger); + + let mid = make_metadata_id(1); + let pid1 = make_payment_id(10); + let pid2 = make_payment_id(11); + let limits = LSPFeeLimits { + max_total_opening_fee_msat: Some(3000), + max_proportional_opening_fee_ppm_msat: None, + }; + let entry = PaymentMetadataEntry { + id: mid, + kind: PaymentMetadataKind::LSPFeeLimits { limits }, + payment_ids: vec![pid1, pid2], + }; + + store.insert(entry).unwrap(); + + // Both payment IDs should find the entry + assert_eq!(store.get_for_payment_id(&pid1).len(), 1); + assert_eq!(store.get_for_payment_id(&pid2).len(), 1); + + // Non-existent payment ID + let pid3 = make_payment_id(99); + assert!(store.get_for_payment_id(&pid3).is_empty()); + } + + #[test] + fn add_payment_id_updates_reverse_index() { + let (kv_store, logger) = make_store(); + let store = PaymentMetadataStore::new(Vec::new(), kv_store, logger); + + let mid = make_metadata_id(1); + let pid1 = make_payment_id(10); + let limits = LSPFeeLimits { + max_total_opening_fee_msat: Some(4000), + max_proportional_opening_fee_ppm_msat: None, + }; + let entry = PaymentMetadataEntry { + id: mid, + kind: PaymentMetadataKind::LSPFeeLimits { limits }, + payment_ids: vec![pid1], + }; + + store.insert(entry).unwrap(); + + let pid2 = make_payment_id(11); + store.add_payment_id(mid, pid2).unwrap(); + + // Both should now resolve + assert_eq!(store.get_for_payment_id(&pid1).len(), 1); + assert_eq!(store.get_for_payment_id(&pid2).len(), 1); + + // The entry itself should have both payment IDs + let retrieved = store.get(&mid).unwrap(); + assert_eq!(retrieved.payment_ids.len(), 2); + assert!(retrieved.payment_ids.contains(&pid1)); + assert!(retrieved.payment_ids.contains(&pid2)); + } + + #[test] + fn get_lsp_fee_limits_for_payment_id() { + let (kv_store, logger) = make_store(); + let store = PaymentMetadataStore::new(Vec::new(), kv_store, logger); + + let mid = make_metadata_id(1); + let pid = make_payment_id(10); + let limits = LSPFeeLimits { + max_total_opening_fee_msat: Some(5000), + max_proportional_opening_fee_ppm_msat: Some(100), + }; + let entry = PaymentMetadataEntry { + id: mid, + kind: PaymentMetadataKind::LSPFeeLimits { limits }, + payment_ids: vec![pid], + }; + + store.insert(entry).unwrap(); + + let retrieved_limits = store.get_lsp_fee_limits_for_payment_id(&pid).unwrap(); + assert_eq!(retrieved_limits, limits); + + // Non-existent payment ID + let pid2 = make_payment_id(99); + assert!(store.get_lsp_fee_limits_for_payment_id(&pid2).is_none()); + } + + #[test] + fn remove_payment_id_cleans_reverse_index() { + let (kv_store, logger) = make_store(); + let store = PaymentMetadataStore::new(Vec::new(), kv_store, logger); + + let mid = make_metadata_id(1); + let pid1 = make_payment_id(10); + let pid2 = make_payment_id(11); + let limits = LSPFeeLimits { + max_total_opening_fee_msat: Some(6000), + max_proportional_opening_fee_ppm_msat: None, + }; + let entry = PaymentMetadataEntry { + id: mid, + kind: PaymentMetadataKind::LSPFeeLimits { limits }, + payment_ids: vec![pid1, pid2], + }; + + store.insert(entry).unwrap(); + + // Remove pid1 + store.remove_payment_id(&pid1).unwrap(); + + // pid1 should no longer resolve + assert!(store.get_for_payment_id(&pid1).is_empty()); + + // pid2 should still resolve + assert_eq!(store.get_for_payment_id(&pid2).len(), 1); + + // The entry should still exist but with only pid2 + let retrieved = store.get(&mid).unwrap(); + assert_eq!(retrieved.payment_ids, vec![pid2]); + } + + #[test] + fn persistence_roundtrip() { + let kv_store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + + let mid = make_metadata_id(1); + let pid = make_payment_id(10); + let limits = LSPFeeLimits { + max_total_opening_fee_msat: Some(7000), + max_proportional_opening_fee_ppm_msat: None, + }; + let entry = PaymentMetadataEntry { + id: mid, + kind: PaymentMetadataKind::LSPFeeLimits { limits }, + payment_ids: vec![pid], + }; + + // Insert via first store instance + { + let logger = Arc::new(Logger::new_log_facade()); + let store = PaymentMetadataStore::new(Vec::new(), Arc::clone(&kv_store), logger); + store.insert(entry.clone()).unwrap(); + } + + // Reconstruct from same KVStore — simulate reading persisted entries + { + let logger = Arc::new(Logger::new_log_facade()); + let store = PaymentMetadataStore::new(vec![entry], Arc::clone(&kv_store), logger); + let retrieved = store.get(&mid).unwrap(); + assert_eq!(retrieved.id, mid); + assert_eq!(retrieved.payment_ids, vec![pid]); + + // Reverse index should work + let entries = store.get_for_payment_id(&pid); + assert_eq!(entries.len(), 1); + } + } +} From 8ee0d43e0652b321bdd98653c3ac009856352ba4 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 26 Feb 2026 14:04:19 +0100 Subject: [PATCH 5/8] Wire `PaymentMetadataStore` into `Builder`, `Node`, `Bolt11Payment`, and `EventHandler` Add `read_payment_metadata` to the startup `tokio::join!` call in the builder to load persisted metadata entries. Construct an `Arc` and thread it through to `Node`, `Bolt11Payment` (along with `KeysManager`), and `EventHandler`. Update `Node::remove_payment` to also call `payment_metadata_store.remove_payment_id` so the reverse index stays consistent when payment store entries are removed. Generated with the assistance of AI tools. Co-Authored-By: HAL 9000 --- src/builder.rs | 23 ++++++++++--- src/event.rs | 12 ++++--- src/io/utils.rs | 78 +++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 11 +++++- src/payment/bolt11.rs | 10 ++++-- 5 files changed, 123 insertions(+), 11 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 1353fe808..81d0c79bc 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -56,8 +56,8 @@ use crate::gossip::GossipSource; use crate::io::sqlite_store::SqliteStore; use crate::io::utils::{ read_event_queue, read_external_pathfinding_scores_from_cache, read_network_graph, - read_node_metrics, read_output_sweeper, read_payments, read_peer_info, read_pending_payments, - read_scorer, write_node_metrics, + read_node_metrics, read_output_sweeper, read_payment_metadata, read_payments, read_peer_info, + read_pending_payments, read_scorer, write_node_metrics, }; use crate::io::vss_store::VssStoreBuilder; use crate::io::{ @@ -71,6 +71,7 @@ use crate::liquidity::{ use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger}; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; +use crate::payment::metadata_store::PaymentMetadataStore; use crate::peer_store::PeerStore; use crate::runtime::{Runtime, RuntimeSpawner}; use crate::tx_broadcaster::TransactionBroadcaster; @@ -1083,12 +1084,13 @@ fn build_with_store_internal( let kv_store_ref = Arc::clone(&kv_store); let logger_ref = Arc::clone(&logger); - let (payment_store_res, node_metris_res, pending_payment_store_res) = + let (payment_store_res, node_metris_res, pending_payment_store_res, payment_metadata_res) = runtime.block_on(async move { tokio::join!( read_payments(&*kv_store_ref, Arc::clone(&logger_ref)), read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)), - read_pending_payments(&*kv_store_ref, Arc::clone(&logger_ref)) + read_pending_payments(&*kv_store_ref, Arc::clone(&logger_ref)), + read_payment_metadata(&*kv_store_ref, Arc::clone(&logger_ref)) ) }); @@ -1119,6 +1121,18 @@ fn build_with_store_internal( }, }; + let payment_metadata_store = match payment_metadata_res { + Ok(metadata_entries) => Arc::new(PaymentMetadataStore::new( + metadata_entries, + Arc::clone(&kv_store), + Arc::clone(&logger), + )), + Err(e) => { + log_error!(logger, "Failed to read payment metadata from store: {}", e); + return Err(BuildError::ReadFailed); + }, + }; + let (chain_source, chain_tip_opt) = match chain_data_source_config { Some(ChainDataSourceConfig::Esplora { server_url, headers, sync_config }) => { let sync_config = sync_config.unwrap_or(EsploraSyncConfig::default()); @@ -1809,6 +1823,7 @@ fn build_with_store_internal( scorer, peer_store, payment_store, + payment_metadata_store, is_running, node_metrics, om_mailbox, diff --git a/src/event.rs b/src/event.rs index a4dcc8cf3..393076275 100644 --- a/src/event.rs +++ b/src/event.rs @@ -45,6 +45,7 @@ use crate::liquidity::LiquiditySource; use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; use crate::payment::asynchronous::static_invoice_store::StaticInvoiceStore; +use crate::payment::metadata_store::PaymentMetadataStore; use crate::payment::store::{ PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, }; @@ -489,6 +490,7 @@ where network_graph: Arc, liquidity_source: Option>>>, payment_store: Arc, + payment_metadata_store: Arc, peer_store: Arc>, keys_manager: Arc, runtime: Arc, @@ -509,10 +511,11 @@ where channel_manager: Arc, connection_manager: Arc>, output_sweeper: Arc, network_graph: Arc, liquidity_source: Option>>>, - payment_store: Arc, peer_store: Arc>, - keys_manager: Arc, static_invoice_store: Option, - onion_messenger: Arc, om_mailbox: Option>, - runtime: Arc, logger: L, config: Arc, + payment_store: Arc, payment_metadata_store: Arc, + peer_store: Arc>, keys_manager: Arc, + static_invoice_store: Option, onion_messenger: Arc, + om_mailbox: Option>, runtime: Arc, logger: L, + config: Arc, ) -> Self { Self { event_queue, @@ -524,6 +527,7 @@ where network_graph, liquidity_source, payment_store, + payment_metadata_store, peer_store, keys_manager, logger, diff --git a/src/io/utils.rs b/src/io/utils.rs index eef71ec0b..402b83758 100644 --- a/src/io/utils.rs +++ b/src/io/utils.rs @@ -44,6 +44,7 @@ use crate::io::{ NODE_METRICS_KEY, NODE_METRICS_PRIMARY_NAMESPACE, NODE_METRICS_SECONDARY_NAMESPACE, }; use crate::logger::{log_error, LdkLogger, Logger}; +use crate::payment::metadata_store::PaymentMetadataEntry; use crate::payment::PendingPaymentDetails; use crate::peer_store::PeerStore; use crate::types::{Broadcaster, DynStore, KeysManager, Sweeper}; @@ -298,6 +299,83 @@ where Ok(res) } +/// Read previously persisted payment metadata from the store. +pub(crate) async fn read_payment_metadata( + kv_store: &DynStore, logger: L, +) -> Result, std::io::Error> +where + L::Target: LdkLogger, +{ + let mut res = Vec::new(); + + let mut stored_keys = KVStore::list( + &*kv_store, + PAYMENT_METADATA_PERSISTENCE_PRIMARY_NAMESPACE, + PAYMENT_METADATA_PERSISTENCE_SECONDARY_NAMESPACE, + ) + .await?; + + const BATCH_SIZE: usize = 50; + + let mut set = tokio::task::JoinSet::new(); + + // Fill JoinSet with tasks if possible + while set.len() < BATCH_SIZE && !stored_keys.is_empty() { + if let Some(next_key) = stored_keys.pop() { + let fut = KVStore::read( + &*kv_store, + PAYMENT_METADATA_PERSISTENCE_PRIMARY_NAMESPACE, + PAYMENT_METADATA_PERSISTENCE_SECONDARY_NAMESPACE, + &next_key, + ); + set.spawn(fut); + debug_assert!(set.len() <= BATCH_SIZE); + } + } + + while let Some(read_res) = set.join_next().await { + // Exit early if we get an IO error. + let reader = read_res + .map_err(|e| { + log_error!(logger, "Failed to read PaymentMetadataEntry: {}", e); + set.abort_all(); + e + })? + .map_err(|e| { + log_error!(logger, "Failed to read PaymentMetadataEntry: {}", e); + set.abort_all(); + e + })?; + + // Refill set for every finished future, if we still have something to do. + if let Some(next_key) = stored_keys.pop() { + let fut = KVStore::read( + &*kv_store, + PAYMENT_METADATA_PERSISTENCE_PRIMARY_NAMESPACE, + PAYMENT_METADATA_PERSISTENCE_SECONDARY_NAMESPACE, + &next_key, + ); + set.spawn(fut); + debug_assert!(set.len() <= BATCH_SIZE); + } + + // Handle result. + let entry = PaymentMetadataEntry::read(&mut &*reader).map_err(|e| { + log_error!(logger, "Failed to deserialize PaymentMetadataEntry: {}", e); + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Failed to deserialize PaymentMetadataEntry", + ) + })?; + res.push(entry); + } + + debug_assert!(set.is_empty()); + debug_assert!(stored_keys.is_empty()); + + Ok(res) +} + /// Read `OutputSweeper` state from the store. pub(crate) async fn read_output_sweeper( broadcaster: Arc, fee_estimator: Arc, diff --git a/src/lib.rs b/src/lib.rs index 1b93cb6e9..119c55ad8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -151,6 +151,7 @@ use liquidity::{LSPS1Liquidity, LiquiditySource}; use logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use payment::asynchronous::om_mailbox::OnionMessageMailbox; use payment::asynchronous::static_invoice_store::StaticInvoiceStore; +use payment::metadata_store::PaymentMetadataStore; use payment::{ Bolt11Payment, Bolt12Payment, OnchainPayment, PaymentDetails, SpontaneousPayment, UnifiedPayment, @@ -220,6 +221,7 @@ pub struct Node { scorer: Arc>, peer_store: Arc>>, payment_store: Arc, + payment_metadata_store: Arc, is_running: Arc>, node_metrics: Arc>, om_mailbox: Option>, @@ -571,6 +573,7 @@ impl Node { Arc::clone(&self.network_graph), self.liquidity_source.clone(), Arc::clone(&self.payment_store), + Arc::clone(&self.payment_metadata_store), Arc::clone(&self.peer_store), Arc::clone(&self.keys_manager), static_invoice_store, @@ -856,6 +859,8 @@ impl Node { Arc::clone(&self.connection_manager), self.liquidity_source.clone(), Arc::clone(&self.payment_store), + Arc::clone(&self.payment_metadata_store), + Arc::clone(&self.keys_manager), Arc::clone(&self.peer_store), Arc::clone(&self.config), Arc::clone(&self.is_running), @@ -874,6 +879,8 @@ impl Node { Arc::clone(&self.connection_manager), self.liquidity_source.clone(), Arc::clone(&self.payment_store), + Arc::clone(&self.payment_metadata_store), + Arc::clone(&self.keys_manager), Arc::clone(&self.peer_store), Arc::clone(&self.config), Arc::clone(&self.is_running), @@ -1552,7 +1559,9 @@ impl Node { /// Remove the payment with the given id from the store. pub fn remove_payment(&self, payment_id: &PaymentId) -> Result<(), Error> { - self.payment_store.remove(&payment_id) + self.payment_store.remove(&payment_id)?; + self.payment_metadata_store.remove_payment_id(payment_id)?; + Ok(()) } /// Retrieves an overview of all known balances. diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 56eb2f20b..8359ae18f 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -30,13 +30,14 @@ use crate::error::Error; use crate::ffi::{maybe_deref, maybe_try_convert_enum, maybe_wrap}; use crate::liquidity::LiquiditySource; use crate::logger::{log_error, log_info, LdkLogger, Logger}; +use crate::payment::metadata_store::PaymentMetadataStore; use crate::payment::store::{ LSPFeeLimits, PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, }; use crate::peer_store::{PeerInfo, PeerStore}; use crate::runtime::Runtime; -use crate::types::{ChannelManager, PaymentStore}; +use crate::types::{ChannelManager, KeysManager, PaymentStore}; #[cfg(not(feature = "uniffi"))] type Bolt11Invoice = LdkBolt11Invoice; @@ -60,6 +61,8 @@ pub struct Bolt11Payment { connection_manager: Arc>>, liquidity_source: Option>>>, payment_store: Arc, + payment_metadata_store: Arc, + keys_manager: Arc, peer_store: Arc>>, config: Arc, is_running: Arc>, @@ -71,7 +74,8 @@ impl Bolt11Payment { runtime: Arc, channel_manager: Arc, connection_manager: Arc>>, liquidity_source: Option>>>, - payment_store: Arc, peer_store: Arc>>, + payment_store: Arc, payment_metadata_store: Arc, + keys_manager: Arc, peer_store: Arc>>, config: Arc, is_running: Arc>, logger: Arc, ) -> Self { Self { @@ -80,6 +84,8 @@ impl Bolt11Payment { connection_manager, liquidity_source, payment_store, + payment_metadata_store, + keys_manager, peer_store, config, is_running, From 7843a8dcf8e877f397f80ff8c15211c829f564bc Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 26 Feb 2026 14:05:16 +0100 Subject: [PATCH 6/8] Store `LSPFeeLimits` in metadata store for JIT channel payments In `receive_via_jit_channel_inner`, insert a `PaymentMetadataKind::LSPFeeLimits` entry into the `PaymentMetadataStore` so JIT channel fee limits are persisted independently of the `PaymentStore` entry. In the `PaymentClaimable` event handler, add a fallback path that checks the metadata store for LSP fee limits when the `PaymentKind` doesn't carry them directly. Generated with the assistance of AI tools. Co-Authored-By: HAL 9000 --- src/event.rs | 16 +++++++++++++++- src/payment/bolt11.rs | 15 ++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/event.rs b/src/event.rs index 393076275..bdfe3d781 100644 --- a/src/event.rs +++ b/src/event.rs @@ -712,7 +712,21 @@ where }) .unwrap_or(0) }, - _ => 0, + _ => { + // Fallback: check the metadata store for LSP fee limits. + self.payment_metadata_store + .get_lsp_fee_limits_for_payment_id(&payment_id) + .and_then(|limits| { + limits.max_total_opening_fee_msat.or_else(|| { + limits.max_proportional_opening_fee_ppm_msat.and_then( + |max_prop_fee| { + compute_opening_fee(amount_msat, 0, max_prop_fee) + }, + ) + }) + }) + .unwrap_or(0) + }, }; if counterparty_skimmed_fee_msat > max_total_opening_fee_msat { diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 8359ae18f..72a61cc1c 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -18,6 +18,7 @@ use lightning::ln::channelmanager::{ }; use lightning::ln::outbound_payment::{Bolt11PaymentError, Retry, RetryableSendFailure}; use lightning::routing::router::{PaymentParameters, RouteParameters, RouteParametersConfig}; +use lightning::sign::EntropySource; use lightning_invoice::{ Bolt11Invoice as LdkBolt11Invoice, Bolt11InvoiceDescription as LdkBolt11InvoiceDescription, }; @@ -30,7 +31,9 @@ use crate::error::Error; use crate::ffi::{maybe_deref, maybe_try_convert_enum, maybe_wrap}; use crate::liquidity::LiquiditySource; use crate::logger::{log_error, log_info, LdkLogger, Logger}; -use crate::payment::metadata_store::PaymentMetadataStore; +use crate::payment::metadata_store::{ + MetadataId, PaymentMetadataEntry, PaymentMetadataKind, PaymentMetadataStore, +}; use crate::payment::store::{ LSPFeeLimits, PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, @@ -734,6 +737,16 @@ impl Bolt11Payment { max_proportional_opening_fee_ppm_msat: lsp_prop_opening_fee, }; let id = PaymentId(payment_hash.0); + + // Store LSP fee limits in the metadata store. + let metadata_id = MetadataId { id: self.keys_manager.get_secure_random_bytes() }; + let metadata_entry = PaymentMetadataEntry { + id: metadata_id, + kind: PaymentMetadataKind::LSPFeeLimits { limits: lsp_fee_limits }, + payment_ids: vec![id], + }; + self.payment_metadata_store.insert(metadata_entry)?; + let preimage = self.channel_manager.get_payment_preimage(payment_hash, payment_secret.clone()).ok(); let kind = PaymentKind::Bolt11Jit { From 894733146d375dd0bf794440890e1f1fa662bf6e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 26 Feb 2026 14:06:02 +0100 Subject: [PATCH 7/8] Stop pre-creating `PaymentStore` entries in `Bolt11Payment` receive methods Remove the eager `PaymentStore` insertions from `receive_inner` and `receive_via_jit_channel_inner`. Inbound payment entries will instead be created on demand by the `EventHandler` when the corresponding LDK events arrive. Outbound payment entries (created by `send` / `send_using_amount`) are kept as before so the sender always has a store record immediately after initiating a payment. Generated with the assistance of AI tools. Co-Authored-By: HAL 9000 --- src/payment/bolt11.rs | 54 +------------------------------------------ 1 file changed, 1 insertion(+), 53 deletions(-) diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 72a61cc1c..9e2bfcdd2 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -512,36 +512,6 @@ impl Bolt11Payment { } }; - let payment_hash = invoice.payment_hash(); - let payment_secret = invoice.payment_secret(); - let id = PaymentId(payment_hash.0); - let preimage = if manual_claim_payment_hash.is_none() { - // If the user hasn't registered a custom payment hash, we're positive ChannelManager - // will know the preimage at this point. - let res = self - .channel_manager - .get_payment_preimage(payment_hash, payment_secret.clone()) - .ok(); - debug_assert!(res.is_some(), "We just let ChannelManager create an inbound payment, it can't have forgotten the preimage by now."); - res - } else { - None - }; - let kind = PaymentKind::Bolt11 { - hash: payment_hash, - preimage, - secret: Some(payment_secret.clone()), - }; - let payment = PaymentDetails::new( - id, - kind, - amount_msat, - None, - PaymentDirection::Inbound, - PaymentStatus::Pending, - ); - self.payment_store.insert(payment)?; - Ok(invoice) } @@ -729,16 +699,13 @@ impl Bolt11Payment { } })?; - // Register payment in payment store. + // Store LSP fee limits in the metadata store. let payment_hash = invoice.payment_hash(); - let payment_secret = invoice.payment_secret(); let lsp_fee_limits = LSPFeeLimits { max_total_opening_fee_msat: lsp_total_opening_fee, max_proportional_opening_fee_ppm_msat: lsp_prop_opening_fee, }; let id = PaymentId(payment_hash.0); - - // Store LSP fee limits in the metadata store. let metadata_id = MetadataId { id: self.keys_manager.get_secure_random_bytes() }; let metadata_entry = PaymentMetadataEntry { id: metadata_id, @@ -747,25 +714,6 @@ impl Bolt11Payment { }; self.payment_metadata_store.insert(metadata_entry)?; - let preimage = - self.channel_manager.get_payment_preimage(payment_hash, payment_secret.clone()).ok(); - let kind = PaymentKind::Bolt11Jit { - hash: payment_hash, - preimage, - secret: Some(payment_secret.clone()), - counterparty_skimmed_fee_msat: None, - lsp_fee_limits, - }; - let payment = PaymentDetails::new( - id, - kind, - amount_msat, - None, - PaymentDirection::Inbound, - PaymentStatus::Pending, - ); - self.payment_store.insert(payment)?; - // Persist LSP peer to make sure we reconnect on restart. self.peer_store.add_peer(peer_info)?; From bb4904fab1c9e42a44a934a4cb870392aecb9cab Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 26 Feb 2026 14:06:59 +0100 Subject: [PATCH 8/8] Update `EventHandler` to create inbound `PaymentStore` entries on demand With receive-side pre-creation removed, the event handler must now create `PaymentStore` entries when it first encounters an inbound payment. In the `PaymentClaimable` handler, when a `Bolt11InvoicePayment` is not found in the store: - Manual-claim path (`preimage == None`): check the metadata store for `LSPFeeLimits`, validate counterparty-skimmed fees, create a `Bolt11Jit` or `Bolt11` entry, and emit `PaymentClaimable`. - Auto-claim path (`preimage == Some`): same fee-limit check and entry creation, then fall through to `claim_funds`. In the `PaymentClaimed` handler, when the update returns `NotFound`, insert a new entry using the payment purpose to determine the kind. Outbound payment handlers (`PaymentSent`, `PaymentFailed`) are unchanged since entries are still pre-created by `send()` / `send_using_amount()`. Generated with the assistance of AI tools. Co-Authored-By: HAL 9000 --- src/event.rs | 293 +++++++++++++++++++++++++++++++++++++++----- tests/common/mod.rs | 13 +- 2 files changed, 273 insertions(+), 33 deletions(-) diff --git a/src/event.rs b/src/event.rs index bdfe3d781..f09e6f110 100644 --- a/src/event.rs +++ b/src/event.rs @@ -821,7 +821,184 @@ where amount_msat, ); let payment_preimage = match purpose { - PaymentPurpose::Bolt11InvoicePayment { payment_preimage, .. } => { + PaymentPurpose::Bolt11InvoicePayment { + payment_preimage, + payment_secret, + .. + } => { + if payment_preimage.is_none() { + // This is a manual-claim (`_for_hash`) payment that was not + // pre-registered in the payment store. Check metadata store for + // LSP fee limits and create the store entry. + let lsp_fee_limits = self + .payment_metadata_store + .get_lsp_fee_limits_for_payment_id(&payment_id); + + if let Some(ref limits) = lsp_fee_limits { + let max_total_opening_fee_msat = limits + .max_total_opening_fee_msat + .or_else(|| { + limits.max_proportional_opening_fee_ppm_msat.and_then( + |max_prop_fee| { + compute_opening_fee(amount_msat, 0, max_prop_fee) + }, + ) + }) + .unwrap_or(0); + + if counterparty_skimmed_fee_msat > max_total_opening_fee_msat { + log_info!( + self.logger, + "Refusing inbound payment with hash {} as the counterparty-withheld fee of {}msat exceeds our limit of {}msat", + hex_utils::to_string(&payment_hash.0), + counterparty_skimmed_fee_msat, + max_total_opening_fee_msat, + ); + self.channel_manager.fail_htlc_backwards(&payment_hash); + return Ok(()); + } + } + + let kind = if lsp_fee_limits.is_some() { + PaymentKind::Bolt11Jit { + hash: payment_hash, + preimage: None, + secret: Some(payment_secret), + counterparty_skimmed_fee_msat: if counterparty_skimmed_fee_msat + > 0 + { + Some(counterparty_skimmed_fee_msat) + } else { + None + }, + lsp_fee_limits: lsp_fee_limits.unwrap(), + } + } else { + PaymentKind::Bolt11 { + hash: payment_hash, + preimage: None, + secret: Some(payment_secret), + } + }; + + let payment = PaymentDetails::new( + payment_id, + kind, + Some(amount_msat), + None, + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + + match self.payment_store.insert(payment) { + Ok(_) => {}, + Err(e) => { + log_error!( + self.logger, + "Failed to insert payment with ID {}: {}", + payment_id, + e + ); + return Err(ReplayEvent()); + }, + } + + let custom_records = onion_fields + .map(|cf| { + cf.custom_tlvs().into_iter().map(|tlv| tlv.into()).collect() + }) + .unwrap_or_default(); + let event = Event::PaymentClaimable { + payment_id, + payment_hash, + claimable_amount_msat: amount_msat, + claim_deadline, + custom_records, + }; + match self.event_queue.add_event(event).await { + Ok(_) => return Ok(()), + Err(e) => { + log_error!(self.logger, "Failed to push to event queue: {}", e); + return Err(ReplayEvent()); + }, + }; + } else { + // Auto-claim path: payment has a preimage but was not + // pre-registered in the store. Check metadata store for + // LSP fee limits and create the store entry before claiming. + let lsp_fee_limits = self + .payment_metadata_store + .get_lsp_fee_limits_for_payment_id(&payment_id); + + if let Some(ref limits) = lsp_fee_limits { + let max_total_opening_fee_msat = limits + .max_total_opening_fee_msat + .or_else(|| { + limits.max_proportional_opening_fee_ppm_msat.and_then( + |max_prop_fee| { + compute_opening_fee(amount_msat, 0, max_prop_fee) + }, + ) + }) + .unwrap_or(0); + + if counterparty_skimmed_fee_msat > max_total_opening_fee_msat { + log_info!( + self.logger, + "Refusing inbound payment with hash {} as the counterparty-withheld fee of {}msat exceeds our limit of {}msat", + hex_utils::to_string(&payment_hash.0), + counterparty_skimmed_fee_msat, + max_total_opening_fee_msat, + ); + self.channel_manager.fail_htlc_backwards(&payment_hash); + return Ok(()); + } + } + + let kind = if lsp_fee_limits.is_some() { + PaymentKind::Bolt11Jit { + hash: payment_hash, + preimage: payment_preimage, + secret: Some(payment_secret), + counterparty_skimmed_fee_msat: if counterparty_skimmed_fee_msat + > 0 + { + Some(counterparty_skimmed_fee_msat) + } else { + None + }, + lsp_fee_limits: lsp_fee_limits.unwrap(), + } + } else { + PaymentKind::Bolt11 { + hash: payment_hash, + preimage: payment_preimage, + secret: Some(payment_secret), + } + }; + + let payment = PaymentDetails::new( + payment_id, + kind, + Some(amount_msat), + None, + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + + match self.payment_store.insert(payment) { + Ok(_) => {}, + Err(e) => { + log_error!( + self.logger, + "Failed to insert payment with ID {}: {}", + payment_id, + e + ); + return Err(ReplayEvent()); + }, + } + } payment_preimage }, PaymentPurpose::Bolt12OfferPayment { @@ -960,43 +1137,85 @@ where amount_msat, ); - let update = match purpose { + let (update, kind_for_insert) = match purpose { PaymentPurpose::Bolt11InvoicePayment { payment_preimage, payment_secret, .. - } => PaymentDetailsUpdate { - preimage: Some(payment_preimage), - secret: Some(Some(payment_secret)), - amount_msat: Some(Some(amount_msat)), - status: Some(PaymentStatus::Succeeded), - ..PaymentDetailsUpdate::new(payment_id) + } => { + let kind = PaymentKind::Bolt11 { + hash: payment_hash, + preimage: payment_preimage, + secret: Some(payment_secret.clone()), + }; + let update = PaymentDetailsUpdate { + preimage: Some(payment_preimage), + secret: Some(Some(payment_secret)), + amount_msat: Some(Some(amount_msat)), + status: Some(PaymentStatus::Succeeded), + ..PaymentDetailsUpdate::new(payment_id) + }; + (update, kind) }, PaymentPurpose::Bolt12OfferPayment { - payment_preimage, payment_secret, .. - } => PaymentDetailsUpdate { - preimage: Some(payment_preimage), - secret: Some(Some(payment_secret)), - amount_msat: Some(Some(amount_msat)), - status: Some(PaymentStatus::Succeeded), - ..PaymentDetailsUpdate::new(payment_id) + payment_preimage, + payment_secret, + payment_context, + .. + } => { + let payer_note = payment_context.invoice_request.payer_note_truncated; + let offer_id = payment_context.offer_id; + let quantity = payment_context.invoice_request.quantity; + let kind = PaymentKind::Bolt12Offer { + hash: Some(payment_hash), + preimage: payment_preimage, + secret: Some(payment_secret.clone()), + offer_id, + payer_note, + quantity, + }; + let update = PaymentDetailsUpdate { + preimage: Some(payment_preimage), + secret: Some(Some(payment_secret)), + amount_msat: Some(Some(amount_msat)), + status: Some(PaymentStatus::Succeeded), + ..PaymentDetailsUpdate::new(payment_id) + }; + (update, kind) }, PaymentPurpose::Bolt12RefundPayment { payment_preimage, payment_secret, .. - } => PaymentDetailsUpdate { - preimage: Some(payment_preimage), - secret: Some(Some(payment_secret)), - amount_msat: Some(Some(amount_msat)), - status: Some(PaymentStatus::Succeeded), - ..PaymentDetailsUpdate::new(payment_id) + } => { + let kind = PaymentKind::Bolt12Refund { + hash: Some(payment_hash), + preimage: payment_preimage, + secret: Some(payment_secret.clone()), + payer_note: None, + quantity: None, + }; + let update = PaymentDetailsUpdate { + preimage: Some(payment_preimage), + secret: Some(Some(payment_secret)), + amount_msat: Some(Some(amount_msat)), + status: Some(PaymentStatus::Succeeded), + ..PaymentDetailsUpdate::new(payment_id) + }; + (update, kind) }, - PaymentPurpose::SpontaneousPayment(preimage) => PaymentDetailsUpdate { - preimage: Some(Some(preimage)), - amount_msat: Some(Some(amount_msat)), - status: Some(PaymentStatus::Succeeded), - ..PaymentDetailsUpdate::new(payment_id) + PaymentPurpose::SpontaneousPayment(preimage) => { + let kind = PaymentKind::Spontaneous { + hash: payment_hash, + preimage: Some(preimage), + }; + let update = PaymentDetailsUpdate { + preimage: Some(Some(preimage)), + amount_msat: Some(Some(amount_msat)), + status: Some(PaymentStatus::Succeeded), + ..PaymentDetailsUpdate::new(payment_id) + }; + (update, kind) }, }; @@ -1006,11 +1225,27 @@ where // be the result of a replayed event. ), Ok(DataStoreUpdateResult::NotFound) => { - log_error!( - self.logger, - "Claimed payment with ID {} couldn't be found in store", + // Payment was auto-claimed without a prior store entry. + let payment = PaymentDetails::new( payment_id, + kind_for_insert, + Some(amount_msat), + None, + PaymentDirection::Inbound, + PaymentStatus::Succeeded, ); + match self.payment_store.insert(payment) { + Ok(_) => (), + Err(e) => { + log_error!( + self.logger, + "Failed to insert payment with ID {}: {}", + payment_id, + e + ); + return Err(ReplayEvent()); + }, + } }, Err(e) => { log_error!( diff --git a/tests/common/mod.rs b/tests/common/mod.rs index c75a6947c..9bc9274a8 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -905,13 +905,15 @@ pub(crate) async fn do_channel_full_cycle( }); assert_eq!(outbound_payments_b.len(), 0); + // Wait for events before checking inbound payments, as they are now created on demand + // by the event handler. + expect_event!(node_a, PaymentSuccessful); + expect_event!(node_b, PaymentReceived); + let inbound_payments_b = node_b.list_payments_with_filter(|p| { p.direction == PaymentDirection::Inbound && matches!(p.kind, PaymentKind::Bolt11 { .. }) }); assert_eq!(inbound_payments_b.len(), 1); - - expect_event!(node_a, PaymentSuccessful); - expect_event!(node_b, PaymentReceived); assert_eq!(node_a.payment(&payment_id).unwrap().status, PaymentStatus::Succeeded); assert_eq!(node_a.payment(&payment_id).unwrap().direction, PaymentDirection::Outbound); assert_eq!(node_a.payment(&payment_id).unwrap().amount_msat, Some(invoice_amount_1_msat)); @@ -1145,9 +1147,12 @@ pub(crate) async fn do_channel_full_cycle( node_a.list_payments_with_filter(|p| matches!(p.kind, PaymentKind::Bolt11 { .. })).len(), 5 ); + // Note: node_b has 5 (not 6) Bolt11 payments because the receive() invoice used only for the + // underpaid attempt (which fails with InvalidAmount) no longer creates a store entry. Only + // invoices that result in actual payment events are tracked. assert_eq!( node_b.list_payments_with_filter(|p| matches!(p.kind, PaymentKind::Bolt11 { .. })).len(), - 6 + 5 ); assert_eq!( node_a