From 988604fd78c6b52ad8dd28e99f0487a1e52f53dd Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 16 Jun 2025 11:58:23 -0300 Subject: [PATCH 01/41] LSPS1: Add initial integration test We add the first LSPS1 integration test. This is based on the unfinished work in https://github.com/lightningdevkit/rust-lightning/pull/3864, but rebased to account for the new ways we now do integration test setup. --- .../tests/lsps1_integration_tests.rs | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 lightning-liquidity/tests/lsps1_integration_tests.rs diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs new file mode 100644 index 00000000000..5e842c6a111 --- /dev/null +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -0,0 +1,273 @@ +#![cfg(all(test, feature = "time", lsps1_service))] + +mod common; + +use common::create_service_and_client_nodes_with_kv_stores; +use common::{get_lsps_message, LSPSNodes}; + +use lightning::ln::peer_handler::CustomMessageHandler; +use lightning_liquidity::events::LiquidityEvent; +use lightning_liquidity::lsps0::ser::LSPSDateTime; +use lightning_liquidity::lsps1::client::LSPS1ClientConfig; +use lightning_liquidity::lsps1::event::LSPS1ClientEvent; +use lightning_liquidity::lsps1::event::LSPS1ServiceEvent; +use lightning_liquidity::lsps1::msgs::LSPS1OrderState; +use lightning_liquidity::lsps1::msgs::{ + LSPS1OnchainPaymentInfo, LSPS1Options, LSPS1OrderParams, LSPS1PaymentInfo, +}; +use lightning_liquidity::lsps1::service::LSPS1ServiceConfig; +use lightning_liquidity::utils::time::DefaultTimeProvider; +use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; + +use lightning::ln::functional_test_utils::{ + create_chanmon_cfgs, create_node_cfgs, create_node_chanmgrs, +}; +use lightning::util::test_utils::TestStore; + +use std::str::FromStr; +use std::sync::Arc; + +use lightning::ln::functional_test_utils::{create_network, Node}; + +fn build_lsps1_configs( + supported_options: LSPS1Options, +) -> (LiquidityServiceConfig, LiquidityClientConfig) { + let lsps1_service_config = + LSPS1ServiceConfig { token: None, supported_options: Some(supported_options) }; + let service_config = LiquidityServiceConfig { + lsps1_service_config: Some(lsps1_service_config), + lsps2_service_config: None, + lsps5_service_config: None, + advertise_service: true, + }; + + let lsps1_client_config = LSPS1ClientConfig { max_channel_fees_msat: None }; + let client_config = LiquidityClientConfig { + lsps1_client_config: Some(lsps1_client_config), + lsps2_client_config: None, + lsps5_client_config: None, + }; + + (service_config, client_config) +} + +fn setup_test_lsps1_nodes_with_kv_stores<'a, 'b, 'c>( + nodes: Vec>, service_kv_store: Arc, + client_kv_store: Arc, supported_options: LSPS1Options, +) -> LSPSNodes<'a, 'b, 'c> { + let (service_config, client_config) = build_lsps1_configs(supported_options); + let lsps_nodes = create_service_and_client_nodes_with_kv_stores( + nodes, + service_config, + client_config, + Arc::new(DefaultTimeProvider), + service_kv_store, + client_kv_store, + ); + lsps_nodes +} + +fn setup_test_lsps1_nodes<'a, 'b, 'c>( + nodes: Vec>, supported_options: LSPS1Options, +) -> LSPSNodes<'a, 'b, 'c> { + let service_kv_store = Arc::new(TestStore::new(false)); + let client_kv_store = Arc::new(TestStore::new(false)); + setup_test_lsps1_nodes_with_kv_stores( + nodes, + service_kv_store, + client_kv_store, + supported_options, + ) +} + +#[test] +fn lsps1_happy_path() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let expected_options_supported = LSPS1Options { + min_required_channel_confirmations: 0, + min_funding_confirms_within_blocks: 6, + supports_zero_channel_reserve: true, + max_channel_expiry_blocks: 144, + min_initial_client_balance_sat: 10_000_000, + max_initial_client_balance_sat: 100_000_000, + min_initial_lsp_balance_sat: 100_000, + max_initial_lsp_balance_sat: 100_000_000, + min_channel_balance_sat: 100_000, + max_channel_balance_sat: 100_000_000, + }; + + let LSPSNodes { service_node, client_node } = + setup_test_lsps1_nodes(nodes, expected_options_supported.clone()); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + let client_handler = client_node.liquidity_manager.lsps1_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps1_service_handler().unwrap(); + + let request_supported_options_id = client_handler.request_supported_options(service_node_id); + let request_supported_options = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(request_supported_options, client_node_id) + .unwrap(); + + let get_info_message = get_lsps_message!(service_node, client_node_id); + + client_node.liquidity_manager.handle_custom_message(get_info_message, service_node_id).unwrap(); + + let get_info_event = client_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::SupportedOptionsReady { + request_id, + counterparty_node_id, + supported_options, + }) = get_info_event + { + assert_eq!(request_id, request_supported_options_id); + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(expected_options_supported, supported_options); + } else { + panic!("Unexpected event"); + } + + let order_params = LSPS1OrderParams { + lsp_balance_sat: 100_000, + client_balance_sat: 10_000_000, + required_channel_confirmations: 0, + funding_confirms_within_blocks: 6, + channel_expiry_blocks: 144, + token: None, + announce_channel: true, + }; + + let _create_order_id = + client_handler.create_order(&service_node_id, order_params.clone(), None); + let create_order = get_lsps_message!(client_node, service_node_id); + + service_node.liquidity_manager.handle_custom_message(create_order, client_node_id).unwrap(); + + let _request_for_payment_event = service_node.liquidity_manager.next_event().unwrap(); + + if let LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::RequestForPaymentDetails { + request_id, + counterparty_node_id, + order, + }) = _request_for_payment_event + { + assert_eq!(request_id, _create_order_id.clone()); + assert_eq!(counterparty_node_id, client_node_id); + assert_eq!(order, order_params); + } else { + panic!("Unexpected event"); + } + + let json_str = r#"{ + "state": "EXPECT_PAYMENT", + "expires_at": "2025-01-01T00:00:00Z", + "fee_total_sat": "9999", + "order_total_sat": "200999", + "address": "bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr", + "min_onchain_payment_confirmations": 1, + "min_fee_for_0conf": 253 + }"#; + + let onchain: LSPS1OnchainPaymentInfo = + serde_json::from_str(json_str).expect("Failed to parse JSON"); + let payment_info = LSPS1PaymentInfo { bolt11: None, bolt12: None, onchain: Some(onchain) }; + let _now = LSPSDateTime::from_str("2024-01-01T00:00:00Z").expect("Failed to parse date"); + + let _ = service_handler + .send_payment_details(_create_order_id.clone(), &client_node_id, payment_info.clone(), _now) + .unwrap(); + + let create_order_response = get_lsps_message!(service_node, client_node_id); + + client_node + .liquidity_manager + .handle_custom_message(create_order_response, service_node_id) + .unwrap(); + + let order_created_event = client_node.liquidity_manager.next_event().unwrap(); + let expected_order_id = if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderCreated { + request_id, + counterparty_node_id, + order_id, + order, + payment, + channel, + }) = order_created_event + { + assert_eq!(request_id, _create_order_id); + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(order, order_params); + assert_eq!(payment, payment_info); + assert!(channel.is_none()); + order_id + } else { + panic!("Unexpected event"); + }; + + let check_order_status_id = + client_handler.check_order_status(&service_node_id, expected_order_id.clone()); + let check_order_status = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(check_order_status, client_node_id) + .unwrap(); + + let _check_payment_confirmation_event = service_node.liquidity_manager.next_event().unwrap(); + + if let LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::CheckPaymentConfirmation { + request_id, + counterparty_node_id, + order_id, + }) = _check_payment_confirmation_event + { + assert_eq!(request_id, check_order_status_id); + assert_eq!(counterparty_node_id, client_node_id); + assert_eq!(order_id, expected_order_id.clone()); + } else { + panic!("Unexpected event"); + } + + let _ = service_handler + .update_order_status( + check_order_status_id.clone(), + client_node_id, + expected_order_id.clone(), + LSPS1OrderState::Created, + None, + ) + .unwrap(); + + let order_status_response = get_lsps_message!(service_node, client_node_id); + + client_node + .liquidity_manager + .handle_custom_message(order_status_response, service_node_id) + .unwrap(); + + let order_status_event = client_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { + request_id, + counterparty_node_id, + order_id, + order, + payment, + channel, + }) = order_status_event + { + assert_eq!(request_id, check_order_status_id); + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(order, order_params); + assert_eq!(payment, payment_info); + assert!(channel.is_none()); + assert_eq!(order_id, expected_order_id); + } else { + panic!("Unexpected event"); + } +} From 3ed1ebdf5404509db13a5f034ea7b056a76d4cfb Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 12:28:40 +0100 Subject: [PATCH 02/41] Cleanup unused code .. for which we got warnings --- lightning-liquidity/src/lsps1/service.rs | 26 ++++-------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index d7010652c37..793e376fa26 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -40,8 +40,6 @@ use lightning::util::persist::KVStore; use bitcoin::secp256k1::PublicKey; -use chrono::Utc; - /// Server-side configuration options for bLIP-51 / LSPS1 channel requests. #[derive(Clone, Debug)] pub struct LSPS1ServiceConfig { @@ -63,7 +61,6 @@ impl From for LightningError { enum OutboundRequestState { OrderCreated { order_id: LSPS1OrderId }, WaitingPayment { order_id: LSPS1OrderId }, - Ready, } impl OutboundRequestState { @@ -102,18 +99,11 @@ impl OutboundCRChannel { self.state = self.state.awaiting_payment()?; Ok(()) } - - fn check_order_validity(&self, supported_options: &LSPS1Options) -> bool { - let order = &self.config.order; - - is_valid(order, supported_options) - } } #[derive(Default)] struct PeerState { outbound_channels_by_order_id: HashMap, - request_to_cid: HashMap, pending_requests: HashMap, } @@ -121,14 +111,6 @@ impl PeerState { fn insert_outbound_channel(&mut self, order_id: LSPS1OrderId, channel: OutboundCRChannel) { self.outbound_channels_by_order_id.insert(order_id, channel); } - - fn insert_request(&mut self, request_id: LSPSRequestId, channel_id: u128) { - self.request_to_cid.insert(request_id, channel_id); - } - - fn remove_outbound_channel(&mut self, order_id: LSPS1OrderId) { - self.outbound_channels_by_order_id.remove(&order_id); - } } /// The main object allowing to send and receive bLIP-51 / LSPS1 messages. @@ -137,8 +119,8 @@ where CM::Target: AChannelManager, { entropy_source: ES, - channel_manager: CM, - chain_source: Option, + _channel_manager: CM, + _chain_source: Option, pending_messages: Arc, pending_events: Arc>, per_peer_state: RwLock>>, @@ -158,8 +140,8 @@ where ) -> Self { Self { entropy_source, - channel_manager, - chain_source, + _channel_manager: channel_manager, + _chain_source: chain_source, pending_messages, pending_events, per_peer_state: RwLock::new(new_hash_map()), From 0299f57b09446acae80f810ead679c2513369d77 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Dec 2025 12:08:55 +0100 Subject: [PATCH 03/41] Drop `chain_source` from `LSPS1ServiceHandler` We previously considered tracking payment confirmations as part of the handler. However, we can considerably simplify our logic if we stick with the current approach of having the LSPs track the payment status and update us when prompted through events. --- lightning-liquidity/src/lsps1/service.rs | 15 +++++---------- lightning-liquidity/src/manager.rs | 9 ++++----- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 793e376fa26..7d138e3b2c7 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -30,7 +30,6 @@ use crate::prelude::{new_hash_map, HashMap}; use crate::sync::{Arc, Mutex, RwLock}; use crate::utils; -use lightning::chain::Filter; use lightning::ln::channelmanager::AChannelManager; use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::sign::EntropySource; @@ -114,34 +113,30 @@ impl PeerState { } /// The main object allowing to send and receive bLIP-51 / LSPS1 messages. -pub struct LSPS1ServiceHandler +pub struct LSPS1ServiceHandler where CM::Target: AChannelManager, { entropy_source: ES, _channel_manager: CM, - _chain_source: Option, pending_messages: Arc, pending_events: Arc>, per_peer_state: RwLock>>, config: LSPS1ServiceConfig, } -impl - LSPS1ServiceHandler +impl LSPS1ServiceHandler where CM::Target: AChannelManager, { /// Constructs a `LSPS1ServiceHandler`. pub(crate) fn new( entropy_source: ES, pending_messages: Arc, - pending_events: Arc>, channel_manager: CM, chain_source: Option, - config: LSPS1ServiceConfig, + pending_events: Arc>, channel_manager: CM, config: LSPS1ServiceConfig, ) -> Self { Self { entropy_source, _channel_manager: channel_manager, - _chain_source: chain_source, pending_messages, pending_events, per_peer_state: RwLock::new(new_hash_map()), @@ -397,8 +392,8 @@ where } } -impl LSPSProtocolMessageHandler - for LSPS1ServiceHandler +impl LSPSProtocolMessageHandler + for LSPS1ServiceHandler where CM::Target: AChannelManager, { diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 1f11fc8add7..5336e6f2111 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -297,7 +297,7 @@ pub struct LiquidityManager< lsps0_client_handler: LSPS0ClientHandler, lsps0_service_handler: Option, #[cfg(lsps1_service)] - lsps1_service_handler: Option>, + lsps1_service_handler: Option>, lsps1_client_handler: Option>, lsps2_service_handler: Option>, lsps2_client_handler: Option>, @@ -474,7 +474,7 @@ where #[cfg(lsps1_service)] let lsps1_service_handler = service_config.as_ref().and_then(|config| { if let Some(number) = - as LSPSProtocolMessageHandler>::PROTOCOL_NUMBER + as LSPSProtocolMessageHandler>::PROTOCOL_NUMBER { supported_protocols.push(number); } @@ -484,7 +484,6 @@ where Arc::clone(&pending_messages), Arc::clone(&pending_events), channel_manager.clone(), - chain_source.clone(), config.clone(), ) }) @@ -544,7 +543,7 @@ where /// Returns a reference to the LSPS1 server-side handler. #[cfg(lsps1_service)] - pub fn lsps1_service_handler(&self) -> Option<&LSPS1ServiceHandler> { + pub fn lsps1_service_handler(&self) -> Option<&LSPS1ServiceHandler> { self.lsps1_service_handler.as_ref() } @@ -1148,7 +1147,7 @@ where #[cfg(lsps1_service)] pub fn lsps1_service_handler( &self, - ) -> Option<&LSPS1ServiceHandler>> { + ) -> Option<&LSPS1ServiceHandler>> { self.inner.lsps1_service_handler() } From dbfd3f29b2651e5a3e8afa358a6fa14ec87c1312 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Dec 2025 12:24:41 +0100 Subject: [PATCH 04/41] Drop `Listen`/`Confirm`/etc from `LiquidityManager` Now that we don't do on-chain tracking in LSPS1, we can drop quite a few `LiquidityManager` parameters and generics, which were only added in anticipation of tracking on-chain state. Signed-off-by: Elias Rohrer --- fuzz/src/lsps_message.rs | 2 - lightning-background-processor/src/lib.rs | 27 +-- lightning-liquidity/src/manager.rs | 219 ++---------------- lightning-liquidity/tests/common/mod.rs | 15 -- .../tests/lsps2_integration_tests.rs | 10 +- .../tests/lsps5_integration_tests.rs | 13 +- 6 files changed, 34 insertions(+), 252 deletions(-) diff --git a/fuzz/src/lsps_message.rs b/fuzz/src/lsps_message.rs index 8371d1c5fc7..42feed48cc1 100644 --- a/fuzz/src/lsps_message.rs +++ b/fuzz/src/lsps_message.rs @@ -82,8 +82,6 @@ pub fn do_test(data: &[u8]) { Arc::clone(&keys_manager), Arc::clone(&keys_manager), Arc::clone(&manager), - None::>, - None, kv_store, Arc::clone(&tx_broadcaster), None, diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index f052f3d8d4c..8a4ec76f6e6 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -464,7 +464,6 @@ pub const NO_LIQUIDITY_MANAGER: Option< NodeSigner = &(dyn lightning::sign::NodeSigner + Send + Sync), AChannelManager = DynChannelManager, CM = &DynChannelManager, - C = &(dyn chain::Filter + Send + Sync), K = &DummyKVStore, TimeProvider = dyn lightning_liquidity::utils::time::TimeProvider + Send + Sync, TP = &(dyn lightning_liquidity::utils::time::TimeProvider + Send + Sync), @@ -486,7 +485,6 @@ pub const NO_LIQUIDITY_MANAGER_SYNC: Option< NodeSigner = &(dyn lightning::sign::NodeSigner + Send + Sync), AChannelManager = DynChannelManager, CM = &DynChannelManager, - C = &(dyn chain::Filter + Send + Sync), KVStoreSync = dyn lightning::util::persist::KVStoreSync + Send + Sync, KS = &(dyn lightning::util::persist::KVStoreSync + Send + Sync), TimeProvider = dyn lightning_liquidity::utils::time::TimeProvider + Send + Sync, @@ -829,7 +827,7 @@ use futures_util::{dummy_waker, Joiner, OptionalSelector, Selector, SelectorOutp /// # type P2PGossipSync
    = lightning::routing::gossip::P2PGossipSync, Arc
      , Arc>; /// # type ChannelManager = lightning::ln::channelmanager::SimpleArcChannelManager, B, FE, Logger>; /// # type OnionMessenger = lightning::onion_message::messenger::OnionMessenger, Arc, Arc, Arc>, Arc, Arc, Arc>>, Arc>, lightning::ln::peer_handler::IgnoringMessageHandler, lightning::ln::peer_handler::IgnoringMessageHandler, lightning::ln::peer_handler::IgnoringMessageHandler>; -/// # type LiquidityManager = lightning_liquidity::LiquidityManager, Arc, Arc>, Arc, Arc, Arc, Arc>; +/// # type LiquidityManager = lightning_liquidity::LiquidityManager, Arc, Arc>, Arc, Arc, Arc>; /// # type Scorer = RwLock, Arc>>; /// # type PeerManager = lightning::ln::peer_handler::SimpleArcPeerManager, B, FE, Arc
        , Logger, F, StoreSync>; /// # type OutputSweeper = lightning::util::sweep::OutputSweeper, Arc, Arc, Arc, Arc, Arc, Arc>; @@ -1898,7 +1896,7 @@ mod tests { use core::sync::atomic::{AtomicBool, Ordering}; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; use lightning::chain::transaction::OutPoint; - use lightning::chain::{chainmonitor, BestBlock, Confirm, Filter}; + use lightning::chain::{chainmonitor, BestBlock, Confirm}; use lightning::events::{Event, PathFailure, ReplayEvent}; use lightning::ln::channelmanager; use lightning::ln::channelmanager::{ @@ -2054,7 +2052,6 @@ mod tests { Arc, Arc, Arc, - Arc, Arc, DefaultTimeProvider, Arc, @@ -2513,8 +2510,6 @@ mod tests { Arc::clone(&keys_manager), Arc::clone(&keys_manager), Arc::clone(&manager), - None, - None, Arc::clone(&kv_store), Arc::clone(&tx_broadcaster), None, @@ -2910,10 +2905,10 @@ mod tests { let kv_store = KVStoreSyncWrapper(kv_store_sync); // Yes, you can unsafe { turn off the borrow checker } - let lm_async: &'static LiquidityManager<_, _, _, _, _, _, _> = unsafe { + let lm_async: &'static LiquidityManager<_, _, _, _, _, _> = unsafe { &*(nodes[0].liquidity_manager.get_lm_async() - as *const LiquidityManager<_, _, _, _, _, _, _>) - as &'static LiquidityManager<_, _, _, _, _, _, _> + as *const LiquidityManager<_, _, _, _, _, _>) + as &'static LiquidityManager<_, _, _, _, _, _> }; let sweeper_async: &'static OutputSweeper<_, _, _, _, _, _, _> = unsafe { &*(nodes[0].sweeper.sweeper_async() as *const OutputSweeper<_, _, _, _, _, _, _>) @@ -3435,10 +3430,10 @@ mod tests { let kv_store = KVStoreSyncWrapper(kv_store_sync); // Yes, you can unsafe { turn off the borrow checker } - let lm_async: &'static LiquidityManager<_, _, _, _, _, _, _> = unsafe { + let lm_async: &'static LiquidityManager<_, _, _, _, _, _> = unsafe { &*(nodes[0].liquidity_manager.get_lm_async() - as *const LiquidityManager<_, _, _, _, _, _, _>) - as &'static LiquidityManager<_, _, _, _, _, _, _> + as *const LiquidityManager<_, _, _, _, _, _>) + as &'static LiquidityManager<_, _, _, _, _, _> }; let sweeper_async: &'static OutputSweeper<_, _, _, _, _, _, _> = unsafe { &*(nodes[0].sweeper.sweeper_async() as *const OutputSweeper<_, _, _, _, _, _, _>) @@ -3662,10 +3657,10 @@ mod tests { let (exit_sender, exit_receiver) = tokio::sync::watch::channel(()); // Yes, you can unsafe { turn off the borrow checker } - let lm_async: &'static LiquidityManager<_, _, _, _, _, _, _> = unsafe { + let lm_async: &'static LiquidityManager<_, _, _, _, _, _> = unsafe { &*(nodes[0].liquidity_manager.get_lm_async() - as *const LiquidityManager<_, _, _, _, _, _, _>) - as &'static LiquidityManager<_, _, _, _, _, _, _> + as *const LiquidityManager<_, _, _, _, _, _>) + as &'static LiquidityManager<_, _, _, _, _, _> }; let sweeper_async: &'static OutputSweeper<_, _, _, _, _, _, _> = unsafe { &*(nodes[0].sweeper.sweeper_async() as *const OutputSweeper<_, _, _, _, _, _, _>) diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 5336e6f2111..45a85e72003 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -43,8 +43,7 @@ use crate::utils::time::DefaultTimeProvider; use crate::utils::time::TimeProvider; use lightning::chain::chaininterface::BroadcasterInterface; -use lightning::chain::{self, BestBlock, Confirm, Filter, Listen}; -use lightning::ln::channelmanager::{AChannelManager, ChainParameters}; +use lightning::ln::channelmanager::AChannelManager; use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning::ln::wire::CustomMessageReader; @@ -111,8 +110,6 @@ pub trait ALiquidityManager { type AChannelManager: AChannelManager + ?Sized; /// A type that may be dereferenced to [`Self::AChannelManager`]. type CM: Deref + Clone; - /// A type implementing [`Filter`]. - type C: Filter + Clone; /// A type implementing [`KVStore`]. type K: KVStore + Clone; /// A type implementing [`TimeProvider`]. @@ -128,7 +125,6 @@ pub trait ALiquidityManager { Self::EntropySource, Self::NodeSigner, Self::CM, - Self::C, Self::K, Self::TP, Self::BroadcasterInterface, @@ -139,11 +135,10 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, K: KVStore + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, - > ALiquidityManager for LiquidityManager + > ALiquidityManager for LiquidityManager where CM::Target: AChannelManager, TP::Target: TimeProvider, @@ -152,12 +147,11 @@ where type NodeSigner = NS; type AChannelManager = CM::Target; type CM = CM; - type C = C; type K = K; type TimeProvider = TP::Target; type TP = TP; type BroadcasterInterface = T; - fn get_lm(&self) -> &LiquidityManager { + fn get_lm(&self) -> &LiquidityManager { self } } @@ -175,8 +169,6 @@ pub trait ALiquidityManagerSync { type AChannelManager: AChannelManager + ?Sized; /// A type that may be dereferenced to [`Self::AChannelManager`]. type CM: Deref + Clone; - /// A type implementing [`Filter`]. - type C: Filter + Clone; /// A type implementing [`KVStoreSync`]. type KVStoreSync: KVStoreSync + ?Sized; /// A type that may be dereferenced to [`Self::KVStoreSync`]. @@ -195,7 +187,6 @@ pub trait ALiquidityManagerSync { Self::EntropySource, Self::NodeSigner, Self::CM, - Self::C, KVStoreSyncWrapper, Self::TP, Self::BroadcasterInterface, @@ -207,7 +198,6 @@ pub trait ALiquidityManagerSync { Self::EntropySource, Self::NodeSigner, Self::CM, - Self::C, Self::KS, Self::TP, Self::BroadcasterInterface, @@ -218,11 +208,10 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, KS: Deref + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, - > ALiquidityManagerSync for LiquidityManagerSync + > ALiquidityManagerSync for LiquidityManagerSync where CM::Target: AChannelManager, KS::Target: KVStoreSync, @@ -232,7 +221,6 @@ where type NodeSigner = NS; type AChannelManager = CM::Target; type CM = CM; - type C = C; type KVStoreSync = KS::Target; type KS = KS; type TimeProvider = TP::Target; @@ -246,14 +234,13 @@ where Self::EntropySource, Self::NodeSigner, Self::CM, - Self::C, KVStoreSyncWrapper, Self::TP, Self::BroadcasterInterface, > { &self.inner } - fn get_lm(&self) -> &LiquidityManagerSync { + fn get_lm(&self) -> &LiquidityManagerSync { self } } @@ -281,7 +268,6 @@ pub struct LiquidityManager< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, K: KVStore + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, @@ -305,8 +291,6 @@ pub struct LiquidityManager< lsps5_client_handler: Option>, service_config: Option, _client_config: Option, - best_block: RwLock>, - _chain_source: Option, pending_msgs_or_needs_persist_notifier: Arc, } @@ -315,10 +299,9 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, K: KVStore + Clone, T: BroadcasterInterface + Clone, - > LiquidityManager + > LiquidityManager where CM::Target: AChannelManager, { @@ -326,9 +309,8 @@ where /// /// Will read persisted service states from the given [`KVStore`]. pub async fn new( - entropy_source: ES, node_signer: NS, channel_manager: CM, chain_source: Option, - chain_params: Option, kv_store: K, transaction_broadcaster: T, - service_config: Option, + entropy_source: ES, node_signer: NS, channel_manager: CM, kv_store: K, + transaction_broadcaster: T, service_config: Option, client_config: Option, ) -> Result { Self::new_with_custom_time_provider( @@ -336,8 +318,6 @@ where node_signer, channel_manager, transaction_broadcaster, - chain_source, - chain_params, kv_store, service_config, client_config, @@ -351,11 +331,10 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, K: KVStore + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, - > LiquidityManager + > LiquidityManager where CM::Target: AChannelManager, TP::Target: TimeProvider, @@ -370,8 +349,7 @@ where /// [`LiquidityClientConfig`] and [`LiquidityServiceConfig`]. pub async fn new_with_custom_time_provider( entropy_source: ES, node_signer: NS, channel_manager: CM, transaction_broadcaster: T, - chain_source: Option, chain_params: Option, kv_store: K, - service_config: Option, + kv_store: K, service_config: Option, client_config: Option, time_provider: TP, ) -> Result { let pending_msgs_or_needs_persist_notifier = Arc::new(Notifier::new()); @@ -517,8 +495,6 @@ where lsps5_service_handler, service_config, _client_config: client_config, - best_block: RwLock::new(chain_params.map(|chain_params| chain_params.best_block)), - _chain_source: chain_source, pending_msgs_or_needs_persist_notifier, }) } @@ -772,11 +748,10 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, K: KVStore + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, - > CustomMessageReader for LiquidityManager + > CustomMessageReader for LiquidityManager where CM::Target: AChannelManager, TP::Target: TimeProvider, @@ -799,11 +774,10 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, K: KVStore + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, - > CustomMessageHandler for LiquidityManager + > CustomMessageHandler for LiquidityManager where CM::Target: AChannelManager, TP::Target: TimeProvider, @@ -924,93 +898,12 @@ where } } -impl< - ES: EntropySource + Clone, - NS: NodeSigner + Clone, - CM: Deref + Clone, - C: Filter + Clone, - K: KVStore + Clone, - TP: Deref + Clone, - T: BroadcasterInterface + Clone, - > Listen for LiquidityManager -where - CM::Target: AChannelManager, - TP::Target: TimeProvider, -{ - fn filtered_block_connected( - &self, header: &bitcoin::block::Header, txdata: &chain::transaction::TransactionData, - height: u32, - ) { - if let Some(best_block) = self.best_block.read().unwrap().as_ref() { - assert_eq!(best_block.block_hash, header.prev_blockhash, - "Blocks must be connected in chain-order - the connected header must build on the last connected header"); - assert_eq!(best_block.height, height - 1, - "Blocks must be connected in chain-order - the connected block height must be one greater than the previous height"); - } - - self.transactions_confirmed(header, txdata, height); - self.best_block_updated(header, height); - } - - fn blocks_disconnected(&self, fork_point: BestBlock) { - if let Some(best_block) = self.best_block.write().unwrap().as_mut() { - assert!(best_block.height > fork_point.height, - "Blocks disconnected must indicate disconnection from the current best height, i.e. the new chain tip must be lower than the previous best height"); - *best_block = fork_point; - } - - // TODO: Call block_disconnected on all sub-modules that require it, e.g., LSPS1MessageHandler. - // Internally this should call transaction_unconfirmed for all transactions that were - // confirmed at a height <= the one we now disconnected. - } -} - -impl< - ES: EntropySource + Clone, - NS: NodeSigner + Clone, - CM: Deref + Clone, - C: Filter + Clone, - K: KVStore + Clone, - TP: Deref + Clone, - T: BroadcasterInterface + Clone, - > Confirm for LiquidityManager -where - CM::Target: AChannelManager, - TP::Target: TimeProvider, -{ - fn transactions_confirmed( - &self, _header: &bitcoin::block::Header, _txdata: &chain::transaction::TransactionData, - _height: u32, - ) { - // TODO: Call transactions_confirmed on all sub-modules that require it, e.g., LSPS1MessageHandler. - } - - fn transaction_unconfirmed(&self, _txid: &bitcoin::Txid) { - // TODO: Call transaction_unconfirmed on all sub-modules that require it, e.g., LSPS1MessageHandler. - // Internally this should call transaction_unconfirmed for all transactions that were - // confirmed at a height <= the one we now unconfirmed. - } - - fn best_block_updated(&self, header: &bitcoin::block::Header, height: u32) { - let new_best_block = BestBlock::new(header.block_hash(), height); - *self.best_block.write().unwrap() = Some(new_best_block); - - // TODO: Call best_block_updated on all sub-modules that require it, e.g., LSPS1MessageHandler. - } - - fn get_relevant_txids(&self) -> Vec<(bitcoin::Txid, u32, Option)> { - // TODO: Collect relevant txids from all sub-modules that, e.g., LSPS1MessageHandler. - Vec::new() - } -} - /// A synchroneous wrapper around [`LiquidityManager`] to be used in contexts where async is not /// available. pub struct LiquidityManagerSync< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, KS: Deref + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, @@ -1019,7 +912,7 @@ pub struct LiquidityManagerSync< KS::Target: KVStoreSync, TP::Target: TimeProvider, { - inner: LiquidityManager, TP, T>, + inner: LiquidityManager, TP, T>, } #[cfg(feature = "time")] @@ -1027,10 +920,9 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, KS: Deref + Clone, T: BroadcasterInterface + Clone, - > LiquidityManagerSync + > LiquidityManagerSync where CM::Target: AChannelManager, KS::Target: KVStoreSync, @@ -1039,9 +931,8 @@ where /// /// Wraps [`LiquidityManager::new`]. pub fn new( - entropy_source: ES, node_signer: NS, channel_manager: CM, chain_source: Option, - chain_params: Option, kv_store_sync: KS, transaction_broadcaster: T, - service_config: Option, + entropy_source: ES, node_signer: NS, channel_manager: CM, kv_store_sync: KS, + transaction_broadcaster: T, service_config: Option, client_config: Option, ) -> Result { let kv_store = KVStoreSyncWrapper(kv_store_sync); @@ -1050,8 +941,6 @@ where entropy_source, node_signer, channel_manager, - chain_source, - chain_params, kv_store, transaction_broadcaster, service_config, @@ -1075,11 +964,10 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, KS: Deref + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, - > LiquidityManagerSync + > LiquidityManagerSync where CM::Target: AChannelManager, KS::Target: KVStoreSync, @@ -1089,9 +977,8 @@ where /// /// Wraps [`LiquidityManager::new_with_custom_time_provider`]. pub fn new_with_custom_time_provider( - entropy_source: ES, node_signer: NS, channel_manager: CM, chain_source: Option, - chain_params: Option, kv_store_sync: KS, transaction_broadcaster: T, - service_config: Option, + entropy_source: ES, node_signer: NS, channel_manager: CM, kv_store_sync: KS, + transaction_broadcaster: T, service_config: Option, client_config: Option, time_provider: TP, ) -> Result { let kv_store = KVStoreSyncWrapper(kv_store_sync); @@ -1100,8 +987,6 @@ where node_signer, channel_manager, transaction_broadcaster, - chain_source, - chain_params, kv_store, service_config, client_config, @@ -1241,11 +1126,10 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, KS: Deref + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, - > CustomMessageReader for LiquidityManagerSync + > CustomMessageReader for LiquidityManagerSync where CM::Target: AChannelManager, KS::Target: KVStoreSync, @@ -1264,11 +1148,10 @@ impl< ES: EntropySource + Clone, NS: NodeSigner + Clone, CM: Deref + Clone, - C: Filter + Clone, KS: Deref + Clone, TP: Deref + Clone, T: BroadcasterInterface + Clone, - > CustomMessageHandler for LiquidityManagerSync + > CustomMessageHandler for LiquidityManagerSync where CM::Target: AChannelManager, KS::Target: KVStoreSync, @@ -1302,63 +1185,3 @@ where self.inner.peer_connected(counterparty_node_id, init_msg, inbound) } } - -impl< - ES: EntropySource + Clone, - NS: NodeSigner + Clone, - CM: Deref + Clone, - C: Filter + Clone, - KS: Deref + Clone, - TP: Deref + Clone, - T: BroadcasterInterface + Clone, - > Listen for LiquidityManagerSync -where - CM::Target: AChannelManager, - KS::Target: KVStoreSync, - TP::Target: TimeProvider, -{ - fn filtered_block_connected( - &self, header: &bitcoin::block::Header, txdata: &chain::transaction::TransactionData, - height: u32, - ) { - self.inner.filtered_block_connected(header, txdata, height) - } - - fn blocks_disconnected(&self, fork_point: BestBlock) { - self.inner.blocks_disconnected(fork_point); - } -} - -impl< - ES: EntropySource + Clone, - NS: NodeSigner + Clone, - CM: Deref + Clone, - C: Filter + Clone, - KS: Deref + Clone, - TP: Deref + Clone, - T: BroadcasterInterface + Clone, - > Confirm for LiquidityManagerSync -where - CM::Target: AChannelManager, - KS::Target: KVStoreSync, - TP::Target: TimeProvider, -{ - fn transactions_confirmed( - &self, header: &bitcoin::block::Header, txdata: &chain::transaction::TransactionData, - height: u32, - ) { - self.inner.transactions_confirmed(header, txdata, height) - } - - fn transaction_unconfirmed(&self, txid: &bitcoin::Txid) { - self.inner.transaction_unconfirmed(txid) - } - - fn best_block_updated(&self, header: &bitcoin::block::Header, height: u32) { - self.inner.best_block_updated(header, height) - } - - fn get_relevant_txids(&self) -> Vec<(bitcoin::Txid, u32, Option)> { - self.inner.get_relevant_txids() - } -} diff --git a/lightning-liquidity/tests/common/mod.rs b/lightning-liquidity/tests/common/mod.rs index dea987527ad..2716df7c0a3 100644 --- a/lightning-liquidity/tests/common/mod.rs +++ b/lightning-liquidity/tests/common/mod.rs @@ -3,13 +3,9 @@ use lightning_liquidity::utils::time::TimeProvider; use lightning_liquidity::{LiquidityClientConfig, LiquidityManagerSync, LiquidityServiceConfig}; -use lightning::chain::{BestBlock, Filter}; -use lightning::ln::channelmanager::ChainParameters; use lightning::ln::functional_test_utils::{Node, TestChannelManager}; use lightning::util::test_utils::{TestBroadcaster, TestKeysInterface, TestStore}; -use bitcoin::Network; - use core::ops::Deref; use std::sync::Arc; @@ -26,11 +22,6 @@ fn build_service_and_client_nodes<'a, 'b, 'c>( ) -> (LiquidityNode<'a, 'b, 'c>, LiquidityNode<'a, 'b, 'c>, Option>) { assert!(nodes.len() >= 2, "Need at least two nodes (service and client)"); - let chain_params = ChainParameters { - network: Network::Testnet, - best_block: BestBlock::from_network(Network::Testnet), - }; - let mut nodes_iter = nodes.into_iter(); let service_inner = nodes_iter.next().expect("missing service node"); let client_inner = nodes_iter.next().expect("missing client node"); @@ -40,8 +31,6 @@ fn build_service_and_client_nodes<'a, 'b, 'c>( service_inner.keys_manager, service_inner.keys_manager, service_inner.node, - None::>, - Some(chain_params.clone()), service_kv_store, service_inner.tx_broadcaster, Some(service_config), @@ -54,8 +43,6 @@ fn build_service_and_client_nodes<'a, 'b, 'c>( client_inner.keys_manager, client_inner.keys_manager, client_inner.node, - None::>, - Some(chain_params), client_kv_store, client_inner.tx_broadcaster, None, @@ -137,7 +124,6 @@ pub(crate) struct LiquidityNode<'a, 'b, 'c> { &'c TestKeysInterface, &'c TestKeysInterface, &'a TestChannelManager<'b, 'c>, - Arc, Arc, Arc, &'c TestBroadcaster, @@ -151,7 +137,6 @@ impl<'a, 'b, 'c> LiquidityNode<'a, 'b, 'c> { &'c TestKeysInterface, &'c TestKeysInterface, &'a TestChannelManager<'b, 'c>, - Arc, Arc, Arc, &'c TestBroadcaster, diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 33a6dd697cf..1c37f164d32 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -27,8 +27,7 @@ use lightning_liquidity::lsps2::utils::is_valid_opening_fee_params; use lightning_liquidity::utils::time::{DefaultTimeProvider, TimeProvider}; use lightning_liquidity::{LiquidityClientConfig, LiquidityManagerSync, LiquidityServiceConfig}; -use lightning::chain::{BestBlock, Filter}; -use lightning::ln::channelmanager::{ChainParameters, InterceptId, MIN_FINAL_CLTV_EXPIRY_DELTA}; +use lightning::ln::channelmanager::{InterceptId, MIN_FINAL_CLTV_EXPIRY_DELTA}; use lightning::ln::functional_test_utils::{ create_chanmon_cfgs, create_node_cfgs, create_node_chanmgrs, }; @@ -1071,19 +1070,12 @@ fn lsps2_service_handler_persistence_across_restarts() { let nodes_restart = create_network(2, &node_cfgs, &node_chanmgrs_restart); // Create a new LiquidityManager with the same configuration and KV store to simulate restart - let chain_params = ChainParameters { - network: Network::Testnet, - best_block: BestBlock::from_network(Network::Testnet), - }; - let transaction_broadcaster = Arc::new(TestBroadcaster::new(Network::Testnet)); let restarted_service_lm = LiquidityManagerSync::new_with_custom_time_provider( nodes_restart[0].keys_manager, nodes_restart[0].keys_manager, nodes_restart[0].node, - None::>, - Some(chain_params), service_kv_store, transaction_broadcaster, Some(service_config), diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs index 16f20fd095f..6af0c137be5 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -7,9 +7,8 @@ use common::{ get_lsps_message, LSPSNodes, LiquidityNode, }; -use lightning::chain::{BestBlock, Filter}; use lightning::events::ClosureReason; -use lightning::ln::channelmanager::{ChainParameters, InterceptId}; +use lightning::ln::channelmanager::InterceptId; use lightning::ln::functional_test_utils::{ check_closed_event, close_channel, create_chan_between_nodes, create_chanmon_cfgs, create_network, create_node_cfgs, create_node_chanmgrs, Node, @@ -43,8 +42,6 @@ use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; use lightning_types::payment::PaymentHash; -use bitcoin::Network; - use std::str::FromStr; use std::sync::{Arc, RwLock}; use std::time::Duration; @@ -1601,18 +1598,10 @@ fn lsps5_service_handler_persistence_across_restarts() { let node_chanmgrs_restart = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes_restart = create_network(2, &node_cfgs, &node_chanmgrs_restart); - // Create a new LiquidityManager with the same configuration and KV store to simulate restart - let chain_params = ChainParameters { - network: Network::Testnet, - best_block: BestBlock::from_network(Network::Testnet), - }; - let restarted_service_lm = LiquidityManagerSync::new_with_custom_time_provider( nodes_restart[0].keys_manager, nodes_restart[0].keys_manager, nodes_restart[0].node, - None::>, - Some(chain_params), service_kv_store, nodes_restart[0].tx_broadcaster, Some(service_config), From 4e3b435aa306766479d0a49ed183b2838cc60535 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 12:44:55 +0100 Subject: [PATCH 05/41] Move `PeerState` and related types to `peer_state.rs` module We move the `PeerState` related types to a new module. In the following commits we'll bit-by-bit drop the `pub(super)`s introduced here, asserting better separation of state and logic going forward. --- lightning-liquidity/src/lsps1/mod.rs | 2 + lightning-liquidity/src/lsps1/peer_state.rs | 84 +++++++++++++++++++++ lightning-liquidity/src/lsps1/service.rs | 65 +--------------- 3 files changed, 87 insertions(+), 64 deletions(-) create mode 100644 lightning-liquidity/src/lsps1/peer_state.rs diff --git a/lightning-liquidity/src/lsps1/mod.rs b/lightning-liquidity/src/lsps1/mod.rs index b068b186610..bdfc4045f54 100644 --- a/lightning-liquidity/src/lsps1/mod.rs +++ b/lightning-liquidity/src/lsps1/mod.rs @@ -13,4 +13,6 @@ pub mod client; pub mod event; pub mod msgs; #[cfg(lsps1_service)] +mod peer_state; +#[cfg(lsps1_service)] pub mod service; diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs new file mode 100644 index 00000000000..71eeb662120 --- /dev/null +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -0,0 +1,84 @@ +// 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. + +//! Contains peer state objects that are used by `LSPS1ServiceHandler`. + +use super::msgs::{LSPS1OrderId, LSPS1OrderParams, LSPS1PaymentInfo, LSPS1Request}; + +use crate::lsps0::ser::{LSPSDateTime, LSPSRequestId}; +use crate::prelude::HashMap; + +use lightning::ln::msgs::{ErrorAction, LightningError}; +use lightning::util::logger::Level; + +#[derive(Default)] +pub(super) struct PeerState { + pub(super) outbound_channels_by_order_id: HashMap, + pub(super) pending_requests: HashMap, +} + +impl PeerState { + pub(super) fn insert_outbound_channel( + &mut self, order_id: LSPS1OrderId, channel: OutboundCRChannel, + ) { + self.outbound_channels_by_order_id.insert(order_id, channel); + } +} + +struct ChannelStateError(String); + +impl From for LightningError { + fn from(value: ChannelStateError) -> Self { + LightningError { err: value.0, action: ErrorAction::IgnoreAndLog(Level::Info) } + } +} + +#[derive(PartialEq, Debug)] +pub(super) enum OutboundRequestState { + OrderCreated { order_id: LSPS1OrderId }, + WaitingPayment { order_id: LSPS1OrderId }, +} + +impl OutboundRequestState { + fn awaiting_payment(&self) -> Result { + match self { + OutboundRequestState::OrderCreated { order_id } => { + Ok(OutboundRequestState::WaitingPayment { order_id: order_id.clone() }) + }, + state => Err(ChannelStateError(format!("TODO. JIT Channel was in state: {:?}", state))), + } + } +} + +pub(super) struct OutboundLSPS1Config { + pub(super) order: LSPS1OrderParams, + pub(super) created_at: LSPSDateTime, + pub(super) payment: LSPS1PaymentInfo, +} + +pub(super) struct OutboundCRChannel { + pub(super) state: OutboundRequestState, + pub(super) config: OutboundLSPS1Config, +} + +impl OutboundCRChannel { + pub(super) fn new( + order: LSPS1OrderParams, created_at: LSPSDateTime, order_id: LSPS1OrderId, + payment: LSPS1PaymentInfo, + ) -> Self { + Self { + state: OutboundRequestState::OrderCreated { order_id }, + config: OutboundLSPS1Config { order, created_at, payment }, + } + } + pub(super) fn awaiting_payment(&mut self) -> Result<(), LightningError> { + self.state = self.state.awaiting_payment()?; + Ok(()) + } +} diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 7d138e3b2c7..ac97b614855 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -20,6 +20,7 @@ use super::msgs::{ LSPS1OrderState, LSPS1PaymentInfo, LSPS1Request, LSPS1Response, LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, }; +use super::peer_state::{OutboundCRChannel, PeerState}; use crate::message_queue::MessageQueue; use crate::events::EventQueue; @@ -48,70 +49,6 @@ pub struct LSPS1ServiceConfig { pub supported_options: Option, } -struct ChannelStateError(String); - -impl From for LightningError { - fn from(value: ChannelStateError) -> Self { - LightningError { err: value.0, action: ErrorAction::IgnoreAndLog(Level::Info) } - } -} - -#[derive(PartialEq, Debug)] -enum OutboundRequestState { - OrderCreated { order_id: LSPS1OrderId }, - WaitingPayment { order_id: LSPS1OrderId }, -} - -impl OutboundRequestState { - fn awaiting_payment(&self) -> Result { - match self { - OutboundRequestState::OrderCreated { order_id } => { - Ok(OutboundRequestState::WaitingPayment { order_id: order_id.clone() }) - }, - state => Err(ChannelStateError(format!("TODO. JIT Channel was in state: {:?}", state))), - } - } -} - -struct OutboundLSPS1Config { - order: LSPS1OrderParams, - created_at: LSPSDateTime, - payment: LSPS1PaymentInfo, -} - -struct OutboundCRChannel { - state: OutboundRequestState, - config: OutboundLSPS1Config, -} - -impl OutboundCRChannel { - fn new( - order: LSPS1OrderParams, created_at: LSPSDateTime, order_id: LSPS1OrderId, - payment: LSPS1PaymentInfo, - ) -> Self { - Self { - state: OutboundRequestState::OrderCreated { order_id }, - config: OutboundLSPS1Config { order, created_at, payment }, - } - } - fn awaiting_payment(&mut self) -> Result<(), LightningError> { - self.state = self.state.awaiting_payment()?; - Ok(()) - } -} - -#[derive(Default)] -struct PeerState { - outbound_channels_by_order_id: HashMap, - pending_requests: HashMap, -} - -impl PeerState { - fn insert_outbound_channel(&mut self, order_id: LSPS1OrderId, channel: OutboundCRChannel) { - self.outbound_channels_by_order_id.insert(order_id, channel); - } -} - /// The main object allowing to send and receive bLIP-51 / LSPS1 messages. pub struct LSPS1ServiceHandler where From ec082f66976e61932d71c10157b5c859d67a3fe6 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 13:07:09 +0100 Subject: [PATCH 06/41] Drop bogus channel state handling .. we will re-add a proper state machine in a later commit, but for now we can just drop all of this half-baked logic that doesn't actually do anything. --- lightning-liquidity/src/lsps1/peer_state.rs | 41 +-------------------- lightning-liquidity/src/lsps1/service.rs | 23 ------------ 2 files changed, 2 insertions(+), 62 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 71eeb662120..3e9d17f4c73 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -14,9 +14,6 @@ use super::msgs::{LSPS1OrderId, LSPS1OrderParams, LSPS1PaymentInfo, LSPS1Request use crate::lsps0::ser::{LSPSDateTime, LSPSRequestId}; use crate::prelude::HashMap; -use lightning::ln::msgs::{ErrorAction, LightningError}; -use lightning::util::logger::Level; - #[derive(Default)] pub(super) struct PeerState { pub(super) outbound_channels_by_order_id: HashMap, @@ -31,31 +28,6 @@ impl PeerState { } } -struct ChannelStateError(String); - -impl From for LightningError { - fn from(value: ChannelStateError) -> Self { - LightningError { err: value.0, action: ErrorAction::IgnoreAndLog(Level::Info) } - } -} - -#[derive(PartialEq, Debug)] -pub(super) enum OutboundRequestState { - OrderCreated { order_id: LSPS1OrderId }, - WaitingPayment { order_id: LSPS1OrderId }, -} - -impl OutboundRequestState { - fn awaiting_payment(&self) -> Result { - match self { - OutboundRequestState::OrderCreated { order_id } => { - Ok(OutboundRequestState::WaitingPayment { order_id: order_id.clone() }) - }, - state => Err(ChannelStateError(format!("TODO. JIT Channel was in state: {:?}", state))), - } - } -} - pub(super) struct OutboundLSPS1Config { pub(super) order: LSPS1OrderParams, pub(super) created_at: LSPSDateTime, @@ -63,22 +35,13 @@ pub(super) struct OutboundLSPS1Config { } pub(super) struct OutboundCRChannel { - pub(super) state: OutboundRequestState, pub(super) config: OutboundLSPS1Config, } impl OutboundCRChannel { pub(super) fn new( - order: LSPS1OrderParams, created_at: LSPSDateTime, order_id: LSPS1OrderId, - payment: LSPS1PaymentInfo, + order: LSPS1OrderParams, created_at: LSPSDateTime, payment: LSPS1PaymentInfo, ) -> Self { - Self { - state: OutboundRequestState::OrderCreated { order_id }, - config: OutboundLSPS1Config { order, created_at, payment }, - } - } - pub(super) fn awaiting_payment(&mut self) -> Result<(), LightningError> { - self.state = self.state.awaiting_payment()?; - Ok(()) + Self { config: OutboundLSPS1Config { order, created_at, payment } } } } diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index ac97b614855..df9d9f02894 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -193,7 +193,6 @@ where let channel = OutboundCRChannel::new( params.order.clone(), created_at, - order_id.clone(), payment.clone(), ); @@ -232,28 +231,6 @@ where match outer_state_lock.get(counterparty_node_id) { Some(inner_state_lock) => { let mut peer_state_lock = inner_state_lock.lock().unwrap(); - - let outbound_channel = peer_state_lock - .outbound_channels_by_order_id - .get_mut(¶ms.order_id) - .ok_or(LightningError { - err: format!( - "Received get order request for unknown order id {:?}", - params.order_id - ), - action: ErrorAction::IgnoreAndLog(Level::Info), - })?; - - if let Err(e) = outbound_channel.awaiting_payment() { - peer_state_lock.outbound_channels_by_order_id.remove(¶ms.order_id); - event_queue_notifier.enqueue(LSPS1ServiceEvent::Refund { - request_id, - counterparty_node_id: *counterparty_node_id, - order_id: params.order_id, - }); - return Err(e); - } - peer_state_lock .pending_requests .insert(request_id.clone(), LSPS1Request::GetOrder(params.clone())); From 77c9ae7ad9edbafffb27b4046e757c95385f2067 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 13:01:51 +0100 Subject: [PATCH 07/41] Replace `insert_outbound_channel` with `PeerState::new_order` .. requiring less access to internals --- lightning-liquidity/src/lsps1/peer_state.rs | 7 +++++-- lightning-liquidity/src/lsps1/service.rs | 8 ++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 3e9d17f4c73..729d6827330 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -21,9 +21,12 @@ pub(super) struct PeerState { } impl PeerState { - pub(super) fn insert_outbound_channel( - &mut self, order_id: LSPS1OrderId, channel: OutboundCRChannel, + pub(super) fn new_order( + &mut self, order_id: LSPS1OrderId, order_params: LSPS1OrderParams, + created_at: LSPSDateTime, payment_details: LSPS1PaymentInfo, ) { + let channel = OutboundCRChannel::new(order_params, created_at, payment_details); + self.outbound_channels_by_order_id.insert(order_id, channel); } } diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index df9d9f02894..bda7d6125dd 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -20,7 +20,7 @@ use super::msgs::{ LSPS1OrderState, LSPS1PaymentInfo, LSPS1Request, LSPS1Response, LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, }; -use super::peer_state::{OutboundCRChannel, PeerState}; +use super::peer_state::PeerState; use crate::message_queue::MessageQueue; use crate::events::EventQueue; @@ -190,14 +190,14 @@ where match peer_state_lock.pending_requests.remove(&request_id) { Some(LSPS1Request::CreateOrder(params)) => { let order_id = self.generate_order_id(); - let channel = OutboundCRChannel::new( + + peer_state_lock.new_order( + order_id.clone(), params.order.clone(), created_at, payment.clone(), ); - peer_state_lock.insert_outbound_channel(order_id.clone(), channel); - let response = LSPS1Response::CreateOrder(LSPS1CreateOrderResponse { order: params.order, order_id, From d4ab4d8dea261b24a6988bb6626cf5939459d81f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 13:35:36 +0100 Subject: [PATCH 08/41] Use `PeerState::{get_order, has_active_requests}` instead of map Previously, we'd directly access the internal `outbound_` map of `PeerState`. Here we refactor the code to avoid this. Note this also highlighted a bug in that we currently don't actually update/persist the order state in `update_order_state`. We don't fix this here, but just improve isolation for now, as all state update behavior will be reworked later. --- lightning-liquidity/src/lsps1/peer_state.rs | 27 ++++++++----- lightning-liquidity/src/lsps1/service.rs | 43 ++++++++++----------- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 729d6827330..9b79d25fa07 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -16,7 +16,7 @@ use crate::prelude::HashMap; #[derive(Default)] pub(super) struct PeerState { - pub(super) outbound_channels_by_order_id: HashMap, + outbound_channels_by_order_id: HashMap, pub(super) pending_requests: HashMap, } @@ -26,25 +26,32 @@ impl PeerState { created_at: LSPSDateTime, payment_details: LSPS1PaymentInfo, ) { let channel = OutboundCRChannel::new(order_params, created_at, payment_details); - self.outbound_channels_by_order_id.insert(order_id, channel); } + + pub(super) fn get_order<'a>(&'a self, order_id: &LSPS1OrderId) -> Option<&'a ChannelOrder> { + self.outbound_channels_by_order_id.get(order_id).map(|channel| &channel.order) + } + + pub(super) fn has_active_requests(&self) -> bool { + !self.outbound_channels_by_order_id.is_empty() + } } -pub(super) struct OutboundLSPS1Config { - pub(super) order: LSPS1OrderParams, +pub(super) struct ChannelOrder { + pub(super) order_params: LSPS1OrderParams, pub(super) created_at: LSPSDateTime, - pub(super) payment: LSPS1PaymentInfo, + pub(super) payment_details: LSPS1PaymentInfo, } -pub(super) struct OutboundCRChannel { - pub(super) config: OutboundLSPS1Config, +struct OutboundCRChannel { + order: ChannelOrder, } impl OutboundCRChannel { - pub(super) fn new( - order: LSPS1OrderParams, created_at: LSPSDateTime, payment: LSPS1PaymentInfo, + fn new( + order_params: LSPS1OrderParams, created_at: LSPSDateTime, payment_details: LSPS1PaymentInfo, ) -> Self { - Self { config: OutboundLSPS1Config { order, created_at, payment } } + Self { order: ChannelOrder { order_params, created_at, payment_details } } } } diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index bda7d6125dd..d73271ada47 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -96,7 +96,7 @@ where let outer_state_lock = self.per_peer_state.read().unwrap(); outer_state_lock.get(counterparty_node_id).map_or(false, |inner| { let peer_state = inner.lock().unwrap(); - !peer_state.outbound_channels_by_order_id.is_empty() + peer_state.has_active_requests() }) } @@ -270,29 +270,26 @@ where match outer_state_lock.get(&counterparty_node_id) { Some(inner_state_lock) => { - let mut peer_state_lock = inner_state_lock.lock().unwrap(); - - if let Some(outbound_channel) = - peer_state_lock.outbound_channels_by_order_id.get_mut(&order_id) - { - let config = &outbound_channel.config; - - let response = LSPS1Response::GetOrder(LSPS1CreateOrderResponse { - order_id, - order: config.order.clone(), - order_state, - created_at: config.created_at.clone(), - payment: config.payment.clone(), - channel, - }); - let msg = LSPS1Message::Response(request_id, response).into(); - message_queue_notifier.enqueue(&counterparty_node_id, msg); - Ok(()) - } else { - Err(APIError::APIMisuseError { + let peer_state_lock = inner_state_lock.lock().unwrap(); + let order = + peer_state_lock.get_order(&order_id).ok_or(APIError::APIMisuseError { err: format!("Channel with order_id {} not found", order_id.0), - }) - } + })?; + + // FIXME: we need to actually remember the order state (and eventually persist it) + // here. + + let response = LSPS1Response::GetOrder(LSPS1CreateOrderResponse { + order_id, + order: order.order_params.clone(), + order_state, + created_at: order.created_at.clone(), + payment: order.payment_details.clone(), + channel, + }); + let msg = LSPS1Message::Response(request_id, response).into(); + message_queue_notifier.enqueue(&counterparty_node_id, msg); + Ok(()) }, None => Err(APIError::APIMisuseError { err: format!("No existing state with counterparty {}", counterparty_node_id), From 20b5c456aa5227eb5141ea7494b43e366a6a08e5 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 14:11:22 +0100 Subject: [PATCH 09/41] Use `PeerState::{register,remove}_request` instead of map access We introduce two new methods on `PeerState` to avoid direct access to the internal `pending_requests` map. --- lightning-liquidity/src/lsps1/peer_state.rs | 35 +++++++++++++- lightning-liquidity/src/lsps1/service.rs | 52 +++++++++++++++------ 2 files changed, 71 insertions(+), 16 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 9b79d25fa07..70ddd12fc06 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -14,10 +14,12 @@ use super::msgs::{LSPS1OrderId, LSPS1OrderParams, LSPS1PaymentInfo, LSPS1Request use crate::lsps0::ser::{LSPSDateTime, LSPSRequestId}; use crate::prelude::HashMap; +use core::fmt; + #[derive(Default)] pub(super) struct PeerState { outbound_channels_by_order_id: HashMap, - pub(super) pending_requests: HashMap, + pending_requests: HashMap, } impl PeerState { @@ -33,11 +35,42 @@ impl PeerState { self.outbound_channels_by_order_id.get(order_id).map(|channel| &channel.order) } + pub(super) fn register_request( + &mut self, request_id: LSPSRequestId, request: LSPS1Request, + ) -> Result<(), PeerStateError> { + if self.pending_requests.contains_key(&request_id) { + return Err(PeerStateError::DuplicateRequestId); + } + self.pending_requests.insert(request_id, request); + Ok(()) + } + + pub(super) fn remove_request( + &mut self, request_id: &LSPSRequestId, + ) -> Result { + self.pending_requests.remove(request_id).ok_or(PeerStateError::UnknownRequestId) + } + pub(super) fn has_active_requests(&self) -> bool { !self.outbound_channels_by_order_id.is_empty() } } +#[derive(Debug, Copy, Clone)] +pub(super) enum PeerStateError { + UnknownRequestId, + DuplicateRequestId, +} + +impl fmt::Display for PeerStateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownRequestId => write!(f, "unknown request id"), + Self::DuplicateRequestId => write!(f, "duplicate request id"), + } + } +} + pub(super) struct ChannelOrder { pub(super) order_params: LSPS1OrderParams, pub(super) created_at: LSPSDateTime, diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index d73271ada47..4e518f54850 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -157,9 +157,12 @@ where .or_insert(Mutex::new(PeerState::default())); let mut peer_state_lock = inner_state_lock.lock().unwrap(); - peer_state_lock - .pending_requests - .insert(request_id.clone(), LSPS1Request::CreateOrder(params.clone())); + let request = LSPS1Request::CreateOrder(params.clone()); + peer_state_lock.register_request(request_id.clone(), request).map_err(|e| { + let err = format!("Failed to handle request due to: {}", e); + let action = ErrorAction::IgnoreAndLog(Level::Error); + LightningError { err, action } + })?; } event_queue_notifier.enqueue(LSPS1ServiceEvent::RequestForPaymentDetails { @@ -186,11 +189,15 @@ where match outer_state_lock.get(counterparty_node_id) { Some(inner_state_lock) => { let mut peer_state_lock = inner_state_lock.lock().unwrap(); - - match peer_state_lock.pending_requests.remove(&request_id) { - Some(LSPS1Request::CreateOrder(params)) => { + let request = peer_state_lock.remove_request(&request_id).map_err(|e| { + debug_assert!(false, "Failed to send response due to: {}", e); + let err = format!("Failed to send response due to: {}", e); + APIError::APIMisuseError { err } + })?; + + match request { + LSPS1Request::CreateOrder(params) => { let order_id = self.generate_order_id(); - peer_state_lock.new_order( order_id.clone(), params.order.clone(), @@ -201,6 +208,9 @@ where let response = LSPS1Response::CreateOrder(LSPS1CreateOrderResponse { order: params.order, order_id, + + // TODO, we need to set this in the peer/channel state, and send the + // set value here: order_state: LSPS1OrderState::Created, created_at, payment, @@ -210,14 +220,22 @@ where message_queue_notifier.enqueue(counterparty_node_id, msg); Ok(()) }, - - _ => Err(APIError::APIMisuseError { - err: format!("No pending buy request for request_id: {:?}", request_id), - }), + t => { + debug_assert!( + false, + "Failed to send response due to unexpected request type: {:?}", + t + ); + let err = format!( + "Failed to send response due to unexpected request type: {:?}", + t + ); + return Err(APIError::APIMisuseError { err }); + }, } }, None => Err(APIError::APIMisuseError { - err: format!("No state for the counterparty exists: {:?}", counterparty_node_id), + err: format!("No state for the counterparty exists: {}", counterparty_node_id), }), } } @@ -231,9 +249,13 @@ where match outer_state_lock.get(counterparty_node_id) { Some(inner_state_lock) => { let mut peer_state_lock = inner_state_lock.lock().unwrap(); - peer_state_lock - .pending_requests - .insert(request_id.clone(), LSPS1Request::GetOrder(params.clone())); + + let request = LSPS1Request::GetOrder(params.clone()); + peer_state_lock.register_request(request_id.clone(), request).map_err(|e| { + let err = format!("Failed to handle request due to: {}", e); + let action = ErrorAction::IgnoreAndLog(Level::Error); + LightningError { err, action } + })?; event_queue_notifier.enqueue(LSPS1ServiceEvent::CheckPaymentConfirmation { request_id, From 7c57a49d5bef5e3d8688b9d0a2371d00c674eb47 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 14:16:00 +0100 Subject: [PATCH 10/41] Drop `OutboundCRChannel` The `OutboundChannel` construct simply wrapped `ChannelOrder` which we can now simply use directly. --- lightning-liquidity/src/lsps1/peer_state.rs | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 70ddd12fc06..90700795365 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -18,7 +18,7 @@ use core::fmt; #[derive(Default)] pub(super) struct PeerState { - outbound_channels_by_order_id: HashMap, + outbound_channels_by_order_id: HashMap, pending_requests: HashMap, } @@ -27,12 +27,12 @@ impl PeerState { &mut self, order_id: LSPS1OrderId, order_params: LSPS1OrderParams, created_at: LSPSDateTime, payment_details: LSPS1PaymentInfo, ) { - let channel = OutboundCRChannel::new(order_params, created_at, payment_details); - self.outbound_channels_by_order_id.insert(order_id, channel); + let channel_order = ChannelOrder { order_params, created_at, payment_details }; + self.outbound_channels_by_order_id.insert(order_id, channel_order); } pub(super) fn get_order<'a>(&'a self, order_id: &LSPS1OrderId) -> Option<&'a ChannelOrder> { - self.outbound_channels_by_order_id.get(order_id).map(|channel| &channel.order) + self.outbound_channels_by_order_id.get(order_id) } pub(super) fn register_request( @@ -76,15 +76,3 @@ pub(super) struct ChannelOrder { pub(super) created_at: LSPSDateTime, pub(super) payment_details: LSPS1PaymentInfo, } - -struct OutboundCRChannel { - order: ChannelOrder, -} - -impl OutboundCRChannel { - fn new( - order_params: LSPS1OrderParams, created_at: LSPSDateTime, payment_details: LSPS1PaymentInfo, - ) -> Self { - Self { order: ChannelOrder { order_params, created_at, payment_details } } - } -} From c3ce7860b3e55266d60094595687048a555ac5b9 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 14:44:46 +0100 Subject: [PATCH 11/41] Actually remember the order state in `ChannelOrder` We here remember and update the order state and channel details in `ChannelOrder` --- lightning-liquidity/src/lsps1/peer_state.rs | 38 ++++++++++++++++---- lightning-liquidity/src/lsps1/service.rs | 40 ++++++++++----------- 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 90700795365..d6cedb13c6c 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -9,7 +9,10 @@ //! Contains peer state objects that are used by `LSPS1ServiceHandler`. -use super::msgs::{LSPS1OrderId, LSPS1OrderParams, LSPS1PaymentInfo, LSPS1Request}; +use super::msgs::{ + LSPS1ChannelInfo, LSPS1OrderId, LSPS1OrderParams, LSPS1OrderState, LSPS1PaymentInfo, + LSPS1Request, +}; use crate::lsps0::ser::{LSPSDateTime, LSPSRequestId}; use crate::prelude::HashMap; @@ -26,13 +29,31 @@ impl PeerState { pub(super) fn new_order( &mut self, order_id: LSPS1OrderId, order_params: LSPS1OrderParams, created_at: LSPSDateTime, payment_details: LSPS1PaymentInfo, - ) { - let channel_order = ChannelOrder { order_params, created_at, payment_details }; - self.outbound_channels_by_order_id.insert(order_id, channel_order); + ) -> ChannelOrder { + let order_state = LSPS1OrderState::Created; + let channel_details = None; + let channel_order = ChannelOrder { + order_params, + order_state, + created_at, + payment_details, + channel_details, + }; + self.outbound_channels_by_order_id.insert(order_id, channel_order.clone()); + channel_order } - pub(super) fn get_order<'a>(&'a self, order_id: &LSPS1OrderId) -> Option<&'a ChannelOrder> { - self.outbound_channels_by_order_id.get(order_id) + pub(super) fn update_order<'a>( + &'a mut self, order_id: &LSPS1OrderId, order_state: LSPS1OrderState, + channel_details: Option, + ) -> Result<&'a ChannelOrder, PeerStateError> { + let order = self + .outbound_channels_by_order_id + .get_mut(order_id) + .ok_or(PeerStateError::UnknownOrderId)?; + order.order_state = order_state; + order.channel_details = channel_details; + Ok(order) } pub(super) fn register_request( @@ -60,6 +81,7 @@ impl PeerState { pub(super) enum PeerStateError { UnknownRequestId, DuplicateRequestId, + UnknownOrderId, } impl fmt::Display for PeerStateError { @@ -67,12 +89,16 @@ impl fmt::Display for PeerStateError { match self { Self::UnknownRequestId => write!(f, "unknown request id"), Self::DuplicateRequestId => write!(f, "duplicate request id"), + Self::UnknownOrderId => write!(f, "unknown order id"), } } } +#[derive(Debug, Clone)] pub(super) struct ChannelOrder { pub(super) order_params: LSPS1OrderParams, + pub(super) order_state: LSPS1OrderState, pub(super) created_at: LSPSDateTime, pub(super) payment_details: LSPS1PaymentInfo, + pub(super) channel_details: Option, } diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 4e518f54850..5101881afd1 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -181,7 +181,7 @@ where /// [`LSPS1ServiceEvent::RequestForPaymentDetails`]: crate::lsps1::event::LSPS1ServiceEvent::RequestForPaymentDetails pub fn send_payment_details( &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, - payment: LSPS1PaymentInfo, created_at: LSPSDateTime, + payment_details: LSPS1PaymentInfo, created_at: LSPSDateTime, ) -> Result<(), APIError> { let mut message_queue_notifier = self.pending_messages.notifier(); @@ -198,23 +198,21 @@ where match request { LSPS1Request::CreateOrder(params) => { let order_id = self.generate_order_id(); - peer_state_lock.new_order( + let order = peer_state_lock.new_order( order_id.clone(), - params.order.clone(), + params.order, created_at, - payment.clone(), + payment_details, ); let response = LSPS1Response::CreateOrder(LSPS1CreateOrderResponse { - order: params.order, + order: order.order_params, order_id, - // TODO, we need to set this in the peer/channel state, and send the - // set value here: - order_state: LSPS1OrderState::Created, - created_at, - payment, - channel: None, + order_state: order.order_state, + created_at: order.created_at, + payment: order.payment_details, + channel: order.channel_details, }); let msg = LSPS1Message::Response(request_id, response).into(); message_queue_notifier.enqueue(counterparty_node_id, msg); @@ -284,7 +282,7 @@ where /// [`LSPS1ServiceEvent::CheckPaymentConfirmation`]: crate::lsps1::event::LSPS1ServiceEvent::CheckPaymentConfirmation pub fn update_order_status( &self, request_id: LSPSRequestId, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, - order_state: LSPS1OrderState, channel: Option, + order_state: LSPS1OrderState, channel_details: Option, ) -> Result<(), APIError> { let mut message_queue_notifier = self.pending_messages.notifier(); @@ -292,22 +290,20 @@ where match outer_state_lock.get(&counterparty_node_id) { Some(inner_state_lock) => { - let peer_state_lock = inner_state_lock.lock().unwrap(); - let order = - peer_state_lock.get_order(&order_id).ok_or(APIError::APIMisuseError { - err: format!("Channel with order_id {} not found", order_id.0), - })?; - - // FIXME: we need to actually remember the order state (and eventually persist it) - // here. + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + let order = peer_state_lock + .update_order(&order_id, order_state, channel_details) + .map_err(|e| APIError::APIMisuseError { + err: format!("Failed to update order: {:?}", e), + })?; let response = LSPS1Response::GetOrder(LSPS1CreateOrderResponse { order_id, order: order.order_params.clone(), - order_state, + order_state: order.order_state.clone(), created_at: order.created_at.clone(), payment: order.payment_details.clone(), - channel, + channel: order.channel_details.clone(), }); let msg = LSPS1Message::Response(request_id, response).into(); message_queue_notifier.enqueue(&counterparty_node_id, msg); From 4869a1d5db1a4abb5efbcb854264c2baf500528f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Dec 2025 09:49:46 +0100 Subject: [PATCH 12/41] `LSPS1ServiceHandler`: Use `TimeProvider` when creating new orders Since we by now have the `TimeProvider` trait, we might as well use it in `LSPS1ServiceHandler` instead of requiring the user to provide a `created_at` manually. Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps1/service.rs | 29 ++++++++++++++----- lightning-liquidity/src/manager.rs | 11 +++---- .../tests/lsps1_integration_tests.rs | 8 ++--- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 5101881afd1..a68a79c2efc 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -30,6 +30,7 @@ use crate::lsps0::ser::{ use crate::prelude::{new_hash_map, HashMap}; use crate::sync::{Arc, Mutex, RwLock}; use crate::utils; +use crate::utils::time::TimeProvider; use lightning::ln::channelmanager::AChannelManager; use lightning::ln::msgs::{ErrorAction, LightningError}; @@ -50,26 +51,35 @@ pub struct LSPS1ServiceConfig { } /// The main object allowing to send and receive bLIP-51 / LSPS1 messages. -pub struct LSPS1ServiceHandler -where +pub struct LSPS1ServiceHandler< + ES: EntropySource, + CM: Deref + Clone, + K: KVStore + Clone, + TP: Deref + Clone, +> where CM::Target: AChannelManager, + TP::Target: TimeProvider, { entropy_source: ES, _channel_manager: CM, pending_messages: Arc, pending_events: Arc>, per_peer_state: RwLock>>, + time_provider: TP, config: LSPS1ServiceConfig, } -impl LSPS1ServiceHandler +impl + LSPS1ServiceHandler where CM::Target: AChannelManager, + TP::Target: TimeProvider, { /// Constructs a `LSPS1ServiceHandler`. pub(crate) fn new( entropy_source: ES, pending_messages: Arc, - pending_events: Arc>, channel_manager: CM, config: LSPS1ServiceConfig, + pending_events: Arc>, channel_manager: CM, time_provider: TP, + config: LSPS1ServiceConfig, ) -> Self { Self { entropy_source, @@ -77,6 +87,7 @@ where pending_messages, pending_events, per_peer_state: RwLock::new(new_hash_map()), + time_provider, config, } } @@ -181,7 +192,7 @@ where /// [`LSPS1ServiceEvent::RequestForPaymentDetails`]: crate::lsps1::event::LSPS1ServiceEvent::RequestForPaymentDetails pub fn send_payment_details( &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, - payment_details: LSPS1PaymentInfo, created_at: LSPSDateTime, + payment_details: LSPS1PaymentInfo, ) -> Result<(), APIError> { let mut message_queue_notifier = self.pending_messages.notifier(); @@ -198,6 +209,9 @@ where match request { LSPS1Request::CreateOrder(params) => { let order_id = self.generate_order_id(); + let created_at = LSPSDateTime::new_from_duration_since_epoch( + self.time_provider.duration_since_epoch(), + ); let order = peer_state_lock.new_order( order_id.clone(), params.order, @@ -321,10 +335,11 @@ where } } -impl LSPSProtocolMessageHandler - for LSPS1ServiceHandler +impl + LSPSProtocolMessageHandler for LSPS1ServiceHandler where CM::Target: AChannelManager, + TP::Target: TimeProvider, { type ProtocolMessage = LSPS1Message; const PROTOCOL_NUMBER: Option = Some(1); diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 45a85e72003..4a3dd6e0424 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -283,7 +283,7 @@ pub struct LiquidityManager< lsps0_client_handler: LSPS0ClientHandler, lsps0_service_handler: Option, #[cfg(lsps1_service)] - lsps1_service_handler: Option>, + lsps1_service_handler: Option>, lsps1_client_handler: Option>, lsps2_service_handler: Option>, lsps2_client_handler: Option>, @@ -429,7 +429,7 @@ where kv_store.clone(), node_signer, lsps5_service_config.clone(), - time_provider, + time_provider.clone(), )) } else { None @@ -452,7 +452,7 @@ where #[cfg(lsps1_service)] let lsps1_service_handler = service_config.as_ref().and_then(|config| { if let Some(number) = - as LSPSProtocolMessageHandler>::PROTOCOL_NUMBER + as LSPSProtocolMessageHandler>::PROTOCOL_NUMBER { supported_protocols.push(number); } @@ -462,6 +462,7 @@ where Arc::clone(&pending_messages), Arc::clone(&pending_events), channel_manager.clone(), + time_provider, config.clone(), ) }) @@ -519,7 +520,7 @@ where /// Returns a reference to the LSPS1 server-side handler. #[cfg(lsps1_service)] - pub fn lsps1_service_handler(&self) -> Option<&LSPS1ServiceHandler> { + pub fn lsps1_service_handler(&self) -> Option<&LSPS1ServiceHandler> { self.lsps1_service_handler.as_ref() } @@ -1032,7 +1033,7 @@ where #[cfg(lsps1_service)] pub fn lsps1_service_handler( &self, - ) -> Option<&LSPS1ServiceHandler>> { + ) -> Option<&LSPS1ServiceHandler, TP>> { self.inner.lsps1_service_handler() } diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 5e842c6a111..0db96f591e9 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -7,7 +7,6 @@ use common::{get_lsps_message, LSPSNodes}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning_liquidity::events::LiquidityEvent; -use lightning_liquidity::lsps0::ser::LSPSDateTime; use lightning_liquidity::lsps1::client::LSPS1ClientConfig; use lightning_liquidity::lsps1::event::LSPS1ClientEvent; use lightning_liquidity::lsps1::event::LSPS1ServiceEvent; @@ -24,7 +23,6 @@ use lightning::ln::functional_test_utils::{ }; use lightning::util::test_utils::TestStore; -use std::str::FromStr; use std::sync::Arc; use lightning::ln::functional_test_utils::{create_network, Node}; @@ -177,10 +175,8 @@ fn lsps1_happy_path() { let onchain: LSPS1OnchainPaymentInfo = serde_json::from_str(json_str).expect("Failed to parse JSON"); let payment_info = LSPS1PaymentInfo { bolt11: None, bolt12: None, onchain: Some(onchain) }; - let _now = LSPSDateTime::from_str("2024-01-01T00:00:00Z").expect("Failed to parse date"); - - let _ = service_handler - .send_payment_details(_create_order_id.clone(), &client_node_id, payment_info.clone(), _now) + service_handler + .send_payment_details(_create_order_id.clone(), &client_node_id, payment_info.clone()) .unwrap(); let create_order_response = get_lsps_message!(service_node, client_node_id); From d2c12cb78826f2f2a201f5f6ff3117032789bc4e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Dec 2025 10:35:12 +0100 Subject: [PATCH 13/41] Require `supported_options` in `LSPS1ServiceConfig` In the future we might want to inline the fields in `LSPS1ServiceConfig` (especially once some are added that we'd want to always/never set for the user), but for now we just make the `supported_options` field in `LSPS1ServiceConfig` required, avoiding some dangerous `unwrap`s. --- lightning-liquidity/src/lsps1/service.rs | 19 ++++--------------- .../tests/lsps0_integration_tests.rs | 18 +++++++++++++++++- .../tests/lsps1_integration_tests.rs | 3 +-- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index a68a79c2efc..e8df66068a8 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -47,7 +47,7 @@ pub struct LSPS1ServiceConfig { /// A token to be send with each channel request. pub token: Option, /// The options supported by the LSP. - pub supported_options: Option, + pub supported_options: LSPS1Options, } /// The main object allowing to send and receive bLIP-51 / LSPS1 messages. @@ -117,15 +117,7 @@ where let mut message_queue_notifier = self.pending_messages.notifier(); let response = LSPS1Response::GetInfo(LSPS1GetInfoResponse { - options: self - .config - .supported_options - .clone() - .ok_or(LightningError { - err: format!("Configuration for LSP server not set."), - action: ErrorAction::IgnoreAndLog(Level::Info), - }) - .unwrap(), + options: self.config.supported_options.clone(), }); let msg = LSPS1Message::Response(request_id, response).into(); @@ -140,14 +132,11 @@ where let mut message_queue_notifier = self.pending_messages.notifier(); let event_queue_notifier = self.pending_events.notifier(); - if !is_valid(¶ms.order, &self.config.supported_options.as_ref().unwrap()) { + if !is_valid(¶ms.order, &self.config.supported_options) { let response = LSPS1Response::CreateOrderError(LSPSResponseError { code: LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, message: format!("Order does not match options supported by LSP server"), - data: Some(format!( - "Supported options are {:?}", - &self.config.supported_options.as_ref().unwrap() - )), + data: Some(format!("Supported options are {:?}", &self.config.supported_options)), }); let msg = LSPS1Message::Response(request_id, response).into(); message_queue_notifier.enqueue(counterparty_node_id, msg); diff --git a/lightning-liquidity/tests/lsps0_integration_tests.rs b/lightning-liquidity/tests/lsps0_integration_tests.rs index 423d49785f2..7f0e01bde92 100644 --- a/lightning-liquidity/tests/lsps0_integration_tests.rs +++ b/lightning-liquidity/tests/lsps0_integration_tests.rs @@ -9,6 +9,8 @@ use lightning_liquidity::lsps0::event::LSPS0ClientEvent; #[cfg(lsps1_service)] use lightning_liquidity::lsps1::client::LSPS1ClientConfig; #[cfg(lsps1_service)] +use lightning_liquidity::lsps1::msgs::LSPS1Options; +#[cfg(lsps1_service)] use lightning_liquidity::lsps1::service::LSPS1ServiceConfig; use lightning_liquidity::lsps2::client::LSPS2ClientConfig; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig; @@ -34,7 +36,21 @@ fn list_protocols_integration_test() { let promise_secret = [42; 32]; let lsps2_service_config = LSPS2ServiceConfig { promise_secret }; #[cfg(lsps1_service)] - let lsps1_service_config = LSPS1ServiceConfig { supported_options: None, token: None }; + let lsps1_service_config = { + let supported_options = LSPS1Options { + min_required_channel_confirmations: 0, + min_funding_confirms_within_blocks: 6, + supports_zero_channel_reserve: true, + max_channel_expiry_blocks: 144, + min_initial_client_balance_sat: 10_000_000, + max_initial_client_balance_sat: 100_000_000, + min_initial_lsp_balance_sat: 100_000, + max_initial_lsp_balance_sat: 100_000_000, + min_channel_balance_sat: 100_000, + max_channel_balance_sat: 100_000_000, + }; + LSPS1ServiceConfig { supported_options, token: None } + }; let lsps5_service_config = LSPS5ServiceConfig::default(); let service_config = LiquidityServiceConfig { #[cfg(lsps1_service)] diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 0db96f591e9..e799cced976 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -30,8 +30,7 @@ use lightning::ln::functional_test_utils::{create_network, Node}; fn build_lsps1_configs( supported_options: LSPS1Options, ) -> (LiquidityServiceConfig, LiquidityClientConfig) { - let lsps1_service_config = - LSPS1ServiceConfig { token: None, supported_options: Some(supported_options) }; + let lsps1_service_config = LSPS1ServiceConfig { token: None, supported_options }; let service_config = LiquidityServiceConfig { lsps1_service_config: Some(lsps1_service_config), lsps2_service_config: None, From 684b04d45c738cf21f3a4469f52ed2046b857757 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Dec 2025 11:13:22 +0100 Subject: [PATCH 14/41] Respond to `GetOrder` requests from our saved state Previously, we'd use an event to have the user check the order status and then call back in. As we already track the order status, we here change that to a model where we respond immediately based on our state and have the user/LSP update that state whenever it detects a change (e.g., a received payment, reorg, etc.). In the next commmit we will add/modify the corresponding API methods to do so. --- lightning-liquidity/src/lsps1/event.rs | 20 ----- lightning-liquidity/src/lsps1/msgs.rs | 2 + lightning-liquidity/src/lsps1/peer_state.rs | 14 ++- lightning-liquidity/src/lsps1/service.rs | 85 ++++++++++--------- .../tests/lsps1_integration_tests.rs | 26 ------ 5 files changed, 58 insertions(+), 89 deletions(-) diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs index fdf3fc57b0d..d966f8bdc2f 100644 --- a/lightning-liquidity/src/lsps1/event.rs +++ b/lightning-liquidity/src/lsps1/event.rs @@ -165,26 +165,6 @@ pub enum LSPS1ServiceEvent { /// The order requested by the client. order: LSPS1OrderParams, }, - /// A request from client to check the status of the payment. - /// - /// An event to poll for checking payment status either onchain or lightning. - /// - /// You must call [`LSPS1ServiceHandler::update_order_status`] to update the client - /// regarding the status of the payment and order. - /// - /// **Note: ** This event will *not* be persisted across restarts. - /// - /// [`LSPS1ServiceHandler::update_order_status`]: crate::lsps1::service::LSPS1ServiceHandler::update_order_status - CheckPaymentConfirmation { - /// An identifier that must be passed to [`LSPS1ServiceHandler::update_order_status`]. - /// - /// [`LSPS1ServiceHandler::update_order_status`]: crate::lsps1::service::LSPS1ServiceHandler::update_order_status - request_id: LSPSRequestId, - /// The node id of the client making the information request. - counterparty_node_id: PublicKey, - /// The order id of order with pending payment. - order_id: LSPS1OrderId, - }, /// If error is encountered, refund the amount if paid by the client. /// /// **Note: ** This event will *not* be persisted across restarts. diff --git a/lightning-liquidity/src/lsps1/msgs.rs b/lightning-liquidity/src/lsps1/msgs.rs index 8402827a4a6..4f79a13821a 100644 --- a/lightning-liquidity/src/lsps1/msgs.rs +++ b/lightning-liquidity/src/lsps1/msgs.rs @@ -32,6 +32,8 @@ pub(crate) const LSPS1_GET_ORDER_METHOD_NAME: &str = "lsps1.get_order"; pub(crate) const _LSPS1_CREATE_ORDER_REQUEST_INVALID_PARAMS_ERROR_CODE: i32 = -32602; #[cfg(lsps1_service)] pub(crate) const LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE: i32 = 100; +#[cfg(lsps1_service)] +pub(crate) const LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE: i32 = 101; /// The identifier of an order. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)] diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index d6cedb13c6c..83b4b2f4d5b 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -43,17 +43,27 @@ impl PeerState { channel_order } + pub(super) fn get_order<'a>( + &'a self, order_id: &LSPS1OrderId, + ) -> Result<&'a ChannelOrder, PeerStateError> { + let order = self + .outbound_channels_by_order_id + .get(order_id) + .ok_or(PeerStateError::UnknownOrderId)?; + Ok(order) + } + pub(super) fn update_order<'a>( &'a mut self, order_id: &LSPS1OrderId, order_state: LSPS1OrderState, channel_details: Option, - ) -> Result<&'a ChannelOrder, PeerStateError> { + ) -> Result<(), PeerStateError> { let order = self .outbound_channels_by_order_id .get_mut(order_id) .ok_or(PeerStateError::UnknownOrderId)?; order.order_state = order_state; order.channel_details = channel_details; - Ok(order) + Ok(()) } pub(super) fn register_request( diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index e8df66068a8..264896001b1 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -19,6 +19,7 @@ use super::msgs::{ LSPS1GetOrderRequest, LSPS1Message, LSPS1Options, LSPS1OrderId, LSPS1OrderParams, LSPS1OrderState, LSPS1PaymentInfo, LSPS1Request, LSPS1Response, LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, + LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, }; use super::peer_state::PeerState; use crate::message_queue::MessageQueue; @@ -245,71 +246,75 @@ where &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, params: LSPS1GetOrderRequest, ) -> Result<(), LightningError> { - let event_queue_notifier = self.pending_events.notifier(); + let mut message_queue_notifier = self.pending_messages.notifier(); let outer_state_lock = self.per_peer_state.read().unwrap(); match outer_state_lock.get(counterparty_node_id) { Some(inner_state_lock) => { - let mut peer_state_lock = inner_state_lock.lock().unwrap(); - - let request = LSPS1Request::GetOrder(params.clone()); - peer_state_lock.register_request(request_id.clone(), request).map_err(|e| { + let peer_state_lock = inner_state_lock.lock().unwrap(); + + let order = peer_state_lock.get_order(¶ms.order_id).map_err(|e| { + let response = LSPS1Response::GetOrderError(LSPSResponseError { + code: LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, + message: format!("Order with the requested order_id has not been found."), + data: None, + }); + let msg = LSPS1Message::Response(request_id.clone(), response).into(); + message_queue_notifier.enqueue(counterparty_node_id, msg); let err = format!("Failed to handle request due to: {}", e); let action = ErrorAction::IgnoreAndLog(Level::Error); LightningError { err, action } })?; - event_queue_notifier.enqueue(LSPS1ServiceEvent::CheckPaymentConfirmation { - request_id, - counterparty_node_id: *counterparty_node_id, + let response = LSPS1Response::GetOrder(LSPS1CreateOrderResponse { order_id: params.order_id, + order: order.order_params.clone(), + order_state: order.order_state.clone(), + created_at: order.created_at.clone(), + payment: order.payment_details.clone(), + channel: order.channel_details.clone(), }); + let msg = LSPS1Message::Response(request_id, response).into(); + message_queue_notifier.enqueue(&counterparty_node_id, msg); + Ok(()) }, None => { - return Err(LightningError { - err: format!("Received error response for a create order request from an unknown counterparty ({:?})", counterparty_node_id), - action: ErrorAction::IgnoreAndLog(Level::Info), + let response = LSPS1Response::GetOrderError(LSPSResponseError { + code: LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, + message: format!("Order with the requested order_id has not been found."), + data: None, }); + let msg = LSPS1Message::Response(request_id, response).into(); + message_queue_notifier.enqueue(counterparty_node_id, msg); + Err(LightningError { + err: format!( + "Received get_order request from an unknown counterparty ({:?})", + counterparty_node_id + ), + action: ErrorAction::IgnoreAndLog(Level::Info), + }) }, } - - Ok(()) } /// Used by LSP to give details to client regarding the status of channel opening. - /// Called to respond to client's GetOrder request. - /// The LSP continously polls for checking payment confirmation on-chain or lighting - /// and then responds to client request. - /// - /// Should be called in response to receiving a [`LSPS1ServiceEvent::CheckPaymentConfirmation`] event. /// - /// [`LSPS1ServiceEvent::CheckPaymentConfirmation`]: crate::lsps1::event::LSPS1ServiceEvent::CheckPaymentConfirmation + /// The LSP continously polls for checking payment confirmation on-chain or Lightning + /// and then responds to client request. pub fn update_order_status( - &self, request_id: LSPSRequestId, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, + &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, order_state: LSPS1OrderState, channel_details: Option, ) -> Result<(), APIError> { - let mut message_queue_notifier = self.pending_messages.notifier(); - let outer_state_lock = self.per_peer_state.read().unwrap(); match outer_state_lock.get(&counterparty_node_id) { Some(inner_state_lock) => { let mut peer_state_lock = inner_state_lock.lock().unwrap(); - let order = peer_state_lock - .update_order(&order_id, order_state, channel_details) - .map_err(|e| APIError::APIMisuseError { - err: format!("Failed to update order: {:?}", e), - })?; + peer_state_lock.update_order(&order_id, order_state, channel_details).map_err( + |e| APIError::APIMisuseError { + err: format!("Failed to update order: {:?}", e), + }, + )?; - let response = LSPS1Response::GetOrder(LSPS1CreateOrderResponse { - order_id, - order: order.order_params.clone(), - order_state: order.order_state.clone(), - created_at: order.created_at.clone(), - payment: order.payment_details.clone(), - channel: order.channel_details.clone(), - }); - let msg = LSPS1Message::Response(request_id, response).into(); - message_queue_notifier.enqueue(&counterparty_node_id, msg); Ok(()) }, None => Err(APIError::APIMisuseError { @@ -364,7 +369,7 @@ fn check_range(min: u64, max: u64, value: u64) -> bool { } fn is_valid(order: &LSPS1OrderParams, options: &LSPS1Options) -> bool { - let bool = check_range( + check_range( options.min_initial_client_balance_sat, options.max_initial_client_balance_sat, order.client_balance_sat, @@ -376,7 +381,5 @@ fn is_valid(order: &LSPS1OrderParams, options: &LSPS1Options) -> bool { 1, options.max_channel_expiry_blocks.into(), order.channel_expiry_blocks.into(), - ); - - bool + ) } diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index e799cced976..ef210a34a16 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -10,7 +10,6 @@ use lightning_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps1::client::LSPS1ClientConfig; use lightning_liquidity::lsps1::event::LSPS1ClientEvent; use lightning_liquidity::lsps1::event::LSPS1ServiceEvent; -use lightning_liquidity::lsps1::msgs::LSPS1OrderState; use lightning_liquidity::lsps1::msgs::{ LSPS1OnchainPaymentInfo, LSPS1Options, LSPS1OrderParams, LSPS1PaymentInfo, }; @@ -214,31 +213,6 @@ fn lsps1_happy_path() { .handle_custom_message(check_order_status, client_node_id) .unwrap(); - let _check_payment_confirmation_event = service_node.liquidity_manager.next_event().unwrap(); - - if let LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::CheckPaymentConfirmation { - request_id, - counterparty_node_id, - order_id, - }) = _check_payment_confirmation_event - { - assert_eq!(request_id, check_order_status_id); - assert_eq!(counterparty_node_id, client_node_id); - assert_eq!(order_id, expected_order_id.clone()); - } else { - panic!("Unexpected event"); - } - - let _ = service_handler - .update_order_status( - check_order_status_id.clone(), - client_node_id, - expected_order_id.clone(), - LSPS1OrderState::Created, - None, - ) - .unwrap(); - let order_status_response = get_lsps_message!(service_node, client_node_id); client_node From 45a39ecdf0058686ea22cd346909655c50c58e48 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Dec 2025 13:32:02 +0100 Subject: [PATCH 15/41] Add serialization logic for LSPS1 `PeerState` types We add the serializations for all types that will be persisted as part of the `PeerState`. --- lightning-liquidity/src/lsps1/msgs.rs | 81 ++++++++++++++++++++- lightning-liquidity/src/lsps1/peer_state.rs | 16 ++++ lightning/src/util/ser.rs | 51 +++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) diff --git a/lightning-liquidity/src/lsps1/msgs.rs b/lightning-liquidity/src/lsps1/msgs.rs index 4f79a13821a..5bf130400e1 100644 --- a/lightning-liquidity/src/lsps1/msgs.rs +++ b/lightning-liquidity/src/lsps1/msgs.rs @@ -19,8 +19,9 @@ use crate::lsps0::ser::{ }; use bitcoin::{Address, FeeRate, OutPoint}; - use lightning::offers::offer::Offer; +use lightning::util::ser::{Readable, Writeable}; +use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use lightning_invoice::Bolt11Invoice; use serde::{Deserialize, Serialize}; @@ -39,6 +40,23 @@ pub(crate) const LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE: i32 = 101; #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)] pub struct LSPS1OrderId(pub String); +impl Writeable for LSPS1OrderId { + fn write( + &self, writer: &mut W, + ) -> Result<(), lightning::io::Error> { + self.0.write(writer) + } +} + +impl Readable for LSPS1OrderId { + fn read( + reader: &mut R, + ) -> Result { + let inner = Readable::read(reader)?; + Ok(Self(inner)) + } +} + /// A request made to an LSP to retrieve the supported options. /// /// Please refer to the [bLIP-51 / LSPS1 @@ -128,6 +146,16 @@ pub struct LSPS1OrderParams { pub announce_channel: bool, } +impl_writeable_tlv_based!(LSPS1OrderParams, { + (0, lsp_balance_sat, required), + (2, client_balance_sat, required), + (4, required_channel_confirmations, required), + (6, funding_confirms_within_blocks, required), + (8, channel_expiry_blocks, required), + (10, token, option), + (12, announce_channel, required), +}); + /// A response to a [`LSPS1CreateOrderRequest`]. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct LSPS1CreateOrderResponse { @@ -158,6 +186,12 @@ pub enum LSPS1OrderState { Failed, } +impl_writeable_tlv_based_enum!(LSPS1OrderState, + (0, Created) => {}, + (2, Completed) => {}, + (4, Failed) => {} +); + /// Details regarding how to pay for an order. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct LSPS1PaymentInfo { @@ -169,6 +203,12 @@ pub struct LSPS1PaymentInfo { pub onchain: Option, } +impl_writeable_tlv_based!(LSPS1PaymentInfo, { + (0, bolt11, option), + (2, bolt12, option), + (4, onchain, option), +}); + /// A Lightning payment using BOLT 11. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct LSPS1Bolt11PaymentInfo { @@ -186,6 +226,14 @@ pub struct LSPS1Bolt11PaymentInfo { pub invoice: Bolt11Invoice, } +impl_writeable_tlv_based!(LSPS1Bolt11PaymentInfo, { + (0, state, required), + (2, expires_at, required), + (4, fee_total_sat, required), + (6, order_total_sat, required), + (8, invoice, required), +}); + /// A Lightning payment using BOLT 12. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct LSPS1Bolt12PaymentInfo { @@ -204,6 +252,14 @@ pub struct LSPS1Bolt12PaymentInfo { pub offer: Offer, } +impl_writeable_tlv_based!(LSPS1Bolt12PaymentInfo, { + (0, state, required), + (2, expires_at, required), + (4, fee_total_sat, required), + (6, order_total_sat, required), + (8, offer, required), +}); + /// An onchain payment. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct LSPS1OnchainPaymentInfo { @@ -235,6 +291,17 @@ pub struct LSPS1OnchainPaymentInfo { pub refund_onchain_address: Option
        , } +impl_writeable_tlv_based!(LSPS1OnchainPaymentInfo, { + (0, state, required), + (2, expires_at, required), + (4, fee_total_sat, required), + (6, order_total_sat, required), + (8, address, required), + (10, min_onchain_payment_confirmations, option), + (12, min_fee_for_0conf, required), + (14, refund_onchain_address, option), +}); + /// The state of a payment. /// /// *Note*: Previously, the spec also knew a `CANCELLED` state for BOLT11 payments, which has since @@ -251,6 +318,12 @@ pub enum LSPS1PaymentState { Refunded, } +impl_writeable_tlv_based_enum!(LSPS1PaymentState, + (0, ExpectPayment) => {}, + (2, Paid) => {}, + (4, Refunded) => {} +); + /// Details regarding a detected on-chain payment. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct LSPS1OnchainPayment { @@ -274,6 +347,12 @@ pub struct LSPS1ChannelInfo { pub expires_at: LSPSDateTime, } +impl_writeable_tlv_based!(LSPS1ChannelInfo, { + (0, funded_at, required), + (2, funding_outpoint, required), + (4, expires_at, required), +}); + /// A request made to an LSP to retrieve information about an previously made order. /// /// Please refer to the [bLIP-51 / LSPS1 diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 83b4b2f4d5b..796b4494a83 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -17,6 +17,9 @@ use super::msgs::{ use crate::lsps0::ser::{LSPSDateTime, LSPSRequestId}; use crate::prelude::HashMap; +use lightning::impl_writeable_tlv_based; +use lightning::util::hash_tables::new_hash_map; + use core::fmt; #[derive(Default)] @@ -87,6 +90,11 @@ impl PeerState { } } +impl_writeable_tlv_based!(PeerState, { + (0, outbound_channels_by_order_id, required), + (_unused, pending_requests, (static_value, new_hash_map())), +}); + #[derive(Debug, Copy, Clone)] pub(super) enum PeerStateError { UnknownRequestId, @@ -112,3 +120,11 @@ pub(super) struct ChannelOrder { pub(super) payment_details: LSPS1PaymentInfo, pub(super) channel_details: Option, } + +impl_writeable_tlv_based!(ChannelOrder, { + (0, order_params, required), + (2, order_state, required), + (4, created_at, required), + (6, payment_details, required), + (8, channel_details, option), +}); diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index 2eace55a4bf..81d2afd7e72 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -22,10 +22,12 @@ use crate::sync::{Mutex, RwLock}; use core::cmp; use core::hash::Hash; use core::ops::Deref; +use core::str::FromStr; use alloc::collections::BTreeMap; use bitcoin::absolute::LockTime as AbsoluteLockTime; +use bitcoin::address::Address; use bitcoin::amount::{Amount, SignedAmount}; use bitcoin::consensus::Encodable; use bitcoin::constants::ChainHash; @@ -46,6 +48,8 @@ use bitcoin::{consensus, Sequence, TxIn, Weight, Witness}; use dnssec_prover::rr::Name; +use lightning_invoice::Bolt11Invoice; + use crate::chain::ClaimId; #[cfg(taproot)] use crate::ln::msgs::PartialSignatureWithNonce; @@ -1499,6 +1503,53 @@ impl Readable for OutPoint { } } +impl Writeable for Address { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + self.to_string().write(w)?; + Ok(()) + } +} + +impl Readable for Address { + fn read(r: &mut R) -> Result { + let addr_string: String = Readable::read(r)?; + let addr = Address::from_str(&addr_string) + .map_err(|_| DecodeError::InvalidValue)? + .assume_checked(); + Ok(addr) + } +} + +impl Writeable for FeeRate { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + self.to_sat_per_kwu().write(w)?; + Ok(()) + } +} + +impl Readable for FeeRate { + fn read(r: &mut R) -> Result { + let sat_per_kwu: u64 = Readable::read(r)?; + Ok(FeeRate::from_sat_per_kwu(sat_per_kwu)) + } +} + +impl Writeable for Bolt11Invoice { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + self.to_string().write(w)?; + Ok(()) + } +} + +impl Readable for Bolt11Invoice { + fn read(r: &mut R) -> Result { + let invoice_string: String = Readable::read(r)?; + let invoice = + Bolt11Invoice::from_str(&invoice_string).map_err(|_| DecodeError::InvalidValue)?; + Ok(invoice) + } +} + macro_rules! impl_consensus_ser { ($bitcoin_type: ty) => { impl Writeable for $bitcoin_type { From 7de83848e8bc96434def71665b536251ad690eb8 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 18 Feb 2026 13:58:49 +0100 Subject: [PATCH 16/41] f Remove redundant `FeeRate` `Writeable`/`Readable` implementations --- lightning/src/util/ser.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index 81d2afd7e72..50665152a96 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -1520,20 +1520,6 @@ impl Readable for Address { } } -impl Writeable for FeeRate { - fn write(&self, w: &mut W) -> Result<(), io::Error> { - self.to_sat_per_kwu().write(w)?; - Ok(()) - } -} - -impl Readable for FeeRate { - fn read(r: &mut R) -> Result { - let sat_per_kwu: u64 = Readable::read(r)?; - Ok(FeeRate::from_sat_per_kwu(sat_per_kwu)) - } -} - impl Writeable for Bolt11Invoice { fn write(&self, w: &mut W) -> Result<(), io::Error> { self.to_string().write(w)?; From b707bb00923c173557e25d5dd7f1feeaaeaa4ed5 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Dec 2025 14:24:04 +0100 Subject: [PATCH 17/41] Implement `LSPS1ServiceHandler` persistence and state pruning We follow the model already employed in LSPS2/LSPS5 and implement state pruning and persistence for `LSPS1ServiceHandler` state. Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps1/peer_state.rs | 55 ++++ lightning-liquidity/src/lsps1/service.rs | 308 ++++++++++++++++-- lightning-liquidity/src/manager.rs | 21 +- lightning-liquidity/src/persist.rs | 6 + .../tests/lsps1_integration_tests.rs | 2 +- 5 files changed, 365 insertions(+), 27 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 796b4494a83..f9e7635d78c 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -26,6 +26,7 @@ use core::fmt; pub(super) struct PeerState { outbound_channels_by_order_id: HashMap, pending_requests: HashMap, + needs_persist: bool, } impl PeerState { @@ -43,6 +44,7 @@ impl PeerState { channel_details, }; self.outbound_channels_by_order_id.insert(order_id, channel_order.clone()); + self.needs_persist |= true; channel_order } @@ -66,6 +68,7 @@ impl PeerState { .ok_or(PeerStateError::UnknownOrderId)?; order.order_state = order_state; order.channel_details = channel_details; + self.needs_persist |= true; Ok(()) } @@ -88,11 +91,39 @@ impl PeerState { pub(super) fn has_active_requests(&self) -> bool { !self.outbound_channels_by_order_id.is_empty() } + + pub(super) fn needs_persist(&self) -> bool { + self.needs_persist + } + + pub(super) fn set_needs_persist(&mut self, needs_persist: bool) { + self.needs_persist = needs_persist; + } + + pub(super) fn is_prunable(&self) -> bool { + // Return whether the entire state is empty. + self.pending_requests.is_empty() && self.outbound_channels_by_order_id.is_empty() + } + + pub(super) fn prune_pending_requests(&mut self) { + self.pending_requests.clear() + } + + pub(super) fn prune_expired_request_state(&mut self) { + self.outbound_channels_by_order_id.retain(|_order_id, entry| { + if entry.is_prunable() { + self.needs_persist |= true; + return false; + } + true + }); + } } impl_writeable_tlv_based!(PeerState, { (0, outbound_channels_by_order_id, required), (_unused, pending_requests, (static_value, new_hash_map())), + (_unused, needs_persist, (static_value, false)), }); #[derive(Debug, Copy, Clone)] @@ -121,6 +152,30 @@ pub(super) struct ChannelOrder { pub(super) channel_details: Option, } +impl ChannelOrder { + fn is_prunable(&self) -> bool { + let all_payment_details_expired; + #[cfg(feature = "time")] + { + let details = &self.payment_details; + all_payment_details_expired = + details.bolt11.as_ref().map_or(true, |d| d.expires_at.is_past()) + && details.bolt12.as_ref().map_or(true, |d| d.expires_at.is_past()) + && details.onchain.as_ref().map_or(true, |d| d.expires_at.is_past()); + } + #[cfg(not(feature = "time"))] + { + // TODO: We need to find a way to check expiry times in no-std builds. + all_payment_details_expired = false; + } + + let created_or_failed = + matches!(self.order_state, LSPS1OrderState::Created | LSPS1OrderState::Failed); + + all_payment_details_expired && created_or_failed + } +} + impl_writeable_tlv_based!(ChannelOrder, { (0, order_params, required), (2, order_state, required), diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 264896001b1..3b80591f6b2 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -9,9 +9,14 @@ //! Contains the main bLIP-51 / LSPS1 server object, [`LSPS1ServiceHandler`]. -use alloc::string::String; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::future::Future as StdFuture; use core::ops::Deref; +use core::pin::pin; +use core::sync::atomic::{AtomicUsize, Ordering}; +use core::task; use super::event::LSPS1ServiceEvent; use super::msgs::{ @@ -28,9 +33,14 @@ use crate::events::EventQueue; use crate::lsps0::ser::{ LSPSDateTime, LSPSProtocolMessageHandler, LSPSRequestId, LSPSResponseError, }; +use crate::persist::{ + LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE, +}; +use crate::prelude::hash_map::Entry; use crate::prelude::{new_hash_map, HashMap}; use crate::sync::{Arc, Mutex, RwLock}; use crate::utils; +use crate::utils::async_poll::dummy_waker; use crate::utils::time::TimeProvider; use lightning::ln::channelmanager::AChannelManager; @@ -39,6 +49,7 @@ use lightning::sign::EntropySource; use lightning::util::errors::APIError; use lightning::util::logger::Level; use lightning::util::persist::KVStore; +use lightning::util::ser::Writeable; use bitcoin::secp256k1::PublicKey; @@ -63,9 +74,11 @@ pub struct LSPS1ServiceHandler< { entropy_source: ES, _channel_manager: CM, + kv_store: K, pending_messages: Arc, pending_events: Arc>, per_peer_state: RwLock>>, + persistence_in_flight: AtomicUsize, time_provider: TP, config: LSPS1ServiceConfig, } @@ -79,15 +92,17 @@ where /// Constructs a `LSPS1ServiceHandler`. pub(crate) fn new( entropy_source: ES, pending_messages: Arc, - pending_events: Arc>, channel_manager: CM, time_provider: TP, + pending_events: Arc>, channel_manager: CM, kv_store: K, time_provider: TP, config: LSPS1ServiceConfig, ) -> Self { Self { entropy_source, _channel_manager: channel_manager, + kv_store, pending_messages, pending_events, per_peer_state: RwLock::new(new_hash_map()), + persistence_in_flight: AtomicUsize::new(0), time_provider, config, } @@ -106,12 +121,153 @@ where /// Pending requests that are still awaiting our response are deliberately NOT counted. pub(crate) fn has_active_requests(&self, counterparty_node_id: &PublicKey) -> bool { let outer_state_lock = self.per_peer_state.read().unwrap(); - outer_state_lock.get(counterparty_node_id).map_or(false, |inner| { + outer_state_lock.get(counterparty_node_id).is_some_and(|inner| { let peer_state = inner.lock().unwrap(); peer_state.has_active_requests() }) } + pub(crate) fn peer_disconnected(&self, counterparty_node_id: PublicKey) { + let outer_state_lock = self.per_peer_state.write().unwrap(); + if let Some(inner_state_lock) = outer_state_lock.get(&counterparty_node_id) { + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + // We clean up the peer state, but leave removing the peer entry to the prune logic in + // `persist` which removes it from the store. + peer_state_lock.prune_pending_requests(); + peer_state_lock.prune_expired_request_state(); + } + } + + pub(crate) async fn persist(&self) -> Result { + // TODO: We should eventually persist in parallel, however, when we do, we probably want to + // introduce some batching to upper-bound the number of requests inflight at any given + // time. + let mut did_persist = false; + + if self.persistence_in_flight.fetch_add(1, Ordering::AcqRel) > 0 { + // If we're not the first event processor to get here, just return early, the increment + // we just did will be treated as "go around again" at the end. + return Ok(did_persist); + } + + loop { + let mut need_remove = Vec::new(); + let mut need_persist = Vec::new(); + + { + // First build a list of peers to persist and prune with the read lock. This allows + // us to avoid the write lock unless we actually need to remove a node. + let outer_state_lock = self.per_peer_state.read().unwrap(); + for (counterparty_node_id, inner_state_lock) in outer_state_lock.iter() { + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + peer_state_lock.prune_expired_request_state(); + let is_prunable = peer_state_lock.is_prunable(); + if is_prunable { + need_remove.push(*counterparty_node_id); + } else if peer_state_lock.needs_persist() { + need_persist.push(*counterparty_node_id); + } + } + } + + for counterparty_node_id in need_persist.into_iter() { + debug_assert!(!need_remove.contains(&counterparty_node_id)); + self.persist_peer_state(counterparty_node_id).await?; + did_persist = true; + } + + for counterparty_node_id in need_remove { + let mut future_opt = None; + { + // We need to take the `per_peer_state` write lock to remove an entry, but also + // have to hold it until after the `remove` call returns (but not through + // future completion) to ensure that writes for the peer's state are + // well-ordered with other `persist_peer_state` calls even across the removal + // itself. + let mut per_peer_state = self.per_peer_state.write().unwrap(); + if let Entry::Occupied(mut entry) = per_peer_state.entry(counterparty_node_id) { + let state = entry.get_mut().get_mut().unwrap(); + if state.is_prunable() { + entry.remove(); + let key = counterparty_node_id.to_string(); + future_opt = Some(self.kv_store.remove( + LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE, + &key, + true, + )); + } else { + // If the peer got new state, force a re-persist of the current state. + state.set_needs_persist(true); + } + } else { + // This should never happen, we can only have one `persist` call + // in-progress at once and map entries are only removed by it. + debug_assert!(false); + } + } + if let Some(future) = future_opt { + future.await?; + did_persist = true; + } else { + self.persist_peer_state(counterparty_node_id).await?; + } + } + + if self.persistence_in_flight.fetch_sub(1, Ordering::AcqRel) != 1 { + // If another thread incremented the state while we were running we should go + // around again, but only once. + self.persistence_in_flight.store(1, Ordering::Release); + continue; + } + break; + } + + Ok(did_persist) + } + + async fn persist_peer_state( + &self, counterparty_node_id: PublicKey, + ) -> Result<(), lightning::io::Error> { + let fut = { + let outer_state_lock = self.per_peer_state.read().unwrap(); + match outer_state_lock.get(&counterparty_node_id) { + None => { + // We dropped the peer state by now. + return Ok(()); + }, + Some(entry) => { + let mut peer_state_lock = entry.lock().unwrap(); + if !peer_state_lock.needs_persist() { + // We already have persisted otherwise by now. + return Ok(()); + } else { + peer_state_lock.set_needs_persist(false); + let key = counterparty_node_id.to_string(); + let encoded = peer_state_lock.encode(); + // Begin the write with the entry lock held. This avoids racing with + // potentially-in-flight `persist` calls writing state for the same peer. + self.kv_store.write( + LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE, + &key, + encoded, + ) + } + }, + } + }; + + fut.await.map_err(|e| { + self.per_peer_state + .read() + .unwrap() + .get(&counterparty_node_id) + .map(|p| p.lock().unwrap().set_needs_persist(true)); + e + }) + } + fn handle_get_info_request( &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, ) -> Result<(), LightningError> { @@ -180,14 +336,14 @@ where /// Should be called in response to receiving a [`LSPS1ServiceEvent::RequestForPaymentDetails`] event. /// /// [`LSPS1ServiceEvent::RequestForPaymentDetails`]: crate::lsps1::event::LSPS1ServiceEvent::RequestForPaymentDetails - pub fn send_payment_details( - &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, + pub async fn send_payment_details( + &self, request_id: LSPSRequestId, counterparty_node_id: PublicKey, payment_details: LSPS1PaymentInfo, ) -> Result<(), APIError> { let mut message_queue_notifier = self.pending_messages.notifier(); + let mut should_persist = false; - let outer_state_lock = self.per_peer_state.read().unwrap(); - match outer_state_lock.get(counterparty_node_id) { + match self.per_peer_state.read().unwrap().get(&counterparty_node_id) { Some(inner_state_lock) => { let mut peer_state_lock = inner_state_lock.lock().unwrap(); let request = peer_state_lock.remove_request(&request_id).map_err(|e| { @@ -208,6 +364,7 @@ where created_at, payment_details, ); + should_persist |= peer_state_lock.needs_persist(); let response = LSPS1Response::CreateOrder(LSPS1CreateOrderResponse { order: order.order_params, @@ -219,8 +376,7 @@ where channel: order.channel_details, }); let msg = LSPS1Message::Response(request_id, response).into(); - message_queue_notifier.enqueue(counterparty_node_id, msg); - Ok(()) + message_queue_notifier.enqueue(&counterparty_node_id, msg); }, t => { debug_assert!( @@ -236,10 +392,25 @@ where }, } }, - None => Err(APIError::APIMisuseError { - err: format!("No state for the counterparty exists: {}", counterparty_node_id), - }), + None => { + return Err(APIError::APIMisuseError { + err: format!("No state for the counterparty exists: {}", counterparty_node_id), + }); + }, + } + + if should_persist { + self.persist_peer_state(counterparty_node_id).await.map_err(|e| { + APIError::APIMisuseError { + err: format!( + "Failed to persist peer state for {}: {}", + counterparty_node_id, e + ), + } + })?; } + + Ok(()) } fn handle_get_order_request( @@ -300,13 +471,12 @@ where /// /// The LSP continously polls for checking payment confirmation on-chain or Lightning /// and then responds to client request. - pub fn update_order_status( + pub async fn update_order_status( &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, order_state: LSPS1OrderState, channel_details: Option, ) -> Result<(), APIError> { - let outer_state_lock = self.per_peer_state.read().unwrap(); - - match outer_state_lock.get(&counterparty_node_id) { + let mut should_persist = false; + match self.per_peer_state.read().unwrap().get(&counterparty_node_id) { Some(inner_state_lock) => { let mut peer_state_lock = inner_state_lock.lock().unwrap(); peer_state_lock.update_order(&order_id, order_state, channel_details).map_err( @@ -314,13 +484,27 @@ where err: format!("Failed to update order: {:?}", e), }, )?; - - Ok(()) + should_persist |= peer_state_lock.needs_persist(); + }, + None => { + return Err(APIError::APIMisuseError { + err: format!("No existing state with counterparty {}", counterparty_node_id), + }); }, - None => Err(APIError::APIMisuseError { - err: format!("No existing state with counterparty {}", counterparty_node_id), - }), } + + if should_persist { + self.persist_peer_state(counterparty_node_id).await.map_err(|e| { + APIError::APIMisuseError { + err: format!( + "Failed to persist peer state for {}: {}", + counterparty_node_id, e + ), + } + })?; + } + + Ok(()) } fn generate_order_id(&self) -> LSPS1OrderId { @@ -364,6 +548,88 @@ where } } +/// A synchroneous wrapper around [`LSPS1ServiceHandler`] to be used in contexts where async is not +/// available. +pub struct LSPS1ServiceHandlerSync< + 'a, + ES: EntropySource, + CM: Deref + Clone, + K: KVStore + Clone, + TP: Deref + Clone, +> where + CM::Target: AChannelManager, + TP::Target: TimeProvider, +{ + inner: &'a LSPS1ServiceHandler, +} + +impl<'a, ES: EntropySource, CM: Deref + Clone, K: KVStore + Clone, TP: Deref + Clone> + LSPS1ServiceHandlerSync<'a, ES, CM, K, TP> +where + CM::Target: AChannelManager, + TP::Target: TimeProvider, +{ + pub(crate) fn from_inner(inner: &'a LSPS1ServiceHandler) -> Self { + Self { inner } + } + + /// Returns a reference to the used config. + /// + /// Wraps [`LSPS1ServiceHandler::config`]. + pub fn config(&self) -> &LSPS1ServiceConfig { + &self.inner.config + } + + /// Used by LSP to send response containing details regarding the channel fees and payment information. + /// + /// Wraps [`LSPS1ServiceHandler::send_payment_details`]. + pub fn send_payment_details( + &self, request_id: LSPSRequestId, counterparty_node_id: PublicKey, + payment_details: LSPS1PaymentInfo, + ) -> Result<(), APIError> { + let mut fut = pin!(self.inner.send_payment_details( + request_id, + counterparty_node_id, + payment_details + )); + + let mut waker = dummy_waker(); + let mut ctx = task::Context::from_waker(&mut waker); + match fut.as_mut().poll(&mut ctx) { + task::Poll::Ready(result) => result, + task::Poll::Pending => { + // In a sync context, we can't wait for the future to complete. + unreachable!("Should not be pending in a sync context"); + }, + } + } + + /// Used by LSP to give details to client regarding the status of channel opening. + /// + /// Wraps [`LSPS1ServiceHandler::update_order_status`]. + pub fn update_order_status( + &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, + order_state: LSPS1OrderState, channel_details: Option, + ) -> Result<(), APIError> { + let mut fut = pin!(self.inner.update_order_status( + counterparty_node_id, + order_id, + order_state, + channel_details + )); + + let mut waker = dummy_waker(); + let mut ctx = task::Context::from_waker(&mut waker); + match fut.as_mut().poll(&mut ctx) { + task::Poll::Ready(result) => result, + task::Poll::Pending => { + // In a sync context, we can't wait for the future to complete. + unreachable!("Should not be pending in a sync context"); + }, + } + } +} + fn check_range(min: u64, max: u64, value: u64) -> bool { (value >= min) && (value <= max) } diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 4a3dd6e0424..e5d19f706a7 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -30,7 +30,7 @@ use crate::persist::{ use crate::lsps1::client::{LSPS1ClientConfig, LSPS1ClientHandler}; use crate::lsps1::msgs::LSPS1Message; #[cfg(lsps1_service)] -use crate::lsps1::service::{LSPS1ServiceConfig, LSPS1ServiceHandler}; +use crate::lsps1::service::{LSPS1ServiceConfig, LSPS1ServiceHandler, LSPS1ServiceHandlerSync}; use crate::lsps2::client::{LSPS2ClientConfig, LSPS2ClientHandler}; use crate::lsps2::msgs::LSPS2Message; @@ -462,6 +462,7 @@ where Arc::clone(&pending_messages), Arc::clone(&pending_events), channel_manager.clone(), + kv_store.clone(), time_provider, config.clone(), ) @@ -623,6 +624,11 @@ where let mut did_persist = false; did_persist |= self.pending_events.persist().await?; + #[cfg(lsps1_service)] + if let Some(lsps1_service_handler) = self.lsps1_service_handler.as_ref() { + did_persist |= lsps1_service_handler.persist().await?; + } + if let Some(lsps2_service_handler) = self.lsps2_service_handler.as_ref() { did_persist |= lsps2_service_handler.persist().await?; } @@ -879,6 +885,11 @@ where // If the peer was misbehaving, drop it from the ignored list to cleanup the kept state. self.ignored_peers.write().unwrap().remove(&counterparty_node_id); + #[cfg(lsps1_service)] + if let Some(lsps1_service_handler) = self.lsps1_service_handler.as_ref() { + lsps1_service_handler.peer_disconnected(counterparty_node_id); + } + if let Some(lsps2_service_handler) = self.lsps2_service_handler.as_ref() { lsps2_service_handler.peer_disconnected(counterparty_node_id); } @@ -1031,10 +1042,10 @@ where /// /// Wraps [`LiquidityManager::lsps1_service_handler`]. #[cfg(lsps1_service)] - pub fn lsps1_service_handler( - &self, - ) -> Option<&LSPS1ServiceHandler, TP>> { - self.inner.lsps1_service_handler() + pub fn lsps1_service_handler<'a>( + &'a self, + ) -> Option, TP>> { + self.inner.lsps1_service_handler.as_ref().map(|r| LSPS1ServiceHandlerSync::from_inner(r)) } /// Returns a reference to the LSPS2 client-side handler. diff --git a/lightning-liquidity/src/persist.rs b/lightning-liquidity/src/persist.rs index d0199440514..9518b409cdf 100644 --- a/lightning-liquidity/src/persist.rs +++ b/lightning-liquidity/src/persist.rs @@ -39,6 +39,12 @@ pub const LIQUIDITY_MANAGER_EVENT_QUEUE_PERSISTENCE_SECONDARY_NAMESPACE: &str = /// [`LiquidityManager`]: crate::LiquidityManager pub const LIQUIDITY_MANAGER_EVENT_QUEUE_PERSISTENCE_KEY: &str = "event_queue"; +/// The secondary namespace under which the [`LSPS1ServiceHandler`] data will be persisted. +/// +/// [`LSPS1ServiceHandler`]: crate::lsps1::service::LSPS1ServiceHandler +#[cfg(lsps1_service)] +pub const LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE: &str = "lsps1_service"; + /// The secondary namespace under which the [`LSPS2ServiceHandler`] data will be persisted. /// /// [`LSPS2ServiceHandler`]: crate::lsps2::service::LSPS2ServiceHandler diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index ef210a34a16..0343116a650 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -174,7 +174,7 @@ fn lsps1_happy_path() { serde_json::from_str(json_str).expect("Failed to parse JSON"); let payment_info = LSPS1PaymentInfo { bolt11: None, bolt12: None, onchain: Some(onchain) }; service_handler - .send_payment_details(_create_order_id.clone(), &client_node_id, payment_info.clone()) + .send_payment_details(_create_order_id.clone(), client_node_id, payment_info.clone()) .unwrap(); let create_order_response = get_lsps_message!(service_node, client_node_id); From ab47e724131776b39a28924cb3cd24d7dc5559a6 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 16:17:31 +0100 Subject: [PATCH 18/41] Read persisted LSPS1ServiceHandler state on startup .. we read the persisted state in `LiquidityManager::new` Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps1/mod.rs | 2 +- lightning-liquidity/src/lsps1/peer_state.rs | 2 +- lightning-liquidity/src/lsps1/service.rs | 10 ++--- lightning-liquidity/src/manager.rs | 34 ++++++++++------ lightning-liquidity/src/persist.rs | 44 +++++++++++++++++++++ 5 files changed, 73 insertions(+), 19 deletions(-) diff --git a/lightning-liquidity/src/lsps1/mod.rs b/lightning-liquidity/src/lsps1/mod.rs index bdfc4045f54..2270abe2fa3 100644 --- a/lightning-liquidity/src/lsps1/mod.rs +++ b/lightning-liquidity/src/lsps1/mod.rs @@ -13,6 +13,6 @@ pub mod client; pub mod event; pub mod msgs; #[cfg(lsps1_service)] -mod peer_state; +pub(crate) mod peer_state; #[cfg(lsps1_service)] pub mod service; diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index f9e7635d78c..e0377dfc2db 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -23,7 +23,7 @@ use lightning::util::hash_tables::new_hash_map; use core::fmt; #[derive(Default)] -pub(super) struct PeerState { +pub(crate) struct PeerState { outbound_channels_by_order_id: HashMap, pending_requests: HashMap, needs_persist: bool, diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 3b80591f6b2..bc55a1c8d91 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -37,7 +37,7 @@ use crate::persist::{ LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE, }; use crate::prelude::hash_map::Entry; -use crate::prelude::{new_hash_map, HashMap}; +use crate::prelude::HashMap; use crate::sync::{Arc, Mutex, RwLock}; use crate::utils; use crate::utils::async_poll::dummy_waker; @@ -91,9 +91,9 @@ where { /// Constructs a `LSPS1ServiceHandler`. pub(crate) fn new( - entropy_source: ES, pending_messages: Arc, - pending_events: Arc>, channel_manager: CM, kv_store: K, time_provider: TP, - config: LSPS1ServiceConfig, + per_peer_state: HashMap>, entropy_source: ES, + pending_messages: Arc, pending_events: Arc>, + channel_manager: CM, kv_store: K, time_provider: TP, config: LSPS1ServiceConfig, ) -> Self { Self { entropy_source, @@ -101,7 +101,7 @@ where kv_store, pending_messages, pending_events, - per_peer_state: RwLock::new(new_hash_map()), + per_peer_state: RwLock::new(per_peer_state), persistence_in_flight: AtomicUsize::new(0), time_provider, config, diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index e5d19f706a7..187a9cb97a7 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -23,6 +23,8 @@ use crate::lsps5::client::{LSPS5ClientConfig, LSPS5ClientHandler}; use crate::lsps5::msgs::LSPS5Message; use crate::lsps5::service::{LSPS5ServiceConfig, LSPS5ServiceHandler}; use crate::message_queue::MessageQueue; +#[cfg(lsps1_service)] +use crate::persist::read_lsps1_service_peer_states; use crate::persist::{ read_event_queue, read_lsps2_service_peer_states, read_lsps5_service_peer_states, }; @@ -450,24 +452,32 @@ where }); #[cfg(lsps1_service)] - let lsps1_service_handler = service_config.as_ref().and_then(|config| { - if let Some(number) = - as LSPSProtocolMessageHandler>::PROTOCOL_NUMBER - { - supported_protocols.push(number); - } - config.lsps1_service_config.as_ref().map(|config| { - LSPS1ServiceHandler::new( + let lsps1_service_handler = if let Some(service_config) = service_config.as_ref() { + if let Some(lsps1_service_config) = service_config.lsps1_service_config.as_ref() { + if let Some(number) = + as LSPSProtocolMessageHandler>::PROTOCOL_NUMBER + { + supported_protocols.push(number); + } + + let peer_states = read_lsps1_service_peer_states(kv_store.clone()).await?; + + Some(LSPS1ServiceHandler::new( + peer_states, entropy_source.clone(), Arc::clone(&pending_messages), Arc::clone(&pending_events), channel_manager.clone(), kv_store.clone(), time_provider, - config.clone(), - ) - }) - }); + lsps1_service_config.clone(), + )) + } else { + None + } + } else { + None + }; let lsps0_client_handler = LSPS0ClientHandler::new( entropy_source.clone(), diff --git a/lightning-liquidity/src/persist.rs b/lightning-liquidity/src/persist.rs index 9518b409cdf..13afdabb61b 100644 --- a/lightning-liquidity/src/persist.rs +++ b/lightning-liquidity/src/persist.rs @@ -10,6 +10,8 @@ //! Types and utils for persistence. use crate::events::{EventQueueDeserWrapper, LiquidityEvent}; +#[cfg(lsps1_service)] +use crate::lsps1::peer_state::PeerState as LSPS1ServicePeerState; use crate::lsps2::service::PeerState as LSPS2ServicePeerState; use crate::lsps5::service::PeerState as LSPS5ServicePeerState; use crate::prelude::{new_hash_map, HashMap}; @@ -86,6 +88,48 @@ pub(crate) async fn read_event_queue( Ok(Some(queue.0)) } +#[cfg(lsps1_service)] +pub(crate) async fn read_lsps1_service_peer_states( + kv_store: K, +) -> Result>, lightning::io::Error> { + let mut res = new_hash_map(); + + for stored_key in kv_store + .list( + LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE, + ) + .await? + { + let mut reader = Cursor::new( + kv_store + .read( + LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE, + &stored_key, + ) + .await?, + ); + + let peer_state = LSPS1ServicePeerState::read(&mut reader).map_err(|_| { + lightning::io::Error::new( + lightning::io::ErrorKind::InvalidData, + "Failed to deserialize LSPS1 peer state", + ) + })?; + + let key = PublicKey::from_str(&stored_key).map_err(|_| { + lightning::io::Error::new( + lightning::io::ErrorKind::InvalidData, + "Failed to deserialize stored key entry", + ) + })?; + + res.insert(key, Mutex::new(peer_state)); + } + Ok(res) +} + pub(crate) async fn read_lsps2_service_peer_states( kv_store: K, ) -> Result>, lightning::io::Error> { From 5ac267516e2bc1a98f9ed628c8208a83d2823563 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 16:01:30 +0100 Subject: [PATCH 19/41] Add test case asserting `LSPS1ServiceState` is persisted across restarts Co-authored by Claude AI --- .../tests/lsps1_integration_tests.rs | 259 +++++++++++++++++- 1 file changed, 257 insertions(+), 2 deletions(-) diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 0343116a650..01c9a38b982 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -15,16 +15,22 @@ use lightning_liquidity::lsps1::msgs::{ }; use lightning_liquidity::lsps1::service::LSPS1ServiceConfig; use lightning_liquidity::utils::time::DefaultTimeProvider; -use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; +use lightning_liquidity::{LiquidityClientConfig, LiquidityManagerSync, LiquidityServiceConfig}; use lightning::ln::functional_test_utils::{ create_chanmon_cfgs, create_node_cfgs, create_node_chanmgrs, }; -use lightning::util::test_utils::TestStore; +use lightning::util::test_utils::{TestBroadcaster, TestStore}; +use bitcoin::secp256k1::PublicKey; +use bitcoin::{Address, Network}; + +use std::str::FromStr; use std::sync::Arc; use lightning::ln::functional_test_utils::{create_network, Node}; +use lightning_liquidity::lsps1::msgs::LSPS1OrderId; +use lightning_liquidity::utils::time::TimeProvider; fn build_lsps1_configs( supported_options: LSPS1Options, @@ -240,3 +246,252 @@ fn lsps1_happy_path() { panic!("Unexpected event"); } } + +#[test] +fn lsps1_service_handler_persistence_across_restarts() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + // Create shared KV store for service node that will persist across restarts + let service_kv_store = Arc::new(TestStore::new(false)); + let client_kv_store = Arc::new(TestStore::new(false)); + + let supported_options = LSPS1Options { + min_required_channel_confirmations: 0, + min_funding_confirms_within_blocks: 6, + supports_zero_channel_reserve: true, + max_channel_expiry_blocks: 144, + min_initial_client_balance_sat: 10_000_000, + max_initial_client_balance_sat: 100_000_000, + min_initial_lsp_balance_sat: 100_000, + max_initial_lsp_balance_sat: 100_000_000, + min_channel_balance_sat: 100_000, + max_channel_balance_sat: 100_000_000, + }; + + let service_config = LiquidityServiceConfig { + lsps1_service_config: Some(LSPS1ServiceConfig { + supported_options: supported_options.clone(), + token: None, + }), + lsps2_service_config: None, + lsps5_service_config: None, + advertise_service: true, + }; + let time_provider: Arc = Arc::new(DefaultTimeProvider); + + // Variables to carry state between scopes + let client_node_id: PublicKey; + let expected_order_id: LSPS1OrderId; + let order_params: LSPS1OrderParams; + let payment_info: LSPS1PaymentInfo; + + // First scope: Setup, persistence, and dropping of all node objects + { + let LSPSNodes { service_node, client_node } = setup_test_lsps1_nodes_with_kv_stores( + nodes, + Arc::clone(&service_kv_store), + client_kv_store, + supported_options.clone(), + ); + + let service_node_id = service_node.inner.node.get_our_node_id(); + client_node_id = client_node.inner.node.get_our_node_id(); + + let client_handler = client_node.liquidity_manager.lsps1_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps1_service_handler().unwrap(); + + // Request supported options + let _request_supported_options_id = + client_handler.request_supported_options(service_node_id); + let request_supported_options = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(request_supported_options, client_node_id) + .unwrap(); + + let get_info_message = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(get_info_message, service_node_id) + .unwrap(); + + let _get_info_event = client_node.liquidity_manager.next_event().unwrap(); + + // Create an order to establish persistent state + order_params = LSPS1OrderParams { + lsp_balance_sat: 100_000, + client_balance_sat: 10_000_000, + required_channel_confirmations: 0, + funding_confirms_within_blocks: 6, + channel_expiry_blocks: 144, + token: None, + announce_channel: true, + }; + + let refund_onchain_address = + Address::from_str("bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr") + .unwrap() + .assume_checked(); + let create_order_id = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address), + ); + let create_order = get_lsps_message!(client_node, service_node_id); + + service_node.liquidity_manager.handle_custom_message(create_order, client_node_id).unwrap(); + + let request_for_payment_event = service_node.liquidity_manager.next_event().unwrap(); + let request_id = + if let LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::RequestForPaymentDetails { + request_id, + .. + }) = request_for_payment_event + { + request_id + } else { + panic!("Unexpected event"); + }; + + // Service sends payment details, creating persistent order state + let json_str = r#"{ + "state": "EXPECT_PAYMENT", + "expires_at": "2035-01-01T00:00:00Z", + "fee_total_sat": "9999", + "order_total_sat": "200999", + "address": "bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr", + "min_onchain_payment_confirmations": 1, + "min_fee_for_0conf": 253 + }"#; + + let onchain: LSPS1OnchainPaymentInfo = + serde_json::from_str(json_str).expect("Failed to parse JSON"); + payment_info = LSPS1PaymentInfo { bolt11: None, bolt12: None, onchain: Some(onchain) }; + service_handler + .send_payment_details(request_id.clone(), client_node_id, payment_info.clone()) + .unwrap(); + + let create_order_response = get_lsps_message!(service_node, client_node_id); + + client_node + .liquidity_manager + .handle_custom_message(create_order_response, service_node_id) + .unwrap(); + + let order_created_event = client_node.liquidity_manager.next_event().unwrap(); + expected_order_id = if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderCreated { + request_id, + order_id, + .. + }) = order_created_event + { + assert_eq!(request_id, create_order_id); + order_id + } else { + panic!("Unexpected event"); + }; + + // Trigger persistence by calling persist + service_node.liquidity_manager.persist().unwrap(); + + // All node objects are dropped at the end of this scope + } + + // Second scope: Recovery from persisted store and verification + { + // Create fresh node configurations for restart + let node_chanmgrs_restart = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes_restart = create_network(2, &node_cfgs, &node_chanmgrs_restart); + + // Create a new LiquidityManager with the same configuration and KV store to simulate restart + let service_transaction_broadcaster = Arc::new(TestBroadcaster::new(Network::Testnet)); + let client_transaction_broadcaster = Arc::new(TestBroadcaster::new(Network::Testnet)); + let client_kv_store_restart = Arc::new(TestStore::new(false)); + + let restarted_service_lm = LiquidityManagerSync::new_with_custom_time_provider( + nodes_restart[0].keys_manager, + nodes_restart[0].keys_manager, + nodes_restart[0].node, + service_kv_store, + service_transaction_broadcaster, + Some(service_config), + None, + Arc::clone(&time_provider), + ) + .unwrap(); + + // Create a fresh client to query the restarted service + let lsps1_client_config = LSPS1ClientConfig { max_channel_fees_msat: None }; + let client_config = LiquidityClientConfig { + lsps1_client_config: Some(lsps1_client_config), + lsps2_client_config: None, + lsps5_client_config: None, + }; + + let client_lm = LiquidityManagerSync::new_with_custom_time_provider( + nodes_restart[1].keys_manager, + nodes_restart[1].keys_manager, + nodes_restart[1].node, + client_kv_store_restart, + client_transaction_broadcaster, + None, + Some(client_config), + time_provider, + ) + .unwrap(); + + let service_node_id = nodes_restart[0].node.get_our_node_id(); + let client_node_id_restart = nodes_restart[1].node.get_our_node_id(); + + // Verify node IDs match (since we use same node_cfgs) + assert_eq!(client_node_id_restart, client_node_id); + + // Use the client to send a GetOrder request + let client_handler = client_lm.lsps1_client_handler().unwrap(); + let check_order_status_id = + client_handler.check_order_status(&service_node_id, expected_order_id.clone()); + + // Get the request message from client + let pending_client_msgs = client_lm.get_and_clear_pending_msg(); + assert_eq!(pending_client_msgs.len(), 1); + let (target_node_id, request_msg) = pending_client_msgs.into_iter().next().unwrap(); + assert_eq!(target_node_id, service_node_id); + + // Pass the request to the restarted service + restarted_service_lm.handle_custom_message(request_msg, client_node_id).unwrap(); + + // Get the response from the service + let pending_service_msgs = restarted_service_lm.get_and_clear_pending_msg(); + assert_eq!(pending_service_msgs.len(), 1); + let (target_node_id, response_msg) = pending_service_msgs.into_iter().next().unwrap(); + assert_eq!(target_node_id, client_node_id); + + // Pass the response to the client + client_lm.handle_custom_message(response_msg, service_node_id).unwrap(); + + // Verify the client receives the order status event with correct data + let order_status_event = client_lm.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { + request_id, + counterparty_node_id, + order_id, + order, + payment, + channel, + }) = order_status_event + { + assert_eq!(request_id, check_order_status_id); + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(order_id, expected_order_id); + assert_eq!(order, order_params); + assert_eq!(payment, payment_info); + assert!(channel.is_none()); + } else { + panic!("Expected OrderStatus event after restart, got: {:?}", order_status_event); + } + } +} From a98d16bc1ceba85a9d3103c74e03a713d4c7100b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 13:23:43 +0100 Subject: [PATCH 20/41] Add some checks on provided payment details As per spec, we check that the user provides at least one payment detail *and* that they don't provide onchain payment details if `refund_onchain_address` is unset. --- lightning-liquidity/src/lsps1/service.rs | 17 +++++++++++++++++ .../tests/lsps1_integration_tests.rs | 11 +++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index bc55a1c8d91..ac104648152 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -358,6 +358,23 @@ where let created_at = LSPSDateTime::new_from_duration_since_epoch( self.time_provider.duration_since_epoch(), ); + + if payment_details.bolt11.is_none() + && payment_details.bolt12.is_none() + && payment_details.onchain.is_none() + { + let err = "At least one payment option must be provided".to_string(); + return Err(APIError::APIMisuseError { err }); + } + + if params.refund_onchain_address.is_none() + && payment_details.onchain.is_some() + { + // bLIP-51: 'LSP MUST disable on-chain payments if the client omits this field.' + let err = "Onchain payments must be disabled if no refund_onchain_address is set.".to_string(); + return Err(APIError::APIMisuseError { err }); + } + let order = peer_state_lock.new_order( order_id.clone(), params.order, diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 01c9a38b982..b87568d8edf 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -145,8 +145,15 @@ fn lsps1_happy_path() { announce_channel: true, }; - let _create_order_id = - client_handler.create_order(&service_node_id, order_params.clone(), None); + let refund_onchain_address = + Address::from_str("bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr") + .unwrap() + .assume_checked(); + let _create_order_id = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address), + ); let create_order = get_lsps_message!(client_node, service_node_id); service_node.liquidity_manager.handle_custom_message(create_order, client_node_id).unwrap(); From 292adb4ac655bed095ac2ef8127159cf11f8e8a3 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Feb 2026 10:51:19 +0100 Subject: [PATCH 21/41] f Expose refund_onchain_address in `RequestForPaymentDetails` event --- lightning-liquidity/src/lsps1/event.rs | 6 ++++++ lightning-liquidity/src/lsps1/service.rs | 4 ++++ lightning-liquidity/tests/lsps1_integration_tests.rs | 7 +++++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs index d966f8bdc2f..cdd09955163 100644 --- a/lightning-liquidity/src/lsps1/event.rs +++ b/lightning-liquidity/src/lsps1/event.rs @@ -15,6 +15,7 @@ use super::msgs::{LSPS1ChannelInfo, LSPS1Options, LSPS1OrderParams, LSPS1Payment use crate::lsps0::ser::{LSPSRequestId, LSPSResponseError}; use bitcoin::secp256k1::PublicKey; +use bitcoin::Address; /// An event which an bLIP-51 / LSPS1 client should take some action in response to. #[derive(Clone, Debug, PartialEq, Eq)] @@ -164,6 +165,11 @@ pub enum LSPS1ServiceEvent { counterparty_node_id: PublicKey, /// The order requested by the client. order: LSPS1OrderParams, + /// The address we need to send onchain refunds to in case channel opening fails. + /// + /// Please note that you can't offer onchain payments if this was not provided by the + /// client. + refund_onchain_address: Option
        , }, /// If error is encountered, refund the amount if paid by the client. /// diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index ac104648152..57844e25712 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -326,6 +326,7 @@ where request_id, counterparty_node_id: *counterparty_node_id, order: params.order, + refund_onchain_address: params.refund_onchain_address, }); Ok(()) @@ -335,6 +336,9 @@ where /// /// Should be called in response to receiving a [`LSPS1ServiceEvent::RequestForPaymentDetails`] event. /// + /// Note that the provided `payment_details` can't include the onchain payment variant if the + /// user didn't provide a `refund_onchain_address`. + /// /// [`LSPS1ServiceEvent::RequestForPaymentDetails`]: crate::lsps1::event::LSPS1ServiceEvent::RequestForPaymentDetails pub async fn send_payment_details( &self, request_id: LSPSRequestId, counterparty_node_id: PublicKey, diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index b87568d8edf..0261a088244 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -152,7 +152,7 @@ fn lsps1_happy_path() { let _create_order_id = client_handler.create_order( &service_node_id, order_params.clone(), - Some(refund_onchain_address), + Some(refund_onchain_address.clone()), ); let create_order = get_lsps_message!(client_node, service_node_id); @@ -164,11 +164,14 @@ fn lsps1_happy_path() { request_id, counterparty_node_id, order, + refund_onchain_address: refund_addr, + .. }) = _request_for_payment_event { assert_eq!(request_id, _create_order_id.clone()); assert_eq!(counterparty_node_id, client_node_id); assert_eq!(order, order_params); + assert_eq!(refund_addr, Some(refund_onchain_address)); } else { panic!("Unexpected event"); } @@ -346,7 +349,7 @@ fn lsps1_service_handler_persistence_across_restarts() { let create_order_id = client_handler.create_order( &service_node_id, order_params.clone(), - Some(refund_onchain_address), + Some(refund_onchain_address.clone()), ); let create_order = get_lsps_message!(client_node, service_node_id); From 5b2b1693ac64ae0811a4795804389a2eed214cc4 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 14:37:41 +0100 Subject: [PATCH 22/41] Don't hold write lock in `LSPS{1,2}ServiceHandler::peer_disconnected` .. as there's no need to do so. --- lightning-liquidity/src/lsps1/service.rs | 2 +- lightning-liquidity/src/lsps2/service.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 57844e25712..fe642765aca 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -128,7 +128,7 @@ where } pub(crate) fn peer_disconnected(&self, counterparty_node_id: PublicKey) { - let outer_state_lock = self.per_peer_state.write().unwrap(); + let outer_state_lock = self.per_peer_state.read().unwrap(); if let Some(inner_state_lock) = outer_state_lock.get(&counterparty_node_id) { let mut peer_state_lock = inner_state_lock.lock().unwrap(); // We clean up the peer state, but leave removing the peer entry to the prune logic in diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 35942dcd624..665cda1df89 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -1871,7 +1871,7 @@ where } pub(crate) fn peer_disconnected(&self, counterparty_node_id: PublicKey) { - let outer_state_lock = self.per_peer_state.write().unwrap(); + let outer_state_lock = self.per_peer_state.read().unwrap(); if let Some(inner_state_lock) = outer_state_lock.get(&counterparty_node_id) { let mut peer_state_lock = inner_state_lock.lock().unwrap(); // We clean up the peer state, but leave removing the peer entry to the prune logic in From 65fa261325e6830bf6388e567f45ea8d7f729d63 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 15:01:05 +0100 Subject: [PATCH 23/41] Add `invalid_token_provided` API method We add a method that allows the LSP to signal to the client the token they used was invalid. We use the `102` error code as proposed in https://github.com/lightning/blips/pull/68. --- lightning-liquidity/src/lsps1/msgs.rs | 1 + lightning-liquidity/src/lsps1/service.rs | 50 +++++++++++++++++-- .../tests/lsps0_integration_tests.rs | 2 +- .../tests/lsps1_integration_tests.rs | 3 +- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/lightning-liquidity/src/lsps1/msgs.rs b/lightning-liquidity/src/lsps1/msgs.rs index 5bf130400e1..a2382e0b71c 100644 --- a/lightning-liquidity/src/lsps1/msgs.rs +++ b/lightning-liquidity/src/lsps1/msgs.rs @@ -35,6 +35,7 @@ pub(crate) const _LSPS1_CREATE_ORDER_REQUEST_INVALID_PARAMS_ERROR_CODE: i32 = -3 pub(crate) const LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE: i32 = 100; #[cfg(lsps1_service)] pub(crate) const LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE: i32 = 101; +pub(crate) const LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE: i32 = 102; /// The identifier of an order. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)] diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index fe642765aca..40519bec0f0 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -9,7 +9,7 @@ //! Contains the main bLIP-51 / LSPS1 server object, [`LSPS1ServiceHandler`]. -use alloc::string::{String, ToString}; +use alloc::string::ToString; use alloc::vec::Vec; use core::future::Future as StdFuture; @@ -24,6 +24,7 @@ use super::msgs::{ LSPS1GetOrderRequest, LSPS1Message, LSPS1Options, LSPS1OrderId, LSPS1OrderParams, LSPS1OrderState, LSPS1PaymentInfo, LSPS1Request, LSPS1Response, LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, + LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE, LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, }; use super::peer_state::PeerState; @@ -56,8 +57,6 @@ use bitcoin::secp256k1::PublicKey; /// Server-side configuration options for bLIP-51 / LSPS1 channel requests. #[derive(Clone, Debug)] pub struct LSPS1ServiceConfig { - /// A token to be send with each channel request. - pub token: Option, /// The options supported by the LSP. pub supported_options: LSPS1Options, } @@ -434,6 +433,42 @@ where Ok(()) } + /// Used by LSP to inform a client that an order was rejected because the used token was invalid. + /// + /// Should be called in response to receiving a [`LSPS1ServiceEvent::RequestForPaymentDetails`] + /// event if the provided token is invalid. + /// + /// [`LSPS1ServiceEvent::RequestForPaymentDetails`]: crate::lsps1::event::LSPS1ServiceEvent::RequestForPaymentDetails + pub fn invalid_token_provided( + &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, + ) -> Result<(), APIError> { + let mut message_queue_notifier = self.pending_messages.notifier(); + + match self.per_peer_state.read().unwrap().get(&counterparty_node_id) { + Some(inner_state_lock) => { + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + peer_state_lock.remove_request(&request_id).map_err(|e| { + debug_assert!(false, "Failed to send response due to: {}", e); + let err = format!("Failed to send response due to: {}", e); + APIError::APIMisuseError { err } + })?; + + let response = LSPS1Response::CreateOrderError(LSPSResponseError { + code: LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE, + message: "An unrecognized or stale token was provided".to_string(), + data: None, + }); + + let msg = LSPS1Message::Response(request_id, response).into(); + message_queue_notifier.enqueue(&counterparty_node_id, msg); + Ok(()) + }, + None => Err(APIError::APIMisuseError { + err: format!("No state for the counterparty exists: {}", counterparty_node_id), + }), + } + } + fn handle_get_order_request( &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, params: LSPS1GetOrderRequest, @@ -625,6 +660,15 @@ where } } + /// Used by LSP to inform a client that an order was rejected because the used token was invalid. + /// + /// Wraps [`LSPS1ServiceHandler::invalid_token_provided`]. + pub fn invalid_token_provided( + &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, + ) -> Result<(), APIError> { + self.inner.invalid_token_provided(counterparty_node_id, request_id) + } + /// Used by LSP to give details to client regarding the status of channel opening. /// /// Wraps [`LSPS1ServiceHandler::update_order_status`]. diff --git a/lightning-liquidity/tests/lsps0_integration_tests.rs b/lightning-liquidity/tests/lsps0_integration_tests.rs index 7f0e01bde92..58d9e867398 100644 --- a/lightning-liquidity/tests/lsps0_integration_tests.rs +++ b/lightning-liquidity/tests/lsps0_integration_tests.rs @@ -49,7 +49,7 @@ fn list_protocols_integration_test() { min_channel_balance_sat: 100_000, max_channel_balance_sat: 100_000_000, }; - LSPS1ServiceConfig { supported_options, token: None } + LSPS1ServiceConfig { supported_options } }; let lsps5_service_config = LSPS5ServiceConfig::default(); let service_config = LiquidityServiceConfig { diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 0261a088244..93a4bddf801 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -35,7 +35,7 @@ use lightning_liquidity::utils::time::TimeProvider; fn build_lsps1_configs( supported_options: LSPS1Options, ) -> (LiquidityServiceConfig, LiquidityClientConfig) { - let lsps1_service_config = LSPS1ServiceConfig { token: None, supported_options }; + let lsps1_service_config = LSPS1ServiceConfig { supported_options }; let service_config = LiquidityServiceConfig { lsps1_service_config: Some(lsps1_service_config), lsps2_service_config: None, @@ -284,7 +284,6 @@ fn lsps1_service_handler_persistence_across_restarts() { let service_config = LiquidityServiceConfig { lsps1_service_config: Some(LSPS1ServiceConfig { supported_options: supported_options.clone(), - token: None, }), lsps2_service_config: None, lsps5_service_config: None, From c8b5259c766eeab1d7c9a33a1016d0b48749d8d6 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Feb 2026 10:39:56 +0100 Subject: [PATCH 24/41] f Add `invalid_token_provided` to RequestForPaymentDetails event docs --- lightning-liquidity/src/lsps1/event.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs index cdd09955163..d05e9b54975 100644 --- a/lightning-liquidity/src/lsps1/event.rs +++ b/lightning-liquidity/src/lsps1/event.rs @@ -153,9 +153,13 @@ pub enum LSPS1ServiceEvent { /// send order parameters including the details regarding the /// payment and order id for this order for the client. /// + /// You should call [`LSPS1ServiceHandler::invalid_token_provided`] if the token provided as + /// partof the order parameters is invalid. + /// /// **Note: ** This event will *not* be persisted across restarts. /// /// [`LSPS1ServiceHandler::send_payment_details`]: crate::lsps1::service::LSPS1ServiceHandler::send_payment_details + /// [`LSPS1ServiceHandler::invalid_token_provided`]: crate::lsps1::service::LSPS1ServiceHandler::invalid_token_provided RequestForPaymentDetails { /// An identifier that must be passed to [`LSPS1ServiceHandler::send_payment_details`]. /// From c4697776e55abe0491880ce3d543c9c3b39e30f1 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 18 Feb 2026 13:43:30 +0100 Subject: [PATCH 25/41] f `s/partof/part of/` --- lightning-liquidity/src/lsps1/event.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs index d05e9b54975..c9a1844da85 100644 --- a/lightning-liquidity/src/lsps1/event.rs +++ b/lightning-liquidity/src/lsps1/event.rs @@ -154,7 +154,7 @@ pub enum LSPS1ServiceEvent { /// payment and order id for this order for the client. /// /// You should call [`LSPS1ServiceHandler::invalid_token_provided`] if the token provided as - /// partof the order parameters is invalid. + /// part of the order parameters is invalid. /// /// **Note: ** This event will *not* be persisted across restarts. /// From 0b121678ce68684d1f34bfb47400916dcbe66ab9 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 15:25:55 +0100 Subject: [PATCH 26/41] Add test case for `invalid_token_provided` flow We test the just-added API. Co-authored by Claude AI --- .../tests/lsps1_integration_tests.rs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 93a4bddf801..8cd7f28ec86 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -504,3 +504,99 @@ fn lsps1_service_handler_persistence_across_restarts() { } } } + +#[test] +fn lsps1_invalid_token_error() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let supported_options = LSPS1Options { + min_required_channel_confirmations: 0, + min_funding_confirms_within_blocks: 6, + supports_zero_channel_reserve: true, + max_channel_expiry_blocks: 144, + min_initial_client_balance_sat: 10_000_000, + max_initial_client_balance_sat: 100_000_000, + min_initial_lsp_balance_sat: 100_000, + max_initial_lsp_balance_sat: 100_000_000, + min_channel_balance_sat: 100_000, + max_channel_balance_sat: 100_000_000, + }; + + let LSPSNodes { service_node, client_node } = + setup_test_lsps1_nodes(nodes, supported_options.clone()); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + let client_handler = client_node.liquidity_manager.lsps1_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps1_service_handler().unwrap(); + + // Create an order with an invalid token + let order_params = LSPS1OrderParams { + lsp_balance_sat: 100_000, + client_balance_sat: 10_000_000, + required_channel_confirmations: 0, + funding_confirms_within_blocks: 6, + channel_expiry_blocks: 144, + token: Some("invalid_token".to_string()), + announce_channel: true, + }; + + let refund_onchain_address = + Address::from_str("bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr") + .unwrap() + .assume_checked(); + let create_order_id = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address), + ); + let create_order = get_lsps_message!(client_node, service_node_id); + + // Service receives the create_order request + service_node.liquidity_manager.handle_custom_message(create_order, client_node_id).unwrap(); + + // Service emits RequestForPaymentDetails event + let request_for_payment_event = service_node.liquidity_manager.next_event().unwrap(); + let request_id = + if let LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::RequestForPaymentDetails { + request_id, + counterparty_node_id, + order, + }) = request_for_payment_event + { + assert_eq!(counterparty_node_id, client_node_id); + assert_eq!(order, order_params); + request_id + } else { + panic!("Unexpected event: expected RequestForPaymentDetails"); + }; + + // Service rejects the order due to invalid token + service_handler.invalid_token_provided(client_node_id, request_id).unwrap(); + + // Get the error response message + let error_response = get_lsps_message!(service_node, client_node_id); + + // Client receives the error response + client_node + .liquidity_manager + .handle_custom_message(error_response, service_node_id) + .unwrap_err(); + + // Client receives OrderRequestFailed event with error code 102 + let error_event = client_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderRequestFailed { + request_id, + counterparty_node_id, + error, + }) = error_event + { + assert_eq!(request_id, create_order_id); + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(error.code, 102); // LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE + } else { + panic!("Unexpected event: expected OrderRequestFailed"); + } +} From d18d3ddbb1e2a6d2e44363c3afc80324d20cf386 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 10:45:39 +0100 Subject: [PATCH 27/41] Drop `lsps1_service` cfg flag --- ci/ci-tests.sh | 2 -- lightning-liquidity/Cargo.toml | 1 - lightning-liquidity/src/events/mod.rs | 2 -- lightning-liquidity/src/lsps1/event.rs | 1 - lightning-liquidity/src/lsps1/mod.rs | 2 -- lightning-liquidity/src/lsps1/msgs.rs | 2 -- lightning-liquidity/src/manager.rs | 25 +++---------------- lightning-liquidity/src/persist.rs | 3 --- .../tests/lsps0_integration_tests.rs | 13 ---------- .../tests/lsps1_integration_tests.rs | 2 +- .../tests/lsps2_integration_tests.rs | 2 -- .../tests/lsps5_integration_tests.rs | 3 --- 12 files changed, 5 insertions(+), 53 deletions(-) diff --git a/ci/ci-tests.sh b/ci/ci-tests.sh index 83b2af277f5..0f9d7601580 100755 --- a/ci/ci-tests.sh +++ b/ci/ci-tests.sh @@ -141,6 +141,4 @@ RUSTFLAGS="--cfg=taproot" cargo test --quiet --color always -p lightning [ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean RUSTFLAGS="--cfg=simple_close" cargo test --quiet --color always -p lightning [ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean -RUSTFLAGS="--cfg=lsps1_service" cargo test --quiet --color always -p lightning-liquidity -[ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean RUSTFLAGS="--cfg=peer_storage" cargo test --quiet --color always -p lightning diff --git a/lightning-liquidity/Cargo.toml b/lightning-liquidity/Cargo.toml index 61f41c15d38..cc7fb0c0f08 100644 --- a/lightning-liquidity/Cargo.toml +++ b/lightning-liquidity/Cargo.toml @@ -46,7 +46,6 @@ parking_lot = { version = "0.12", default-features = false } level = "forbid" # When adding a new cfg attribute, ensure that it is added to this list. check-cfg = [ - "cfg(lsps1_service)", "cfg(c_bindings)", "cfg(backtrace)", "cfg(ldk_bench)", diff --git a/lightning-liquidity/src/events/mod.rs b/lightning-liquidity/src/events/mod.rs index c39b8b9fd59..3d9587a058a 100644 --- a/lightning-liquidity/src/events/mod.rs +++ b/lightning-liquidity/src/events/mod.rs @@ -33,7 +33,6 @@ pub enum LiquidityEvent { /// An LSPS1 (Channel Request) client event. LSPS1Client(lsps1::event::LSPS1ClientEvent), /// An LSPS1 (Channel Request) server event. - #[cfg(lsps1_service)] LSPS1Service(lsps1::event::LSPS1ServiceEvent), /// An LSPS2 (JIT Channel) client event. LSPS2Client(lsps2::event::LSPS2ClientEvent), @@ -57,7 +56,6 @@ impl From for LiquidityEvent { } } -#[cfg(lsps1_service)] impl From for LiquidityEvent { fn from(event: lsps1::event::LSPS1ServiceEvent) -> Self { Self::LSPS1Service(event) diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs index c9a1844da85..d78d6d975c2 100644 --- a/lightning-liquidity/src/lsps1/event.rs +++ b/lightning-liquidity/src/lsps1/event.rs @@ -143,7 +143,6 @@ pub enum LSPS1ClientEvent { } /// An event which an LSPS1 server should take some action in response to. -#[cfg(lsps1_service)] #[derive(Clone, Debug, PartialEq, Eq)] pub enum LSPS1ServiceEvent { /// A client has selected the parameters to use from the supported options of the LSP diff --git a/lightning-liquidity/src/lsps1/mod.rs b/lightning-liquidity/src/lsps1/mod.rs index 2270abe2fa3..5f7f554dfb0 100644 --- a/lightning-liquidity/src/lsps1/mod.rs +++ b/lightning-liquidity/src/lsps1/mod.rs @@ -12,7 +12,5 @@ pub mod client; pub mod event; pub mod msgs; -#[cfg(lsps1_service)] pub(crate) mod peer_state; -#[cfg(lsps1_service)] pub mod service; diff --git a/lightning-liquidity/src/lsps1/msgs.rs b/lightning-liquidity/src/lsps1/msgs.rs index a2382e0b71c..eae9568f589 100644 --- a/lightning-liquidity/src/lsps1/msgs.rs +++ b/lightning-liquidity/src/lsps1/msgs.rs @@ -31,9 +31,7 @@ pub(crate) const LSPS1_CREATE_ORDER_METHOD_NAME: &str = "lsps1.create_order"; pub(crate) const LSPS1_GET_ORDER_METHOD_NAME: &str = "lsps1.get_order"; pub(crate) const _LSPS1_CREATE_ORDER_REQUEST_INVALID_PARAMS_ERROR_CODE: i32 = -32602; -#[cfg(lsps1_service)] pub(crate) const LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE: i32 = 100; -#[cfg(lsps1_service)] pub(crate) const LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE: i32 = 101; pub(crate) const LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE: i32 = 102; diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 187a9cb97a7..5f6598da2fc 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -23,15 +23,13 @@ use crate::lsps5::client::{LSPS5ClientConfig, LSPS5ClientHandler}; use crate::lsps5::msgs::LSPS5Message; use crate::lsps5::service::{LSPS5ServiceConfig, LSPS5ServiceHandler}; use crate::message_queue::MessageQueue; -#[cfg(lsps1_service)] -use crate::persist::read_lsps1_service_peer_states; use crate::persist::{ - read_event_queue, read_lsps2_service_peer_states, read_lsps5_service_peer_states, + read_event_queue, read_lsps1_service_peer_states, read_lsps2_service_peer_states, + read_lsps5_service_peer_states, }; use crate::lsps1::client::{LSPS1ClientConfig, LSPS1ClientHandler}; use crate::lsps1::msgs::LSPS1Message; -#[cfg(lsps1_service)] use crate::lsps1::service::{LSPS1ServiceConfig, LSPS1ServiceHandler, LSPS1ServiceHandlerSync}; use crate::lsps2::client::{LSPS2ClientConfig, LSPS2ClientHandler}; @@ -73,7 +71,6 @@ const LSPS_FEATURE_BIT: usize = 729; #[derive(Clone)] pub struct LiquidityServiceConfig { /// Optional server-side configuration for LSPS1 channel requests. - #[cfg(lsps1_service)] pub lsps1_service_config: Option, /// Optional server-side configuration for JIT channels /// should you want to support them. @@ -284,7 +281,6 @@ pub struct LiquidityManager< ignored_peers: RwLock>, lsps0_client_handler: LSPS0ClientHandler, lsps0_service_handler: Option, - #[cfg(lsps1_service)] lsps1_service_handler: Option>, lsps1_client_handler: Option>, lsps2_service_handler: Option>, @@ -451,7 +447,6 @@ where }) }); - #[cfg(lsps1_service)] let lsps1_service_handler = if let Some(service_config) = service_config.as_ref() { if let Some(lsps1_service_config) = service_config.lsps1_service_config.as_ref() { if let Some(number) = @@ -499,7 +494,6 @@ where lsps0_client_handler, lsps0_service_handler, lsps1_client_handler, - #[cfg(lsps1_service)] lsps1_service_handler, lsps2_client_handler, lsps2_service_handler, @@ -530,7 +524,6 @@ where } /// Returns a reference to the LSPS1 server-side handler. - #[cfg(lsps1_service)] pub fn lsps1_service_handler(&self) -> Option<&LSPS1ServiceHandler> { self.lsps1_service_handler.as_ref() } @@ -634,7 +627,6 @@ where let mut did_persist = false; did_persist |= self.pending_events.persist().await?; - #[cfg(lsps1_service)] if let Some(lsps1_service_handler) = self.lsps1_service_handler.as_ref() { did_persist |= lsps1_service_handler.persist().await?; } @@ -680,18 +672,15 @@ where }, } }, - LSPSMessage::LSPS1(_msg @ LSPS1Message::Request(..)) => { - #[cfg(lsps1_service)] + LSPSMessage::LSPS1(msg @ LSPS1Message::Request(..)) => { match &self.lsps1_service_handler { Some(lsps1_service_handler) => { - lsps1_service_handler.handle_message(_msg, sender_node_id)?; + lsps1_service_handler.handle_message(msg, sender_node_id)?; }, None => { return Err(LightningError { err: format!("Received LSPS1 request message without LSPS1 service handler configured. From node {}", sender_node_id), action: ErrorAction::IgnoreAndLog(Level::Debug)}); }, } - #[cfg(not(lsps1_service))] - return Err(LightningError { err: format!("Received LSPS1 request message without LSPS1 service handler configured. From node {}", sender_node_id), action: ErrorAction::IgnoreAndLog(Level::Debug)}); }, LSPSMessage::LSPS2(msg @ LSPS2Message::Response(..)) => { match &self.lsps2_client_handler { @@ -732,14 +721,10 @@ where .lsps2_service_handler .as_ref() .is_some_and(|h| h.has_active_requests(sender_node_id)); - #[cfg(lsps1_service)] let lsps1_has_active_requests = self .lsps1_service_handler .as_ref() .is_some_and(|h| h.has_active_requests(sender_node_id)); - #[cfg(not(lsps1_service))] - let lsps1_has_active_requests = false; - lsps5_service_handler.enforce_prior_activity_or_reject( sender_node_id, lsps2_has_active_requests, @@ -895,7 +880,6 @@ where // If the peer was misbehaving, drop it from the ignored list to cleanup the kept state. self.ignored_peers.write().unwrap().remove(&counterparty_node_id); - #[cfg(lsps1_service)] if let Some(lsps1_service_handler) = self.lsps1_service_handler.as_ref() { lsps1_service_handler.peer_disconnected(counterparty_node_id); } @@ -1051,7 +1035,6 @@ where /// Returns a reference to the LSPS1 server-side handler. /// /// Wraps [`LiquidityManager::lsps1_service_handler`]. - #[cfg(lsps1_service)] pub fn lsps1_service_handler<'a>( &'a self, ) -> Option, TP>> { diff --git a/lightning-liquidity/src/persist.rs b/lightning-liquidity/src/persist.rs index 13afdabb61b..30d78249796 100644 --- a/lightning-liquidity/src/persist.rs +++ b/lightning-liquidity/src/persist.rs @@ -10,7 +10,6 @@ //! Types and utils for persistence. use crate::events::{EventQueueDeserWrapper, LiquidityEvent}; -#[cfg(lsps1_service)] use crate::lsps1::peer_state::PeerState as LSPS1ServicePeerState; use crate::lsps2::service::PeerState as LSPS2ServicePeerState; use crate::lsps5::service::PeerState as LSPS5ServicePeerState; @@ -44,7 +43,6 @@ pub const LIQUIDITY_MANAGER_EVENT_QUEUE_PERSISTENCE_KEY: &str = "event_queue"; /// The secondary namespace under which the [`LSPS1ServiceHandler`] data will be persisted. /// /// [`LSPS1ServiceHandler`]: crate::lsps1::service::LSPS1ServiceHandler -#[cfg(lsps1_service)] pub const LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE: &str = "lsps1_service"; /// The secondary namespace under which the [`LSPS2ServiceHandler`] data will be persisted. @@ -88,7 +86,6 @@ pub(crate) async fn read_event_queue( Ok(Some(queue.0)) } -#[cfg(lsps1_service)] pub(crate) async fn read_lsps1_service_peer_states( kv_store: K, ) -> Result>, lightning::io::Error> { diff --git a/lightning-liquidity/tests/lsps0_integration_tests.rs b/lightning-liquidity/tests/lsps0_integration_tests.rs index 58d9e867398..c2e94e30661 100644 --- a/lightning-liquidity/tests/lsps0_integration_tests.rs +++ b/lightning-liquidity/tests/lsps0_integration_tests.rs @@ -6,11 +6,8 @@ use common::{create_service_and_client_nodes, get_lsps_message, LSPSNodes}; use lightning_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps0::event::LSPS0ClientEvent; -#[cfg(lsps1_service)] use lightning_liquidity::lsps1::client::LSPS1ClientConfig; -#[cfg(lsps1_service)] use lightning_liquidity::lsps1::msgs::LSPS1Options; -#[cfg(lsps1_service)] use lightning_liquidity::lsps1::service::LSPS1ServiceConfig; use lightning_liquidity::lsps2::client::LSPS2ClientConfig; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig; @@ -35,7 +32,6 @@ fn list_protocols_integration_test() { let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let promise_secret = [42; 32]; let lsps2_service_config = LSPS2ServiceConfig { promise_secret }; - #[cfg(lsps1_service)] let lsps1_service_config = { let supported_options = LSPS1Options { min_required_channel_confirmations: 0, @@ -53,7 +49,6 @@ fn list_protocols_integration_test() { }; let lsps5_service_config = LSPS5ServiceConfig::default(); let service_config = LiquidityServiceConfig { - #[cfg(lsps1_service)] lsps1_service_config: Some(lsps1_service_config), lsps2_service_config: Some(lsps2_service_config), lsps5_service_config: Some(lsps5_service_config), @@ -61,14 +56,10 @@ fn list_protocols_integration_test() { }; let lsps2_client_config = LSPS2ClientConfig::default(); - #[cfg(lsps1_service)] let lsps1_client_config: LSPS1ClientConfig = LSPS1ClientConfig { max_channel_fees_msat: None }; let lsps5_client_config = LSPS5ClientConfig::default(); let client_config = LiquidityClientConfig { - #[cfg(lsps1_service)] lsps1_client_config: Some(lsps1_client_config), - #[cfg(not(lsps1_service))] - lsps1_client_config: None, lsps2_client_config: Some(lsps2_client_config), lsps5_client_config: Some(lsps5_client_config), }; @@ -107,16 +98,12 @@ fn list_protocols_integration_test() { protocols, }) => { assert_eq!(counterparty_node_id, client_node_id); - #[cfg(lsps1_service)] { assert!(protocols.contains(&1)); assert!(protocols.contains(&2)); assert!(protocols.contains(&5)); assert_eq!(protocols.len(), 3); } - - #[cfg(not(lsps1_service))] - assert_eq!(protocols, vec![2, 5]); }, _ => panic!("Unexpected event"), } diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 8cd7f28ec86..d2ca559e577 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -1,4 +1,4 @@ -#![cfg(all(test, feature = "time", lsps1_service))] +#![cfg(all(test, feature = "time"))] mod common; diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 1c37f164d32..47be70f80dc 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -60,7 +60,6 @@ fn build_lsps2_configs() -> ([u8; 32], LiquidityServiceConfig, LiquidityClientCo let promise_secret = [42; 32]; let lsps2_service_config = LSPS2ServiceConfig { promise_secret }; let service_config = LiquidityServiceConfig { - #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: Some(lsps2_service_config), lsps5_service_config: None, @@ -941,7 +940,6 @@ fn lsps2_service_handler_persistence_across_restarts() { let promise_secret = [42; 32]; let service_config = LiquidityServiceConfig { - #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: Some(LSPS2ServiceConfig { promise_secret }), lsps5_service_config: None, diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs index 6af0c137be5..2b32b4dcbc6 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -52,7 +52,6 @@ pub(crate) fn lsps5_test_setup_with_kv_stores<'a, 'b, 'c>( ) -> (LSPSNodes<'a, 'b, 'c>, LSPS5Validator) { let lsps5_service_config = LSPS5ServiceConfig::default(); let service_config = LiquidityServiceConfig { - #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: None, lsps5_service_config: Some(lsps5_service_config), @@ -236,7 +235,6 @@ pub(crate) fn lsps5_lsps2_test_setup<'a, 'b, 'c>( let lsps5_service_config = LSPS5ServiceConfig::default(); let lsps2_service_config = LSPS2ServiceConfig { promise_secret: [42; 32] }; let service_config = LiquidityServiceConfig { - #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: Some(lsps2_service_config), lsps5_service_config: Some(lsps5_service_config), @@ -1512,7 +1510,6 @@ fn lsps5_service_handler_persistence_across_restarts() { let client_kv_store = Arc::new(TestStore::new(false)); let service_config = LiquidityServiceConfig { - #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: None, lsps5_service_config: Some(LSPS5ServiceConfig::default()), From 386b734ee4a4bbab310dd603cacd6ad2b4410397 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 17:04:09 +0100 Subject: [PATCH 28/41] Fix clippy lints --- lightning-liquidity/src/lsps1/service.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 40519bec0f0..d6b5c4698ae 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -291,7 +291,7 @@ where if !is_valid(¶ms.order, &self.config.supported_options) { let response = LSPS1Response::CreateOrderError(LSPSResponseError { code: LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, - message: format!("Order does not match options supported by LSP server"), + message: "Order does not match options supported by LSP server".to_string(), data: Some(format!("Supported options are {:?}", &self.config.supported_options)), }); let msg = LSPS1Message::Response(request_id, response).into(); @@ -482,7 +482,8 @@ where let order = peer_state_lock.get_order(¶ms.order_id).map_err(|e| { let response = LSPS1Response::GetOrderError(LSPSResponseError { code: LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, - message: format!("Order with the requested order_id has not been found."), + message: "Order with the requested order_id has not been found." + .to_string(), data: None, }); let msg = LSPS1Message::Response(request_id.clone(), response).into(); @@ -507,7 +508,7 @@ where None => { let response = LSPS1Response::GetOrderError(LSPSResponseError { code: LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, - message: format!("Order with the requested order_id has not been found."), + message: "Order with the requested order_id has not been found.".to_string(), data: None, }); let msg = LSPS1Message::Response(request_id, response).into(); From 833896a63e72dae44afc0824432739fbc56f2bd7 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Feb 2026 13:16:55 +0100 Subject: [PATCH 29/41] Refactor `ChannelOrder` to use `ChannelOrderState` state machine This refactors `ChannelOrder` to use an internal state machine enum `ChannelOrderState` that: - Encapsulates state-specific data in variants (e.g., `channel_info` only available in `CompletedAndChannelOpened`) - Provides type-safe state transitions - Replaces the generic `update_order_status` API with specific transition methods: `order_payment_received`, `order_channel_opened`, and `order_failed_and_refunded` The state machine has four states: - `ExpectingPayment`: Initial state, awaiting payment - `OrderPaid`: Payment received, awaiting channel open - `CompletedAndChannelOpened`: Terminal state with channel info - `FailedAndRefunded`: Terminal state for failed/refunded orders Co-Authored-By: HAL 9000 Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps1/peer_state.rs | 585 +++++++++++++++++++- lightning-liquidity/src/lsps1/service.rs | 179 +++++- 2 files changed, 708 insertions(+), 56 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index e0377dfc2db..64e87d55ed0 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -11,17 +11,240 @@ use super::msgs::{ LSPS1ChannelInfo, LSPS1OrderId, LSPS1OrderParams, LSPS1OrderState, LSPS1PaymentInfo, - LSPS1Request, + LSPS1PaymentState, LSPS1Request, }; use crate::lsps0::ser::{LSPSDateTime, LSPSRequestId}; use crate::prelude::HashMap; -use lightning::impl_writeable_tlv_based; use lightning::util::hash_tables::new_hash_map; +use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use core::fmt; +/// Indicates which payment method was used for the order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaymentMethod { + /// A Lightning payment using BOLT 11. + Bolt11, + /// A Lightning payment using BOLT 12. + Bolt12, + /// An onchain payment. + Onchain, +} + +/// Error type for invalid state transitions. +#[derive(Debug, Clone)] +pub(super) enum ChannelOrderStateError { + /// Attempted an invalid state transition. + InvalidStateTransition { + /// The state from which the transition was attempted. + from: LSPS1OrderState, + /// The action that was attempted. + action: &'static str, + }, + /// The specified payment method was not configured for this order. + PaymentMethodNotConfigured, +} + +impl fmt::Display for ChannelOrderStateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidStateTransition { from, action } => { + write!(f, "invalid state transition: cannot {} from {:?}", action, from) + }, + Self::PaymentMethodNotConfigured => { + write!(f, "payment method not configured for this order") + }, + } + } +} + +/// Internal state machine for tracking channel order progress. +/// +/// This combines the wire `order_state` (CREATED/COMPLETED/FAILED) with internal +/// payment tracking to provide type-safe state transitions. +#[derive(Debug, Clone)] +pub(super) enum ChannelOrderState { + /// Initial state - awaiting payment from client. + /// Payment states within payment_details should be EXPECT_PAYMENT. + ExpectingPayment { + /// Details about how to pay for the order. + payment_details: LSPS1PaymentInfo, + }, + /// Payment received, awaiting channel open. + /// The paid method's state should be PAID. + OrderPaid { + /// Details about how to pay for the order (with paid method updated). + payment_details: LSPS1PaymentInfo, + }, + /// Channel successfully funded and opened (terminal). + /// Payment states should be PAID. + CompletedAndChannelOpened { + /// Details about how to pay for the order. + payment_details: LSPS1PaymentInfo, + /// Information about the opened channel. + channel_info: LSPS1ChannelInfo, + }, + /// Order failed, payment refunded (terminal). + /// Payment states should be REFUNDED. + FailedAndRefunded { + /// Details about how to pay for the order (with states set to REFUNDED). + payment_details: LSPS1PaymentInfo, + }, +} + +impl ChannelOrderState { + /// Creates a new state in the ExpectingPayment state. + pub(super) fn new(payment_details: LSPS1PaymentInfo) -> Self { + ChannelOrderState::ExpectingPayment { payment_details } + } + + /// Transition: ExpectingPayment -> OrderPaid + /// + /// Updates the specified payment method's state to PAID. + pub(super) fn payment_received( + &mut self, method: PaymentMethod, + ) -> Result<(), ChannelOrderStateError> { + match self { + ChannelOrderState::ExpectingPayment { payment_details } => { + // Update the payment state for the specified method + let method_exists = match method { + PaymentMethod::Bolt11 => { + if let Some(ref mut bolt11) = payment_details.bolt11 { + bolt11.state = LSPS1PaymentState::Paid; + true + } else { + false + } + }, + PaymentMethod::Bolt12 => { + if let Some(ref mut bolt12) = payment_details.bolt12 { + bolt12.state = LSPS1PaymentState::Paid; + true + } else { + false + } + }, + PaymentMethod::Onchain => { + if let Some(ref mut onchain) = payment_details.onchain { + onchain.state = LSPS1PaymentState::Paid; + true + } else { + false + } + }, + }; + + if !method_exists { + return Err(ChannelOrderStateError::PaymentMethodNotConfigured); + } + + // Move to OrderPaid state + *self = ChannelOrderState::OrderPaid { payment_details: payment_details.clone() }; + Ok(()) + }, + _ => Err(ChannelOrderStateError::InvalidStateTransition { + from: self.order_state(), + action: "payment_received", + }), + } + } + + /// Transition: OrderPaid -> CompletedAndChannelOpened + pub(super) fn channel_opened( + &mut self, channel_info: LSPS1ChannelInfo, + ) -> Result<(), ChannelOrderStateError> { + match self { + ChannelOrderState::OrderPaid { payment_details } => { + *self = ChannelOrderState::CompletedAndChannelOpened { + payment_details: payment_details.clone(), + channel_info, + }; + Ok(()) + }, + _ => Err(ChannelOrderStateError::InvalidStateTransition { + from: self.order_state(), + action: "channel_opened", + }), + } + } + + /// Transition: ExpectingPayment|OrderPaid -> FailedAndRefunded + /// + /// Updates all payment states to REFUNDED. + pub(super) fn mark_failed_and_refunded(&mut self) -> Result<(), ChannelOrderStateError> { + match self { + ChannelOrderState::ExpectingPayment { payment_details } + | ChannelOrderState::OrderPaid { payment_details } => { + // Mark all payment methods as refunded + let mut refunded_details = payment_details.clone(); + if let Some(ref mut bolt11) = refunded_details.bolt11 { + bolt11.state = LSPS1PaymentState::Refunded; + } + if let Some(ref mut bolt12) = refunded_details.bolt12 { + bolt12.state = LSPS1PaymentState::Refunded; + } + if let Some(ref mut onchain) = refunded_details.onchain { + onchain.state = LSPS1PaymentState::Refunded; + } + + *self = ChannelOrderState::FailedAndRefunded { payment_details: refunded_details }; + Ok(()) + }, + _ => Err(ChannelOrderStateError::InvalidStateTransition { + from: self.order_state(), + action: "mark_failed_and_refunded", + }), + } + } + + /// Get payment_details (available in all states). + pub(super) fn payment_details(&self) -> &LSPS1PaymentInfo { + match self { + ChannelOrderState::ExpectingPayment { payment_details } + | ChannelOrderState::OrderPaid { payment_details } + | ChannelOrderState::CompletedAndChannelOpened { payment_details, .. } + | ChannelOrderState::FailedAndRefunded { payment_details } => payment_details, + } + } + + /// Get channel_info if in CompletedAndChannelOpened state. + pub(super) fn channel_info(&self) -> Option<&LSPS1ChannelInfo> { + match self { + ChannelOrderState::CompletedAndChannelOpened { channel_info, .. } => Some(channel_info), + _ => None, + } + } + + /// Convert to wire format LSPS1OrderState. + pub(super) fn order_state(&self) -> LSPS1OrderState { + match self { + ChannelOrderState::ExpectingPayment { .. } | ChannelOrderState::OrderPaid { .. } => { + LSPS1OrderState::Created + }, + ChannelOrderState::CompletedAndChannelOpened { .. } => LSPS1OrderState::Completed, + ChannelOrderState::FailedAndRefunded { .. } => LSPS1OrderState::Failed, + } + } +} + +impl_writeable_tlv_based_enum!(ChannelOrderState, + (0, ExpectingPayment) => { + (0, payment_details, required), + }, + (2, OrderPaid) => { + (0, payment_details, required), + }, + (4, CompletedAndChannelOpened) => { + (0, payment_details, required), + (2, channel_info, required), + }, + (6, FailedAndRefunded) => { + (0, payment_details, required), + } +); + #[derive(Default)] pub(crate) struct PeerState { outbound_channels_by_order_id: HashMap, @@ -34,15 +257,8 @@ impl PeerState { &mut self, order_id: LSPS1OrderId, order_params: LSPS1OrderParams, created_at: LSPSDateTime, payment_details: LSPS1PaymentInfo, ) -> ChannelOrder { - let order_state = LSPS1OrderState::Created; - let channel_details = None; - let channel_order = ChannelOrder { - order_params, - order_state, - created_at, - payment_details, - channel_details, - }; + let state = ChannelOrderState::new(payment_details); + let channel_order = ChannelOrder { order_params, state, created_at }; self.outbound_channels_by_order_id.insert(order_id, channel_order.clone()); self.needs_persist |= true; channel_order @@ -58,16 +274,45 @@ impl PeerState { Ok(order) } - pub(super) fn update_order<'a>( - &'a mut self, order_id: &LSPS1OrderId, order_state: LSPS1OrderState, - channel_details: Option, + /// Transition: ExpectingPayment -> OrderPaid + /// + /// Updates the specified payment method's state to PAID. + pub(super) fn order_payment_received( + &mut self, order_id: &LSPS1OrderId, method: PaymentMethod, + ) -> Result<(), PeerStateError> { + let order = self + .outbound_channels_by_order_id + .get_mut(order_id) + .ok_or(PeerStateError::UnknownOrderId)?; + order.state.payment_received(method).map_err(PeerStateError::InvalidStateTransition)?; + self.needs_persist |= true; + Ok(()) + } + + /// Transition: OrderPaid -> CompletedAndChannelOpened + pub(super) fn order_channel_opened( + &mut self, order_id: &LSPS1OrderId, channel_info: LSPS1ChannelInfo, ) -> Result<(), PeerStateError> { let order = self .outbound_channels_by_order_id .get_mut(order_id) .ok_or(PeerStateError::UnknownOrderId)?; - order.order_state = order_state; - order.channel_details = channel_details; + order.state.channel_opened(channel_info).map_err(PeerStateError::InvalidStateTransition)?; + self.needs_persist |= true; + Ok(()) + } + + /// Transition: ExpectingPayment|OrderPaid -> FailedAndRefunded + /// + /// Updates all payment states to REFUNDED. + pub(super) fn order_failed_and_refunded( + &mut self, order_id: &LSPS1OrderId, + ) -> Result<(), PeerStateError> { + let order = self + .outbound_channels_by_order_id + .get_mut(order_id) + .ok_or(PeerStateError::UnknownOrderId)?; + order.state.mark_failed_and_refunded().map_err(PeerStateError::InvalidStateTransition)?; self.needs_persist |= true; Ok(()) } @@ -126,11 +371,12 @@ impl_writeable_tlv_based!(PeerState, { (_unused, needs_persist, (static_value, false)), }); -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Clone)] pub(super) enum PeerStateError { UnknownRequestId, DuplicateRequestId, UnknownOrderId, + InvalidStateTransition(ChannelOrderStateError), } impl fmt::Display for PeerStateError { @@ -139,6 +385,7 @@ impl fmt::Display for PeerStateError { Self::UnknownRequestId => write!(f, "unknown request id"), Self::DuplicateRequestId => write!(f, "duplicate request id"), Self::UnknownOrderId => write!(f, "unknown order id"), + Self::InvalidStateTransition(e) => write!(f, "{}", e), } } } @@ -146,18 +393,31 @@ impl fmt::Display for PeerStateError { #[derive(Debug, Clone)] pub(super) struct ChannelOrder { pub(super) order_params: LSPS1OrderParams, - pub(super) order_state: LSPS1OrderState, + pub(super) state: ChannelOrderState, pub(super) created_at: LSPSDateTime, - pub(super) payment_details: LSPS1PaymentInfo, - pub(super) channel_details: Option, } impl ChannelOrder { + /// Returns the order state. + pub(super) fn order_state(&self) -> LSPS1OrderState { + self.state.order_state() + } + + /// Returns the payment details. + pub(super) fn payment_details(&self) -> &LSPS1PaymentInfo { + self.state.payment_details() + } + + /// Returns the channel details if the channel has been opened. + pub(super) fn channel_details(&self) -> Option<&LSPS1ChannelInfo> { + self.state.channel_info() + } + fn is_prunable(&self) -> bool { let all_payment_details_expired; #[cfg(feature = "time")] { - let details = &self.payment_details; + let details = self.state.payment_details(); all_payment_details_expired = details.bolt11.as_ref().map_or(true, |d| d.expires_at.is_past()) && details.bolt12.as_ref().map_or(true, |d| d.expires_at.is_past()) @@ -169,8 +429,11 @@ impl ChannelOrder { all_payment_details_expired = false; } - let created_or_failed = - matches!(self.order_state, LSPS1OrderState::Created | LSPS1OrderState::Failed); + let created_or_failed = matches!( + self.state, + ChannelOrderState::ExpectingPayment { .. } + | ChannelOrderState::FailedAndRefunded { .. } + ); all_payment_details_expired && created_or_failed } @@ -178,8 +441,278 @@ impl ChannelOrder { impl_writeable_tlv_based!(ChannelOrder, { (0, order_params, required), - (2, order_state, required), + (2, state, required), (4, created_at, required), - (6, payment_details, required), - (8, channel_details, option), }); + +#[cfg(test)] +mod tests { + use super::*; + use crate::lsps0::ser::LSPSDateTime; + use crate::lsps1::msgs::{LSPS1Bolt11PaymentInfo, LSPS1OnchainPaymentInfo, LSPS1PaymentState}; + + use bitcoin::{Address, FeeRate, OutPoint}; + use lightning_invoice::Bolt11Invoice; + + use core::str::FromStr; + + fn create_test_bolt11_payment_info() -> LSPS1Bolt11PaymentInfo { + let invoice_str = "lnbc252u1p3aht9ysp580g4633gd2x9lc5al0wd8wx0mpn9748jeyz46kqjrpxn52uhfpjqpp5qgf67tcqmuqehzgjm8mzya90h73deafvr4m5705l5u5l4r05l8cqdpud3h8ymm4w3jhytnpwpczqmt0de6xsmre2pkxzm3qydmkzdjrdev9s7zhgfaqxqyjw5qcqpjrzjqt6xptnd85lpqnu2lefq4cx070v5cdwzh2xlvmdgnu7gqp4zvkus5zapryqqx9qqqyqqqqqqqqqqqcsq9q9qyysgqen77vu8xqjelum24hgjpgfdgfgx4q0nehhalcmuggt32japhjuksq9jv6eksjfnppm4hrzsgyxt8y8xacxut9qv3fpyetz8t7tsymygq8yzn05"; + LSPS1Bolt11PaymentInfo { + state: LSPS1PaymentState::ExpectPayment, + expires_at: LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(), + fee_total_sat: 9999, + order_total_sat: 200999, + invoice: Bolt11Invoice::from_str(invoice_str).unwrap(), + } + } + + fn create_test_onchain_payment_info() -> LSPS1OnchainPaymentInfo { + LSPS1OnchainPaymentInfo { + state: LSPS1PaymentState::ExpectPayment, + expires_at: LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(), + fee_total_sat: 9999, + order_total_sat: 200999, + address: Address::from_str( + "bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr", + ) + .unwrap() + .assume_checked(), + min_onchain_payment_confirmations: Some(1), + min_fee_for_0conf: FeeRate::from_sat_per_vb(253).unwrap(), + refund_onchain_address: None, + } + } + + fn create_test_payment_info_bolt11_only() -> LSPS1PaymentInfo { + LSPS1PaymentInfo { + bolt11: Some(create_test_bolt11_payment_info()), + bolt12: None, + onchain: None, + } + } + + fn create_test_payment_info_onchain_only() -> LSPS1PaymentInfo { + LSPS1PaymentInfo { + bolt11: None, + bolt12: None, + onchain: Some(create_test_onchain_payment_info()), + } + } + + fn create_test_channel_info() -> LSPS1ChannelInfo { + LSPS1ChannelInfo { + funded_at: LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(), + funding_outpoint: OutPoint::from_str( + "0301e0480b374b32851a9462db29dc19fe830a7f7d7a88b81612b9d42099c0ae:0", + ) + .unwrap(), + expires_at: LSPSDateTime::from_str("2036-01-01T00:00:00Z").unwrap(), + } + } + + // Test valid transition: ExpectingPayment -> OrderPaid via payment_received (Bolt11) + #[test] + fn test_payment_received_bolt11() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + + assert!(matches!(state, ChannelOrderState::ExpectingPayment { .. })); + assert_eq!(state.order_state(), LSPS1OrderState::Created); + + state.payment_received(PaymentMethod::Bolt11).unwrap(); + + assert!(matches!(state, ChannelOrderState::OrderPaid { .. })); + assert_eq!(state.order_state(), LSPS1OrderState::Created); + assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Paid); + } + + // Test valid transition: ExpectingPayment -> OrderPaid via payment_received (Onchain) + #[test] + fn test_payment_received_onchain() { + let payment_info = create_test_payment_info_onchain_only(); + let mut state = ChannelOrderState::new(payment_info); + + state.payment_received(PaymentMethod::Onchain).unwrap(); + + assert!(matches!(state, ChannelOrderState::OrderPaid { .. })); + assert_eq!( + state.payment_details().onchain.as_ref().unwrap().state, + LSPS1PaymentState::Paid + ); + } + + // Test valid transition: OrderPaid -> CompletedAndChannelOpened via channel_opened + #[test] + fn test_channel_opened() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.payment_received(PaymentMethod::Bolt11).unwrap(); + + let channel_info = create_test_channel_info(); + state.channel_opened(channel_info.clone()).unwrap(); + + assert!(matches!(state, ChannelOrderState::CompletedAndChannelOpened { .. })); + assert_eq!(state.order_state(), LSPS1OrderState::Completed); + assert_eq!(state.channel_info(), Some(&channel_info)); + } + + // Test valid transition: ExpectingPayment -> FailedAndRefunded + #[test] + fn test_mark_failed_from_expecting_payment() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + + state.mark_failed_and_refunded().unwrap(); + + assert!(matches!(state, ChannelOrderState::FailedAndRefunded { .. })); + assert_eq!(state.order_state(), LSPS1OrderState::Failed); + assert_eq!( + state.payment_details().bolt11.as_ref().unwrap().state, + LSPS1PaymentState::Refunded + ); + } + + // Test valid transition: OrderPaid -> FailedAndRefunded + #[test] + fn test_mark_failed_from_order_paid() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.payment_received(PaymentMethod::Bolt11).unwrap(); + + state.mark_failed_and_refunded().unwrap(); + + assert!(matches!(state, ChannelOrderState::FailedAndRefunded { .. })); + assert_eq!(state.order_state(), LSPS1OrderState::Failed); + assert_eq!( + state.payment_details().bolt11.as_ref().unwrap().state, + LSPS1PaymentState::Refunded + ); + } + + // Test invalid transition: payment_received from OrderPaid + #[test] + fn test_payment_received_from_order_paid_fails() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.payment_received(PaymentMethod::Bolt11).unwrap(); + + let result = state.payment_received(PaymentMethod::Bolt11); + assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. }))); + } + + // Test invalid transition: payment_received from CompletedAndChannelOpened + #[test] + fn test_payment_received_from_completed_fails() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.payment_received(PaymentMethod::Bolt11).unwrap(); + state.channel_opened(create_test_channel_info()).unwrap(); + + let result = state.payment_received(PaymentMethod::Bolt11); + assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. }))); + } + + // Test invalid transition: payment_received from FailedAndRefunded + #[test] + fn test_payment_received_from_failed_fails() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.mark_failed_and_refunded().unwrap(); + + let result = state.payment_received(PaymentMethod::Bolt11); + assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. }))); + } + + // Test invalid transition: channel_opened from ExpectingPayment + #[test] + fn test_channel_opened_from_expecting_payment_fails() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + + let result = state.channel_opened(create_test_channel_info()); + assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. }))); + } + + // Test invalid transition: channel_opened from CompletedAndChannelOpened + #[test] + fn test_channel_opened_from_completed_fails() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.payment_received(PaymentMethod::Bolt11).unwrap(); + state.channel_opened(create_test_channel_info()).unwrap(); + + let result = state.channel_opened(create_test_channel_info()); + assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. }))); + } + + // Test invalid transition: channel_opened from FailedAndRefunded + #[test] + fn test_channel_opened_from_failed_fails() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.mark_failed_and_refunded().unwrap(); + + let result = state.channel_opened(create_test_channel_info()); + assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. }))); + } + + // Test invalid transition: mark_failed_and_refunded from CompletedAndChannelOpened + #[test] + fn test_mark_failed_from_completed_fails() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.payment_received(PaymentMethod::Bolt11).unwrap(); + state.channel_opened(create_test_channel_info()).unwrap(); + + let result = state.mark_failed_and_refunded(); + assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. }))); + } + + // Test invalid transition: mark_failed_and_refunded from FailedAndRefunded + #[test] + fn test_mark_failed_from_failed_fails() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + state.mark_failed_and_refunded().unwrap(); + + let result = state.mark_failed_and_refunded(); + assert!(matches!(result, Err(ChannelOrderStateError::InvalidStateTransition { .. }))); + } + + // Test error: payment_received with unconfigured payment method + #[test] + fn test_payment_received_unconfigured_method_fails() { + // Create payment info with only onchain configured + let payment_info = create_test_payment_info_onchain_only(); + let mut state = ChannelOrderState::new(payment_info); + + // Try to mark bolt11 as paid, which is not configured + let result = state.payment_received(PaymentMethod::Bolt11); + assert!(matches!(result, Err(ChannelOrderStateError::PaymentMethodNotConfigured))); + + // State should remain unchanged + assert!(matches!(state, ChannelOrderState::ExpectingPayment { .. })); + } + + // Test that channel_info is only available in CompletedAndChannelOpened state + #[test] + fn test_channel_info_availability() { + let payment_info = create_test_payment_info_bolt11_only(); + let mut state = ChannelOrderState::new(payment_info); + + // Not available in ExpectingPayment + assert!(state.channel_info().is_none()); + + state.payment_received(PaymentMethod::Bolt11).unwrap(); + + // Not available in OrderPaid + assert!(state.channel_info().is_none()); + + let channel_info = create_test_channel_info(); + state.channel_opened(channel_info.clone()).unwrap(); + + // Available in CompletedAndChannelOpened + assert_eq!(state.channel_info(), Some(&channel_info)); + } +} diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index d6b5c4698ae..fe9cdfd6eb2 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -22,11 +22,12 @@ use super::event::LSPS1ServiceEvent; use super::msgs::{ LSPS1ChannelInfo, LSPS1CreateOrderRequest, LSPS1CreateOrderResponse, LSPS1GetInfoResponse, LSPS1GetOrderRequest, LSPS1Message, LSPS1Options, LSPS1OrderId, LSPS1OrderParams, - LSPS1OrderState, LSPS1PaymentInfo, LSPS1Request, LSPS1Response, + LSPS1PaymentInfo, LSPS1Request, LSPS1Response, LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE, LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, }; +pub use super::peer_state::PaymentMethod; use super::peer_state::PeerState; use crate::message_queue::MessageQueue; @@ -387,13 +388,12 @@ where should_persist |= peer_state_lock.needs_persist(); let response = LSPS1Response::CreateOrder(LSPS1CreateOrderResponse { - order: order.order_params, order_id, - - order_state: order.order_state, - created_at: order.created_at, - payment: order.payment_details, - channel: order.channel_details, + order_state: order.order_state(), + created_at: order.created_at.clone(), + payment: order.payment_details().clone(), + channel: order.channel_details().cloned(), + order: order.order_params, }); let msg = LSPS1Message::Response(request_id, response).into(); message_queue_notifier.enqueue(&counterparty_node_id, msg); @@ -496,10 +496,10 @@ where let response = LSPS1Response::GetOrder(LSPS1CreateOrderResponse { order_id: params.order_id, order: order.order_params.clone(), - order_state: order.order_state.clone(), + order_state: order.order_state(), created_at: order.created_at.clone(), - payment: order.payment_details.clone(), - channel: order.channel_details.clone(), + payment: order.payment_details().clone(), + channel: order.channel_details().cloned(), }); let msg = LSPS1Message::Response(request_id, response).into(); message_queue_notifier.enqueue(&counterparty_node_id, msg); @@ -524,23 +524,107 @@ where } } - /// Used by LSP to give details to client regarding the status of channel opening. + /// Marks an order as paid after payment has been received. + /// + /// This should be called when the LSP detects that a Lightning payment has arrived or an + /// on-chain payment has been confirmed. + /// + /// This should be called before opening the channel and the channel should not be opened if + /// this returns an error. + /// + /// Note that in the case of a lightning payment, we expect the payment to have been received + /// (i.e. LDK's [`Event::PaymentClaimable`]) but not claimed (i.e. calling LDK's + /// [`ChannelManager::claim_funds`]), allowing the payment to be returned to the sender if + /// channel opening fails. + /// + /// [`Event::PaymentClaimable`]: lightning::events::Event::PaymentClaimable + /// [`ChannelManager::claim_funds`]: lightning::ln::channelmanager::ChannelManager::claim_funds + pub async fn order_payment_received( + &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, method: PaymentMethod, + ) -> Result<(), APIError> { + let mut should_persist = false; + match self.per_peer_state.read().unwrap().get(&counterparty_node_id) { + Some(inner_state_lock) => { + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + peer_state_lock.order_payment_received(&order_id, method).map_err(|e| { + APIError::APIMisuseError { err: format!("Failed to update order: {}", e) } + })?; + should_persist |= peer_state_lock.needs_persist(); + }, + None => { + return Err(APIError::APIMisuseError { + err: format!("No existing state with counterparty {}", counterparty_node_id), + }); + }, + } + + if should_persist { + self.persist_peer_state(counterparty_node_id).await.map_err(|e| { + APIError::APIMisuseError { + err: format!( + "Failed to persist peer state for {}: {}", + counterparty_node_id, e + ), + } + })?; + } + + Ok(()) + } + + /// Marks an order as completed after the channel has been opened. /// - /// The LSP continously polls for checking payment confirmation on-chain or Lightning - /// and then responds to client request. - pub async fn update_order_status( + /// This should be called when the LSP has successfully published the funding + /// transaction for the channel. + pub async fn order_channel_opened( &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, - order_state: LSPS1OrderState, channel_details: Option, + channel_info: LSPS1ChannelInfo, ) -> Result<(), APIError> { let mut should_persist = false; match self.per_peer_state.read().unwrap().get(&counterparty_node_id) { Some(inner_state_lock) => { let mut peer_state_lock = inner_state_lock.lock().unwrap(); - peer_state_lock.update_order(&order_id, order_state, channel_details).map_err( - |e| APIError::APIMisuseError { - err: format!("Failed to update order: {:?}", e), - }, - )?; + peer_state_lock.order_channel_opened(&order_id, channel_info).map_err(|e| { + APIError::APIMisuseError { err: format!("Failed to update order: {}", e) } + })?; + should_persist |= peer_state_lock.needs_persist(); + }, + None => { + return Err(APIError::APIMisuseError { + err: format!("No existing state with counterparty {}", counterparty_node_id), + }); + }, + } + + if should_persist { + self.persist_peer_state(counterparty_node_id).await.map_err(|e| { + APIError::APIMisuseError { + err: format!( + "Failed to persist peer state for {}: {}", + counterparty_node_id, e + ), + } + })?; + } + + Ok(()) + } + + /// Marks an order as failed and refunded. + /// + /// This should be called when: + /// - The order expires without payment + /// - The channel open fails after payment and the LSP must refund + pub async fn order_failed_and_refunded( + &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, + ) -> Result<(), APIError> { + let mut should_persist = false; + match self.per_peer_state.read().unwrap().get(&counterparty_node_id) { + Some(inner_state_lock) => { + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + peer_state_lock.order_failed_and_refunded(&order_id).map_err(|e| { + APIError::APIMisuseError { err: format!("Failed to update order: {}", e) } + })?; should_persist |= peer_state_lock.needs_persist(); }, None => { @@ -670,19 +754,54 @@ where self.inner.invalid_token_provided(counterparty_node_id, request_id) } - /// Used by LSP to give details to client regarding the status of channel opening. + /// Marks an order as paid after payment has been received. + /// + /// Wraps [`LSPS1ServiceHandler::order_payment_received`]. + pub fn order_payment_received( + &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, method: PaymentMethod, + ) -> Result<(), APIError> { + let mut fut = + pin!(self.inner.order_payment_received(counterparty_node_id, order_id, method)); + + let mut waker = dummy_waker(); + let mut ctx = task::Context::from_waker(&mut waker); + match fut.as_mut().poll(&mut ctx) { + task::Poll::Ready(result) => result, + task::Poll::Pending => { + // In a sync context, we can't wait for the future to complete. + unreachable!("Should not be pending in a sync context"); + }, + } + } + + /// Marks an order as completed after the channel has been opened. /// - /// Wraps [`LSPS1ServiceHandler::update_order_status`]. - pub fn update_order_status( + /// Wraps [`LSPS1ServiceHandler::order_channel_opened`]. + pub fn order_channel_opened( &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, - order_state: LSPS1OrderState, channel_details: Option, + channel_info: LSPS1ChannelInfo, ) -> Result<(), APIError> { - let mut fut = pin!(self.inner.update_order_status( - counterparty_node_id, - order_id, - order_state, - channel_details - )); + let mut fut = + pin!(self.inner.order_channel_opened(counterparty_node_id, order_id, channel_info)); + + let mut waker = dummy_waker(); + let mut ctx = task::Context::from_waker(&mut waker); + match fut.as_mut().poll(&mut ctx) { + task::Poll::Ready(result) => result, + task::Poll::Pending => { + // In a sync context, we can't wait for the future to complete. + unreachable!("Should not be pending in a sync context"); + }, + } + } + + /// Marks an order as failed and refunded. + /// + /// Wraps [`LSPS1ServiceHandler::order_failed_and_refunded`]. + pub fn order_failed_and_refunded( + &self, counterparty_node_id: PublicKey, order_id: LSPS1OrderId, + ) -> Result<(), APIError> { + let mut fut = pin!(self.inner.order_failed_and_refunded(counterparty_node_id, order_id)); let mut waker = dummy_waker(); let mut ctx = task::Context::from_waker(&mut waker); From 529f379eee0d60a80cbcc084f6b7e2353d8b2ec4 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Feb 2026 10:56:16 +0100 Subject: [PATCH 30/41] f Mention refund_onchain_address --- lightning-liquidity/src/lsps1/service.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index fe9cdfd6eb2..d628c4d6bfe 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -613,6 +613,7 @@ where /// Marks an order as failed and refunded. /// /// This should be called when: + /// - We require onchain payment and the client didn't provide a `refund_onchain_address`. /// - The order expires without payment /// - The channel open fails after payment and the LSP must refund pub async fn order_failed_and_refunded( From 6373fa91587fa4f9401154d15f3dc76d63230cb7 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Feb 2026 13:28:16 +0100 Subject: [PATCH 31/41] Add integration tests for LSPS1 order state transition API Add two new integration tests to cover the new public API methods: - `lsps1_order_state_transitions`: Tests the full flow of `order_payment_received` followed by `order_channel_opened`, verifying that payment states are updated correctly and channel info is returned after the channel is opened. - `lsps1_order_failed_and_refunded`: Tests the `order_failed_and_refunded` method, verifying that payment states are set to Refunded. Co-Authored-By: HAL 9000 --- .../tests/lsps1_integration_tests.rs | 289 +++++++++++++++++- 1 file changed, 286 insertions(+), 3 deletions(-) diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index d2ca559e577..e4f08d16d98 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -7,13 +7,15 @@ use common::{get_lsps_message, LSPSNodes}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning_liquidity::events::LiquidityEvent; +use lightning_liquidity::lsps0::ser::LSPSDateTime; use lightning_liquidity::lsps1::client::LSPS1ClientConfig; use lightning_liquidity::lsps1::event::LSPS1ClientEvent; use lightning_liquidity::lsps1::event::LSPS1ServiceEvent; use lightning_liquidity::lsps1::msgs::{ - LSPS1OnchainPaymentInfo, LSPS1Options, LSPS1OrderParams, LSPS1PaymentInfo, + LSPS1ChannelInfo, LSPS1OnchainPaymentInfo, LSPS1Options, LSPS1OrderParams, LSPS1PaymentInfo, + LSPS1PaymentState, }; -use lightning_liquidity::lsps1::service::LSPS1ServiceConfig; +use lightning_liquidity::lsps1::service::{LSPS1ServiceConfig, PaymentMethod}; use lightning_liquidity::utils::time::DefaultTimeProvider; use lightning_liquidity::{LiquidityClientConfig, LiquidityManagerSync, LiquidityServiceConfig}; @@ -23,7 +25,7 @@ use lightning::ln::functional_test_utils::{ use lightning::util::test_utils::{TestBroadcaster, TestStore}; use bitcoin::secp256k1::PublicKey; -use bitcoin::{Address, Network}; +use bitcoin::{Address, Network, OutPoint}; use std::str::FromStr; use std::sync::Arc; @@ -600,3 +602,284 @@ fn lsps1_invalid_token_error() { panic!("Unexpected event: expected OrderRequestFailed"); } } + +#[test] +fn lsps1_order_state_transitions() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let supported_options = LSPS1Options { + min_required_channel_confirmations: 0, + min_funding_confirms_within_blocks: 6, + supports_zero_channel_reserve: true, + max_channel_expiry_blocks: 144, + min_initial_client_balance_sat: 10_000_000, + max_initial_client_balance_sat: 100_000_000, + min_initial_lsp_balance_sat: 100_000, + max_initial_lsp_balance_sat: 100_000_000, + min_channel_balance_sat: 100_000, + max_channel_balance_sat: 100_000_000, + }; + + let LSPSNodes { service_node, client_node } = + setup_test_lsps1_nodes(nodes, supported_options.clone()); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + let client_handler = client_node.liquidity_manager.lsps1_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps1_service_handler().unwrap(); + + // Create an order + let order_params = LSPS1OrderParams { + lsp_balance_sat: 100_000, + client_balance_sat: 10_000_000, + required_channel_confirmations: 0, + funding_confirms_within_blocks: 6, + channel_expiry_blocks: 144, + token: None, + announce_channel: true, + }; + + let refund_onchain_address = + Address::from_str("bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr") + .unwrap() + .assume_checked(); + let create_order_id = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address), + ); + let create_order = get_lsps_message!(client_node, service_node_id); + + service_node.liquidity_manager.handle_custom_message(create_order, client_node_id).unwrap(); + + let request_for_payment_event = service_node.liquidity_manager.next_event().unwrap(); + let request_id = + if let LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::RequestForPaymentDetails { + request_id, + .. + }) = request_for_payment_event + { + request_id + } else { + panic!("Unexpected event"); + }; + + // Send payment details with onchain payment option + let json_str = r#"{ + "state": "EXPECT_PAYMENT", + "expires_at": "2035-01-01T00:00:00Z", + "fee_total_sat": "9999", + "order_total_sat": "200999", + "address": "bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr", + "min_onchain_payment_confirmations": 1, + "min_fee_for_0conf": 253 + }"#; + + let onchain: LSPS1OnchainPaymentInfo = + serde_json::from_str(json_str).expect("Failed to parse JSON"); + let payment_info = LSPS1PaymentInfo { bolt11: None, bolt12: None, onchain: Some(onchain) }; + service_handler + .send_payment_details(request_id.clone(), client_node_id, payment_info.clone()) + .unwrap(); + + let create_order_response = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(create_order_response, service_node_id) + .unwrap(); + + let order_created_event = client_node.liquidity_manager.next_event().unwrap(); + let order_id = if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderCreated { + request_id, + order_id, + payment, + .. + }) = order_created_event + { + assert_eq!(request_id, create_order_id); + // Initially, payment state should be ExpectPayment + assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::ExpectPayment); + order_id + } else { + panic!("Unexpected event"); + }; + + // Test order_payment_received: mark the order as paid + service_handler + .order_payment_received(client_node_id, order_id.clone(), PaymentMethod::Onchain) + .unwrap(); + + // Client checks order status - should see payment state as Paid + let _check_order_id = client_handler.check_order_status(&service_node_id, order_id.clone()); + let check_order = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(check_order, client_node_id).unwrap(); + let order_response = get_lsps_message!(service_node, client_node_id); + client_node.liquidity_manager.handle_custom_message(order_response, service_node_id).unwrap(); + + let order_status_event = client_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { payment, channel, .. }) = + order_status_event + { + // Payment state should be Paid + assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::Paid); + // No channel info yet (order state is still Created internally) + assert!(channel.is_none()); + } else { + panic!("Unexpected event"); + } + + // Test order_channel_opened: mark the channel as opened + let channel_info = LSPS1ChannelInfo { + funded_at: LSPSDateTime::from_str("2035-01-01T00:00:00Z").unwrap(), + funding_outpoint: OutPoint::from_str( + "0301e0480b374b32851a9462db29dc19fe830a7f7d7a88b81612b9d42099c0ae:0", + ) + .unwrap(), + expires_at: LSPSDateTime::from_str("2036-01-01T00:00:00Z").unwrap(), + }; + service_handler + .order_channel_opened(client_node_id, order_id.clone(), channel_info.clone()) + .unwrap(); + + // Client checks order status - should see Completed state with channel info + let _check_order_id = client_handler.check_order_status(&service_node_id, order_id.clone()); + let check_order = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(check_order, client_node_id).unwrap(); + let order_response = get_lsps_message!(service_node, client_node_id); + client_node.liquidity_manager.handle_custom_message(order_response, service_node_id).unwrap(); + + let order_status_event = client_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { channel, .. }) = + order_status_event + { + // Channel info should be present (indicates Completed state) + assert_eq!(channel, Some(channel_info)); + } else { + panic!("Unexpected event"); + } +} + +#[test] +fn lsps1_order_failed_and_refunded() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let supported_options = LSPS1Options { + min_required_channel_confirmations: 0, + min_funding_confirms_within_blocks: 6, + supports_zero_channel_reserve: true, + max_channel_expiry_blocks: 144, + min_initial_client_balance_sat: 10_000_000, + max_initial_client_balance_sat: 100_000_000, + min_initial_lsp_balance_sat: 100_000, + max_initial_lsp_balance_sat: 100_000_000, + min_channel_balance_sat: 100_000, + max_channel_balance_sat: 100_000_000, + }; + + let LSPSNodes { service_node, client_node } = + setup_test_lsps1_nodes(nodes, supported_options.clone()); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + let client_handler = client_node.liquidity_manager.lsps1_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps1_service_handler().unwrap(); + + // Create an order + let order_params = LSPS1OrderParams { + lsp_balance_sat: 100_000, + client_balance_sat: 10_000_000, + required_channel_confirmations: 0, + funding_confirms_within_blocks: 6, + channel_expiry_blocks: 144, + token: None, + announce_channel: true, + }; + + let refund_onchain_address = + Address::from_str("bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr") + .unwrap() + .assume_checked(); + let create_order_id = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address), + ); + let create_order = get_lsps_message!(client_node, service_node_id); + + service_node.liquidity_manager.handle_custom_message(create_order, client_node_id).unwrap(); + + let request_for_payment_event = service_node.liquidity_manager.next_event().unwrap(); + let request_id = + if let LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::RequestForPaymentDetails { + request_id, + .. + }) = request_for_payment_event + { + request_id + } else { + panic!("Unexpected event"); + }; + + // Send payment details + let json_str = r#"{ + "state": "EXPECT_PAYMENT", + "expires_at": "2035-01-01T00:00:00Z", + "fee_total_sat": "9999", + "order_total_sat": "200999", + "address": "bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr", + "min_onchain_payment_confirmations": 1, + "min_fee_for_0conf": 253 + }"#; + + let onchain: LSPS1OnchainPaymentInfo = + serde_json::from_str(json_str).expect("Failed to parse JSON"); + let payment_info = LSPS1PaymentInfo { bolt11: None, bolt12: None, onchain: Some(onchain) }; + service_handler + .send_payment_details(request_id.clone(), client_node_id, payment_info.clone()) + .unwrap(); + + let create_order_response = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(create_order_response, service_node_id) + .unwrap(); + + let order_created_event = client_node.liquidity_manager.next_event().unwrap(); + let order_id = if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderCreated { + request_id, + order_id, + .. + }) = order_created_event + { + assert_eq!(request_id, create_order_id); + order_id + } else { + panic!("Unexpected event"); + }; + + // Test order_failed_and_refunded: mark the order as failed + service_handler.order_failed_and_refunded(client_node_id, order_id.clone()).unwrap(); + + // Client checks order status - should see Failed state with Refunded payment + let _check_order_id = client_handler.check_order_status(&service_node_id, order_id.clone()); + let check_order = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(check_order, client_node_id).unwrap(); + let order_response = get_lsps_message!(service_node, client_node_id); + client_node.liquidity_manager.handle_custom_message(order_response, service_node_id).unwrap(); + + let order_status_event = client_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { payment, channel, .. }) = + order_status_event + { + // Payment state should be Refunded (indicates Failed state) + assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::Refunded); + // No channel info + assert!(channel.is_none()); + } else { + panic!("Unexpected event"); + } +} From a7a54772db90718be03b5f15f94be1456943e92a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Feb 2026 13:04:15 +0100 Subject: [PATCH 32/41] f Account for refund_onchain_address in tests --- lightning-liquidity/tests/lsps1_integration_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index e4f08d16d98..91825f9540b 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -552,7 +552,7 @@ fn lsps1_invalid_token_error() { let create_order_id = client_handler.create_order( &service_node_id, order_params.clone(), - Some(refund_onchain_address), + Some(refund_onchain_address.clone()), ); let create_order = get_lsps_message!(client_node, service_node_id); @@ -566,10 +566,13 @@ fn lsps1_invalid_token_error() { request_id, counterparty_node_id, order, + refund_onchain_address: refund_addr, + .. }) = request_for_payment_event { assert_eq!(counterparty_node_id, client_node_id); assert_eq!(order, order_params); + assert_eq!(refund_addr, Some(refund_onchain_address)); request_id } else { panic!("Unexpected event: expected RequestForPaymentDetails"); From 24a2f95fa62badd392e4d3c99f040992679ea1e7 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Feb 2026 13:32:02 +0100 Subject: [PATCH 33/41] Add integration test for expired order pruning Add `lsps1_expired_orders_are_pruned_and_not_persisted` test that verifies: - Orders with expired payment details (expires_at in the past) are accessible before persist() is called - After persist() is called, expired orders in ExpectingPayment state are pruned and no longer accessible - Pruned orders are not recovered after restart, confirming that the pruning also removes the persisted state Co-Authored-By: HAL 9000 --- .../tests/lsps1_integration_tests.rs | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 91825f9540b..92ad06abfdc 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -886,3 +886,254 @@ fn lsps1_order_failed_and_refunded() { panic!("Unexpected event"); } } + +#[test] +fn lsps1_expired_orders_are_pruned_and_not_persisted() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + // Create shared KV store for service node that will persist across restarts + let service_kv_store = Arc::new(TestStore::new(false)); + let client_kv_store = Arc::new(TestStore::new(false)); + + let supported_options = LSPS1Options { + min_required_channel_confirmations: 0, + min_funding_confirms_within_blocks: 6, + supports_zero_channel_reserve: true, + max_channel_expiry_blocks: 144, + min_initial_client_balance_sat: 10_000_000, + max_initial_client_balance_sat: 100_000_000, + min_initial_lsp_balance_sat: 100_000, + max_initial_lsp_balance_sat: 100_000_000, + min_channel_balance_sat: 100_000, + max_channel_balance_sat: 100_000_000, + }; + + let service_config = LiquidityServiceConfig { + lsps1_service_config: Some(LSPS1ServiceConfig { + supported_options: supported_options.clone(), + }), + lsps2_service_config: None, + lsps5_service_config: None, + advertise_service: true, + }; + let time_provider: Arc = Arc::new(DefaultTimeProvider); + + // Variables to carry state between scopes + let client_node_id: PublicKey; + let expected_order_id: LSPS1OrderId; + + // First scope: Create an order with EXPIRED payment details + { + let LSPSNodes { service_node, client_node } = setup_test_lsps1_nodes_with_kv_stores( + nodes, + Arc::clone(&service_kv_store), + Arc::clone(&client_kv_store), + supported_options.clone(), + ); + + let service_node_id = service_node.inner.node.get_our_node_id(); + client_node_id = client_node.inner.node.get_our_node_id(); + + let client_handler = client_node.liquidity_manager.lsps1_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps1_service_handler().unwrap(); + + // Create an order + let order_params = LSPS1OrderParams { + lsp_balance_sat: 100_000, + client_balance_sat: 10_000_000, + required_channel_confirmations: 0, + funding_confirms_within_blocks: 6, + channel_expiry_blocks: 144, + token: None, + announce_channel: true, + }; + + let refund_onchain_address = + Address::from_str("bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr") + .unwrap() + .assume_checked(); + let create_order_id = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address), + ); + let create_order = get_lsps_message!(client_node, service_node_id); + + service_node.liquidity_manager.handle_custom_message(create_order, client_node_id).unwrap(); + + let request_for_payment_event = service_node.liquidity_manager.next_event().unwrap(); + let request_id = + if let LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::RequestForPaymentDetails { + request_id, + .. + }) = request_for_payment_event + { + request_id + } else { + panic!("Unexpected event"); + }; + + // Send payment details with EXPIRED expiry time (in the past) + let json_str = r#"{ + "state": "EXPECT_PAYMENT", + "expires_at": "2020-01-01T00:00:00Z", + "fee_total_sat": "9999", + "order_total_sat": "200999", + "address": "bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr", + "min_onchain_payment_confirmations": 1, + "min_fee_for_0conf": 253 + }"#; + + let onchain: LSPS1OnchainPaymentInfo = + serde_json::from_str(json_str).expect("Failed to parse JSON"); + let payment_info = LSPS1PaymentInfo { bolt11: None, bolt12: None, onchain: Some(onchain) }; + service_handler + .send_payment_details(request_id.clone(), client_node_id, payment_info.clone()) + .unwrap(); + + let create_order_response = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(create_order_response, service_node_id) + .unwrap(); + + let order_created_event = client_node.liquidity_manager.next_event().unwrap(); + expected_order_id = if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderCreated { + request_id, + order_id, + .. + }) = order_created_event + { + assert_eq!(request_id, create_order_id); + order_id + } else { + panic!("Unexpected event"); + }; + + // Verify the order exists by querying it (before persist is called) + let _check_order_id = + client_handler.check_order_status(&service_node_id, expected_order_id.clone()); + let check_order = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(check_order, client_node_id).unwrap(); + let order_response = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(order_response, service_node_id) + .unwrap(); + + // Should get the order status (order exists before pruning) + let order_status_event = client_node.liquidity_manager.next_event().unwrap(); + assert!(matches!( + order_status_event, + LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { .. }) + )); + + // Now call persist - this should prune the expired order since expires_at is in the past + // (prune_expired_request_state is called during persist) + service_node.liquidity_manager.persist().unwrap(); + + // Try to query the order again - it should fail (order not found) + let _check_order_id = + client_handler.check_order_status(&service_node_id, expected_order_id.clone()); + let check_order = get_lsps_message!(client_node, service_node_id); + + // This should return an error response since the order was pruned + service_node + .liquidity_manager + .handle_custom_message(check_order, client_node_id) + .unwrap_err(); + + let error_response = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(error_response, service_node_id) + .unwrap_err(); + + // Should get an error event (order not found) + let error_event = client_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderRequestFailed { error, .. }) = + error_event + { + // Error code 101 is LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE + assert_eq!(error.code, 101); + } else { + panic!("Expected OrderRequestFailed event"); + } + + // All node objects are dropped at the end of this scope + } + + // Second scope: Restart and verify pruned order is NOT recovered + { + let node_chanmgrs_restart = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes_restart = create_network(2, &node_cfgs, &node_chanmgrs_restart); + + let service_transaction_broadcaster = Arc::new(TestBroadcaster::new(Network::Testnet)); + let client_transaction_broadcaster = Arc::new(TestBroadcaster::new(Network::Testnet)); + + let restarted_service_lm = LiquidityManagerSync::new_with_custom_time_provider( + nodes_restart[0].keys_manager, + nodes_restart[0].keys_manager, + nodes_restart[0].node, + Arc::clone(&service_kv_store), + service_transaction_broadcaster, + Some(service_config), + None, + Arc::clone(&time_provider), + ) + .unwrap(); + + let lsps1_client_config = LSPS1ClientConfig { max_channel_fees_msat: None }; + let client_config = LiquidityClientConfig { + lsps1_client_config: Some(lsps1_client_config), + lsps2_client_config: None, + lsps5_client_config: None, + }; + + let client_lm = LiquidityManagerSync::new_with_custom_time_provider( + nodes_restart[1].keys_manager, + nodes_restart[1].keys_manager, + nodes_restart[1].node, + Arc::clone(&client_kv_store), + client_transaction_broadcaster, + None, + Some(client_config), + time_provider, + ) + .unwrap(); + + let service_node_id = nodes_restart[0].node.get_our_node_id(); + + // Try to query the previously pruned order - it should NOT be recovered + let client_handler = client_lm.lsps1_client_handler().unwrap(); + let _check_order_id = + client_handler.check_order_status(&service_node_id, expected_order_id.clone()); + + let pending_client_msgs = client_lm.get_and_clear_pending_msg(); + assert_eq!(pending_client_msgs.len(), 1); + let (_, request_msg) = pending_client_msgs.into_iter().next().unwrap(); + + // This should return an error since the order was pruned and not persisted + restarted_service_lm.handle_custom_message(request_msg, client_node_id).unwrap_err(); + + let pending_service_msgs = restarted_service_lm.get_and_clear_pending_msg(); + assert_eq!(pending_service_msgs.len(), 1); + let (_, response_msg) = pending_service_msgs.into_iter().next().unwrap(); + + client_lm.handle_custom_message(response_msg, service_node_id).unwrap_err(); + + // Should get an error event (order not found after restart) + let error_event = client_lm.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderRequestFailed { error, .. }) = + error_event + { + // Error code 101 is LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE + assert_eq!(error.code, 101); + } else { + panic!("Expected OrderRequestFailed event after restart, got: {:?}", error_event); + } + } +} From 88e9fbfc0ff878338dae499c6d5d0a2c841ddfe6 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Feb 2026 13:44:17 +0100 Subject: [PATCH 34/41] Drop unused `LSPS1OnchainPayment` type --- lightning-liquidity/src/lsps1/msgs.rs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/lightning-liquidity/src/lsps1/msgs.rs b/lightning-liquidity/src/lsps1/msgs.rs index eae9568f589..6021b650cfa 100644 --- a/lightning-liquidity/src/lsps1/msgs.rs +++ b/lightning-liquidity/src/lsps1/msgs.rs @@ -323,18 +323,6 @@ impl_writeable_tlv_based_enum!(LSPS1PaymentState, (4, Refunded) => {} ); -/// Details regarding a detected on-chain payment. -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] -pub struct LSPS1OnchainPayment { - /// The outpoint of the payment. - pub outpoint: String, - /// The amount of satoshi paid. - #[serde(with = "string_amount")] - pub sat: u64, - /// Indicates if the LSP regards the transaction as sufficiently confirmed. - pub confirmed: bool, -} - /// Details regarding the state of an ordered channel. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct LSPS1ChannelInfo { From cf383a974d7d4f5df89a5bbdf8e2d3bb8418731c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Feb 2026 13:51:28 +0100 Subject: [PATCH 35/41] Add `Hold` payment state per bLIP-51 spec The bLIP-51 specification defines a `HOLD` intermediate payment state: - `EXPECT_PAYMENT` -> `HOLD` -> `PAID` (success path) - `EXPECT_PAYMENT` -> `REFUNDED` (failure before payment) - `HOLD` -> `REFUNDED` (failure after payment received) This commit adds the `Hold` variant to `LSPS1PaymentState` and updates the state machine transitions: - `payment_received()` now sets payment state to `Hold` (not `Paid`) - `channel_opened()` transitions payment state from `Hold` to `Paid` - Tests updated to verify the correct state at each transition This allows LSPs to properly communicate when a payment has been received but the channel has not yet been opened (e.g., Lightning HTLC held, or on-chain tx detected but channel funding not published). Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps1/msgs.rs | 12 +++-- lightning-liquidity/src/lsps1/peer_state.rs | 47 +++++++++++++++---- .../tests/lsps1_integration_tests.rs | 8 ++-- 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/lightning-liquidity/src/lsps1/msgs.rs b/lightning-liquidity/src/lsps1/msgs.rs index 6021b650cfa..9eff06e7d90 100644 --- a/lightning-liquidity/src/lsps1/msgs.rs +++ b/lightning-liquidity/src/lsps1/msgs.rs @@ -310,7 +310,12 @@ impl_writeable_tlv_based!(LSPS1OnchainPaymentInfo, { pub enum LSPS1PaymentState { /// A payment is expected. ExpectPayment, - /// A sufficient payment has been received. + /// A payment has been received but the channel has not yet been opened. + /// + /// This indicates the LSP has received the payment (e.g., Lightning HTLC held, + /// or on-chain transaction detected) but has not yet published the funding transaction. + Hold, + /// A sufficient payment has been received and the channel has been opened. Paid, /// The payment has been refunded. #[serde(alias = "CANCELLED")] @@ -319,8 +324,9 @@ pub enum LSPS1PaymentState { impl_writeable_tlv_based_enum!(LSPS1PaymentState, (0, ExpectPayment) => {}, - (2, Paid) => {}, - (4, Refunded) => {} + (2, Hold) => {}, + (4, Paid) => {}, + (6, Refunded) => {} ); /// Details regarding the state of an ordered channel. diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 64e87d55ed0..c103a4fd1ac 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -102,17 +102,17 @@ impl ChannelOrderState { /// Transition: ExpectingPayment -> OrderPaid /// - /// Updates the specified payment method's state to PAID. + /// Updates the specified payment method's state to HOLD. pub(super) fn payment_received( &mut self, method: PaymentMethod, ) -> Result<(), ChannelOrderStateError> { match self { ChannelOrderState::ExpectingPayment { payment_details } => { - // Update the payment state for the specified method + // Update the payment state for the specified method to HOLD let method_exists = match method { PaymentMethod::Bolt11 => { if let Some(ref mut bolt11) = payment_details.bolt11 { - bolt11.state = LSPS1PaymentState::Paid; + bolt11.state = LSPS1PaymentState::Hold; true } else { false @@ -120,7 +120,7 @@ impl ChannelOrderState { }, PaymentMethod::Bolt12 => { if let Some(ref mut bolt12) = payment_details.bolt12 { - bolt12.state = LSPS1PaymentState::Paid; + bolt12.state = LSPS1PaymentState::Hold; true } else { false @@ -128,7 +128,7 @@ impl ChannelOrderState { }, PaymentMethod::Onchain => { if let Some(ref mut onchain) = payment_details.onchain { - onchain.state = LSPS1PaymentState::Paid; + onchain.state = LSPS1PaymentState::Hold; true } else { false @@ -152,13 +152,33 @@ impl ChannelOrderState { } /// Transition: OrderPaid -> CompletedAndChannelOpened + /// + /// Updates payment states from HOLD to PAID. pub(super) fn channel_opened( &mut self, channel_info: LSPS1ChannelInfo, ) -> Result<(), ChannelOrderStateError> { match self { ChannelOrderState::OrderPaid { payment_details } => { + // Update payment states from HOLD to PAID + let mut paid_details = payment_details.clone(); + if let Some(ref mut bolt11) = paid_details.bolt11 { + if bolt11.state == LSPS1PaymentState::Hold { + bolt11.state = LSPS1PaymentState::Paid; + } + } + if let Some(ref mut bolt12) = paid_details.bolt12 { + if bolt12.state == LSPS1PaymentState::Hold { + bolt12.state = LSPS1PaymentState::Paid; + } + } + if let Some(ref mut onchain) = paid_details.onchain { + if onchain.state == LSPS1PaymentState::Hold { + onchain.state = LSPS1PaymentState::Paid; + } + } + *self = ChannelOrderState::CompletedAndChannelOpened { - payment_details: payment_details.clone(), + payment_details: paid_details, channel_info, }; Ok(()) @@ -524,7 +544,8 @@ mod tests { assert!(matches!(state, ChannelOrderState::OrderPaid { .. })); assert_eq!(state.order_state(), LSPS1OrderState::Created); - assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Paid); + // Payment state should be HOLD (not PAID) until channel is opened + assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Hold); } // Test valid transition: ExpectingPayment -> OrderPaid via payment_received (Onchain) @@ -536,9 +557,10 @@ mod tests { state.payment_received(PaymentMethod::Onchain).unwrap(); assert!(matches!(state, ChannelOrderState::OrderPaid { .. })); + // Payment state should be HOLD (not PAID) until channel is opened assert_eq!( state.payment_details().onchain.as_ref().unwrap().state, - LSPS1PaymentState::Paid + LSPS1PaymentState::Hold ); } @@ -549,12 +571,17 @@ mod tests { let mut state = ChannelOrderState::new(payment_info); state.payment_received(PaymentMethod::Bolt11).unwrap(); + // Verify payment state is HOLD before channel opens + assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Hold); + let channel_info = create_test_channel_info(); state.channel_opened(channel_info.clone()).unwrap(); assert!(matches!(state, ChannelOrderState::CompletedAndChannelOpened { .. })); assert_eq!(state.order_state(), LSPS1OrderState::Completed); assert_eq!(state.channel_info(), Some(&channel_info)); + // Payment state should now be PAID after channel is opened + assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Paid); } // Test valid transition: ExpectingPayment -> FailedAndRefunded @@ -580,10 +607,14 @@ mod tests { let mut state = ChannelOrderState::new(payment_info); state.payment_received(PaymentMethod::Bolt11).unwrap(); + // Verify payment state is HOLD before failure + assert_eq!(state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Hold); + state.mark_failed_and_refunded().unwrap(); assert!(matches!(state, ChannelOrderState::FailedAndRefunded { .. })); assert_eq!(state.order_state(), LSPS1OrderState::Failed); + // Payment state should now be REFUNDED assert_eq!( state.payment_details().bolt11.as_ref().unwrap().state, LSPS1PaymentState::Refunded diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 92ad06abfdc..63185669bbf 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -725,8 +725,8 @@ fn lsps1_order_state_transitions() { if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { payment, channel, .. }) = order_status_event { - // Payment state should be Paid - assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::Paid); + // Payment state should be Hold (payment received but channel not yet opened) + assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::Hold); // No channel info yet (order state is still Created internally) assert!(channel.is_none()); } else { @@ -754,9 +754,11 @@ fn lsps1_order_state_transitions() { client_node.liquidity_manager.handle_custom_message(order_response, service_node_id).unwrap(); let order_status_event = client_node.liquidity_manager.next_event().unwrap(); - if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { channel, .. }) = + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { payment, channel, .. }) = order_status_event { + // Payment state should now be Paid (channel has been opened) + assert_eq!(payment.onchain.as_ref().unwrap().state, LSPS1PaymentState::Paid); // Channel info should be present (indicates Completed state) assert_eq!(channel, Some(channel_info)); } else { From 9c7be159e737f28f85fe5ea76350b951f5bdd2c7 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Feb 2026 10:52:25 +0100 Subject: [PATCH 36/41] Drop unused `LSPS1ServiceEvent::Refund` event Turns out this was another variant we didn't actually use anywhere. So we're dropping it. --- lightning-liquidity/src/lsps1/event.rs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs index d78d6d975c2..1d188421e9d 100644 --- a/lightning-liquidity/src/lsps1/event.rs +++ b/lightning-liquidity/src/lsps1/event.rs @@ -174,15 +174,4 @@ pub enum LSPS1ServiceEvent { /// client. refund_onchain_address: Option
        , }, - /// If error is encountered, refund the amount if paid by the client. - /// - /// **Note: ** This event will *not* be persisted across restarts. - Refund { - /// An identifier. - request_id: LSPSRequestId, - /// The node id of the client making the information request. - counterparty_node_id: PublicKey, - /// The order id of the refunded order. - order_id: LSPS1OrderId, - }, } From f2ccc69c270b2cf4efff0b898a3c8579ffdfd828 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Feb 2026 11:13:37 +0100 Subject: [PATCH 37/41] Add `onchain_payment_required` method We previously had no way to reject requests in case the LSP requires onchain payment while the client not providing `refund_onchain_address`. Here we add a method allowing to do so. --- lightning-liquidity/src/lsps1/event.rs | 7 ++- lightning-liquidity/src/lsps1/msgs.rs | 2 +- lightning-liquidity/src/lsps1/service.rs | 56 ++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs index 1d188421e9d..f0a1a0a8cae 100644 --- a/lightning-liquidity/src/lsps1/event.rs +++ b/lightning-liquidity/src/lsps1/event.rs @@ -170,8 +170,11 @@ pub enum LSPS1ServiceEvent { order: LSPS1OrderParams, /// The address we need to send onchain refunds to in case channel opening fails. /// - /// Please note that you can't offer onchain payments if this was not provided by the - /// client. + /// In case this is `None` you might just provide Lightning payments options to the client. + /// If you *require* onchain payment, you should call + /// [`LSPS1ServiceHandler::onchain_payments_required`] to reject the request. + /// + /// [`LSPS1ServiceHandler::onchain_payments_required`]: crate::lsps1::service::LSPS1ServiceHandler::onchain_payments_required refund_onchain_address: Option
        , }, } diff --git a/lightning-liquidity/src/lsps1/msgs.rs b/lightning-liquidity/src/lsps1/msgs.rs index 9eff06e7d90..b754f0438aa 100644 --- a/lightning-liquidity/src/lsps1/msgs.rs +++ b/lightning-liquidity/src/lsps1/msgs.rs @@ -31,7 +31,7 @@ pub(crate) const LSPS1_CREATE_ORDER_METHOD_NAME: &str = "lsps1.create_order"; pub(crate) const LSPS1_GET_ORDER_METHOD_NAME: &str = "lsps1.get_order"; pub(crate) const _LSPS1_CREATE_ORDER_REQUEST_INVALID_PARAMS_ERROR_CODE: i32 = -32602; -pub(crate) const LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE: i32 = 100; +pub(crate) const LSPS1_CREATE_ORDER_REQUEST_OPTION_MISMATCH_ERROR_CODE: i32 = 100; pub(crate) const LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE: i32 = 101; pub(crate) const LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE: i32 = 102; diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index d628c4d6bfe..9db271e3c86 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -23,7 +23,7 @@ use super::msgs::{ LSPS1ChannelInfo, LSPS1CreateOrderRequest, LSPS1CreateOrderResponse, LSPS1GetInfoResponse, LSPS1GetOrderRequest, LSPS1Message, LSPS1Options, LSPS1OrderId, LSPS1OrderParams, LSPS1PaymentInfo, LSPS1Request, LSPS1Response, - LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, + LSPS1_CREATE_ORDER_REQUEST_OPTION_MISMATCH_ERROR_CODE, LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE, LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, }; @@ -291,7 +291,7 @@ where if !is_valid(¶ms.order, &self.config.supported_options) { let response = LSPS1Response::CreateOrderError(LSPSResponseError { - code: LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, + code: LSPS1_CREATE_ORDER_REQUEST_OPTION_MISMATCH_ERROR_CODE, message: "Order does not match options supported by LSP server".to_string(), data: Some(format!("Supported options are {:?}", &self.config.supported_options)), }); @@ -337,7 +337,8 @@ where /// Should be called in response to receiving a [`LSPS1ServiceEvent::RequestForPaymentDetails`] event. /// /// Note that the provided `payment_details` can't include the onchain payment variant if the - /// user didn't provide a `refund_onchain_address`. + /// user didn't provide a `refund_onchain_address`. If you *require* onchain payments, you need + /// to call [`Self::onchain_payments_required`] to reject the request. /// /// [`LSPS1ServiceEvent::RequestForPaymentDetails`]: crate::lsps1::event::LSPS1ServiceEvent::RequestForPaymentDetails pub async fn send_payment_details( @@ -469,6 +470,45 @@ where } } + /// Used by LSP to inform a client that an order was rejected because they require onchain + /// payments and the client didn't provided a `refund_onchain_address`. + /// + /// Should be called in response to receiving a [`LSPS1ServiceEvent::RequestForPaymentDetails`] + /// event if the LSP requires onchain payments and `refund_onchain_address` is `None`. + /// + /// [`LSPS1ServiceEvent::RequestForPaymentDetails`]: crate::lsps1::event::LSPS1ServiceEvent::RequestForPaymentDetails + pub fn onchain_payments_required( + &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, + ) -> Result<(), APIError> { + let mut message_queue_notifier = self.pending_messages.notifier(); + + match self.per_peer_state.read().unwrap().get(&counterparty_node_id) { + Some(inner_state_lock) => { + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + peer_state_lock.remove_request(&request_id).map_err(|e| { + debug_assert!(false, "Failed to send response due to: {}", e); + let err = format!("Failed to send response due to: {}", e); + APIError::APIMisuseError { err } + })?; + + let response = LSPS1Response::CreateOrderError(LSPSResponseError { + code: LSPS1_CREATE_ORDER_REQUEST_OPTION_MISMATCH_ERROR_CODE, + message: + "We require onchain payment but no `refund_onchain_address` was provided" + .to_string(), + data: None, + }); + + let msg = LSPS1Message::Response(request_id, response).into(); + message_queue_notifier.enqueue(&counterparty_node_id, msg); + Ok(()) + }, + None => Err(APIError::APIMisuseError { + err: format!("No state for the counterparty exists: {}", counterparty_node_id), + }), + } + } + fn handle_get_order_request( &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, params: LSPS1GetOrderRequest, @@ -755,6 +795,16 @@ where self.inner.invalid_token_provided(counterparty_node_id, request_id) } + /// Used by LSP to inform a client that an order was rejected because they require onchain + /// payments and the client didn't provided a `refund_onchain_address`. + /// + /// Wraps [`LSPS1ServiceHandler::onchain_payments_required`]. + pub fn onchain_payments_required( + &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, + ) -> Result<(), APIError> { + self.inner.onchain_payments_required(counterparty_node_id, request_id) + } + /// Marks an order as paid after payment has been received. /// /// Wraps [`LSPS1ServiceHandler::order_payment_received`]. From f9bd7b882f97d29c33cd72fd6a8490978b15ac1b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 18 Feb 2026 13:45:23 +0100 Subject: [PATCH 38/41] f reword docs --- lightning-liquidity/src/lsps1/event.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs index f0a1a0a8cae..8868790da31 100644 --- a/lightning-liquidity/src/lsps1/event.rs +++ b/lightning-liquidity/src/lsps1/event.rs @@ -170,8 +170,7 @@ pub enum LSPS1ServiceEvent { order: LSPS1OrderParams, /// The address we need to send onchain refunds to in case channel opening fails. /// - /// In case this is `None` you might just provide Lightning payments options to the client. - /// If you *require* onchain payment, you should call + /// If this is `None` and you *require* onchain payment, you should call /// [`LSPS1ServiceHandler::onchain_payments_required`] to reject the request. /// /// [`LSPS1ServiceHandler::onchain_payments_required`]: crate::lsps1::service::LSPS1ServiceHandler::onchain_payments_required From f6b42bd7fe840a16d425b3dbb9da581b67609481 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Feb 2026 13:09:43 +0100 Subject: [PATCH 39/41] Limit pending requests and peers in LSPS1 service Add per-peer and global rate limiting to `LSPS1ServiceHandler` to prevent resource exhaustion, mirroring the existing LSPS2 pattern. Introduce `MAX_PENDING_REQUESTS_PER_PEER` (10), `MAX_TOTAL_PENDING_REQUESTS` (1000), and `MAX_TOTAL_PEERS` (100000) constants and enforce them in `handle_create_order_request`. Rejected requests receive a `CreateOrderError` with `LSPS0_CLIENT_REJECTED_ERROR_CODE`. A `total_pending_requests` atomic counter tracks the global count, and a `verify_pending_request_counter` debug assertion ensures it stays in sync. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps1/peer_state.rs | 24 ++- lightning-liquidity/src/lsps1/service.rs | 111 +++++++++- .../tests/lsps1_integration_tests.rs | 204 +++++++++++++++++- 3 files changed, 325 insertions(+), 14 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index c103a4fd1ac..285b6130502 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -353,6 +353,20 @@ impl PeerState { self.pending_requests.remove(request_id).ok_or(PeerStateError::UnknownRequestId) } + pub(super) fn pending_request_count(&self) -> usize { + self.pending_requests.len() + } + + pub(super) fn pending_requests_and_channels(&self) -> usize { + let pending_requests = self.pending_requests.len(); + let pending_orders = self + .outbound_channels_by_order_id + .iter() + .filter(|(_, v)| v.is_pending_payment()) + .count(); + pending_requests + pending_orders + } + pub(super) fn has_active_requests(&self) -> bool { !self.outbound_channels_by_order_id.is_empty() } @@ -370,8 +384,10 @@ impl PeerState { self.pending_requests.is_empty() && self.outbound_channels_by_order_id.is_empty() } - pub(super) fn prune_pending_requests(&mut self) { - self.pending_requests.clear() + pub(super) fn prune_pending_requests(&mut self) -> usize { + let num_pruned = self.pending_requests.len(); + self.pending_requests.clear(); + num_pruned } pub(super) fn prune_expired_request_state(&mut self) { @@ -433,6 +449,10 @@ impl ChannelOrder { self.state.channel_info() } + fn is_pending_payment(&self) -> bool { + matches!(self.state, ChannelOrderState::ExpectingPayment { .. }) + } + fn is_prunable(&self) -> bool { let all_payment_details_expired; #[cfg(feature = "time")] diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 9db271e3c86..e6d864042b7 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -34,6 +34,7 @@ use crate::message_queue::MessageQueue; use crate::events::EventQueue; use crate::lsps0::ser::{ LSPSDateTime, LSPSProtocolMessageHandler, LSPSRequestId, LSPSResponseError, + LSPS0_CLIENT_REJECTED_ERROR_CODE, }; use crate::persist::{ LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, LSPS1_SERVICE_PERSISTENCE_SECONDARY_NAMESPACE, @@ -62,6 +63,10 @@ pub struct LSPS1ServiceConfig { pub supported_options: LSPS1Options, } +const MAX_PENDING_REQUESTS_PER_PEER: usize = 10; +const MAX_TOTAL_PENDING_REQUESTS: usize = 1000; +const MAX_TOTAL_PEERS: usize = 100000; + /// The main object allowing to send and receive bLIP-51 / LSPS1 messages. pub struct LSPS1ServiceHandler< ES: EntropySource, @@ -78,6 +83,7 @@ pub struct LSPS1ServiceHandler< pending_messages: Arc, pending_events: Arc>, per_peer_state: RwLock>>, + total_pending_requests: AtomicUsize, persistence_in_flight: AtomicUsize, time_provider: TP, config: LSPS1ServiceConfig, @@ -102,6 +108,7 @@ where pending_messages, pending_events, per_peer_state: RwLock::new(per_peer_state), + total_pending_requests: AtomicUsize::new(0), persistence_in_flight: AtomicUsize::new(0), time_provider, config, @@ -133,7 +140,8 @@ where let mut peer_state_lock = inner_state_lock.lock().unwrap(); // We clean up the peer state, but leave removing the peer entry to the prune logic in // `persist` which removes it from the store. - peer_state_lock.prune_pending_requests(); + let num_pruned = peer_state_lock.prune_pending_requests(); + self.total_pending_requests.fetch_sub(num_pruned, Ordering::Relaxed); peer_state_lock.prune_expired_request_state(); } } @@ -309,17 +317,75 @@ where { let mut outer_state_lock = self.per_peer_state.write().unwrap(); + if !outer_state_lock.contains_key(counterparty_node_id) + && outer_state_lock.len() >= MAX_TOTAL_PEERS + { + let response = LSPS1Response::CreateOrderError(LSPSResponseError { + code: LSPS0_CLIENT_REJECTED_ERROR_CODE, + message: "Reached maximum number of pending requests. Please try again later." + .to_string(), + data: None, + }); + let msg = LSPS1Message::Response(request_id, response).into(); + message_queue_notifier.enqueue(counterparty_node_id, msg); + return Err(LightningError { + err: format!( + "Dropping request from peer {} due to reaching maximally allowed number of total peers: {}", + counterparty_node_id, MAX_TOTAL_PEERS + ), + action: ErrorAction::IgnoreAndLog(Level::Debug), + }); + } + + if self.total_pending_requests.load(Ordering::Relaxed) >= MAX_TOTAL_PENDING_REQUESTS { + let response = LSPS1Response::CreateOrderError(LSPSResponseError { + code: LSPS0_CLIENT_REJECTED_ERROR_CODE, + message: "Reached maximum number of pending requests. Please try again later." + .to_string(), + data: None, + }); + let msg = LSPS1Message::Response(request_id, response).into(); + message_queue_notifier.enqueue(counterparty_node_id, msg); + return Err(LightningError { + err: format!( + "Reached maximum number of total pending requests: {}", + MAX_TOTAL_PENDING_REQUESTS + ), + action: ErrorAction::IgnoreAndLog(Level::Debug), + }); + } + let inner_state_lock = outer_state_lock .entry(*counterparty_node_id) .or_insert(Mutex::new(PeerState::default())); let mut peer_state_lock = inner_state_lock.lock().unwrap(); + if peer_state_lock.pending_requests_and_channels() >= MAX_PENDING_REQUESTS_PER_PEER { + let response = LSPS1Response::CreateOrderError(LSPSResponseError { + code: LSPS0_CLIENT_REJECTED_ERROR_CODE, + message: "Reached maximum number of pending requests. Please try again later." + .to_string(), + data: None, + }); + let msg = LSPS1Message::Response(request_id, response).into(); + message_queue_notifier.enqueue(counterparty_node_id, msg); + return Err(LightningError { + err: format!( + "Peer {} reached maximum number of pending requests: {}", + counterparty_node_id, MAX_PENDING_REQUESTS_PER_PEER + ), + action: ErrorAction::IgnoreAndLog(Level::Debug), + }); + } + let request = LSPS1Request::CreateOrder(params.clone()); peer_state_lock.register_request(request_id.clone(), request).map_err(|e| { let err = format!("Failed to handle request due to: {}", e); let action = ErrorAction::IgnoreAndLog(Level::Error); LightningError { err, action } })?; + + self.total_pending_requests.fetch_add(1, Ordering::Relaxed); } event_queue_notifier.enqueue(LSPS1ServiceEvent::RequestForPaymentDetails { @@ -356,6 +422,7 @@ where let err = format!("Failed to send response due to: {}", e); APIError::APIMisuseError { err } })?; + self.total_pending_requests.fetch_sub(1, Ordering::Relaxed); match request { LSPS1Request::CreateOrder(params) => { @@ -453,6 +520,7 @@ where let err = format!("Failed to send response due to: {}", e); APIError::APIMisuseError { err } })?; + self.total_pending_requests.fetch_sub(1, Ordering::Relaxed); let response = LSPS1Response::CreateOrderError(LSPSResponseError { code: LSPS1_CREATE_ORDER_REQUEST_UNRECOGNIZED_OR_STALE_TOKEN_ERROR_CODE, @@ -490,6 +558,7 @@ where let err = format!("Failed to send response due to: {}", e); APIError::APIMisuseError { err } })?; + self.total_pending_requests.fetch_sub(1, Ordering::Relaxed); let response = LSPS1Response::CreateOrderError(LSPSResponseError { code: LSPS1_CREATE_ORDER_REQUEST_OPTION_MISMATCH_ERROR_CODE, @@ -693,6 +762,21 @@ where let bytes = self.entropy_source.get_secure_random_bytes(); LSPS1OrderId(utils::hex_str(&bytes[0..16])) } + + #[cfg(debug_assertions)] + fn verify_pending_request_counter(&self) { + let mut num_requests = 0; + let outer_state_lock = self.per_peer_state.read().unwrap(); + for (_, inner) in outer_state_lock.iter() { + let inner_state_lock = inner.lock().unwrap(); + num_requests += inner_state_lock.pending_request_count(); + } + debug_assert_eq!( + num_requests, + self.total_pending_requests.load(Ordering::Relaxed), + "total_pending_requests counter out-of-sync! This should never happen!" + ); + } } impl @@ -708,16 +792,21 @@ where &self, message: Self::ProtocolMessage, counterparty_node_id: &PublicKey, ) -> Result<(), LightningError> { match message { - LSPS1Message::Request(request_id, request) => match request { - LSPS1Request::GetInfo(_) => { - self.handle_get_info_request(request_id, counterparty_node_id) - }, - LSPS1Request::CreateOrder(params) => { - self.handle_create_order_request(request_id, counterparty_node_id, params) - }, - LSPS1Request::GetOrder(params) => { - self.handle_get_order_request(request_id, counterparty_node_id, params) - }, + LSPS1Message::Request(request_id, request) => { + let res = match request { + LSPS1Request::GetInfo(_) => { + self.handle_get_info_request(request_id, counterparty_node_id) + }, + LSPS1Request::CreateOrder(params) => { + self.handle_create_order_request(request_id, counterparty_node_id, params) + }, + LSPS1Request::GetOrder(params) => { + self.handle_get_order_request(request_id, counterparty_node_id, params) + }, + }; + #[cfg(debug_assertions)] + self.verify_pending_request_counter(); + res }, _ => { debug_assert!( diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 63185669bbf..0a08590eda6 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -24,7 +24,7 @@ use lightning::ln::functional_test_utils::{ }; use lightning::util::test_utils::{TestBroadcaster, TestStore}; -use bitcoin::secp256k1::PublicKey; +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::{Address, Network, OutPoint}; use std::str::FromStr; @@ -34,6 +34,9 @@ use lightning::ln::functional_test_utils::{create_network, Node}; use lightning_liquidity::lsps1::msgs::LSPS1OrderId; use lightning_liquidity::utils::time::TimeProvider; +const MAX_PENDING_REQUESTS_PER_PEER: usize = 10; +const MAX_TOTAL_PENDING_REQUESTS: usize = 1000; + fn build_lsps1_configs( supported_options: LSPS1Options, ) -> (LiquidityServiceConfig, LiquidityClientConfig) { @@ -1139,3 +1142,202 @@ fn lsps1_expired_orders_are_pruned_and_not_persisted() { } } } + +#[test] +fn max_pending_requests_per_peer_rejected() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let supported_options = LSPS1Options { + min_required_channel_confirmations: 0, + min_funding_confirms_within_blocks: 6, + supports_zero_channel_reserve: true, + max_channel_expiry_blocks: 144, + min_initial_client_balance_sat: 10_000_000, + max_initial_client_balance_sat: 100_000_000, + min_initial_lsp_balance_sat: 100_000, + max_initial_lsp_balance_sat: 100_000_000, + min_channel_balance_sat: 100_000, + max_channel_balance_sat: 100_000_000, + }; + + let LSPSNodes { service_node, client_node } = + setup_test_lsps1_nodes(nodes, supported_options.clone()); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + let client_handler = client_node.liquidity_manager.lsps1_client_handler().unwrap(); + + let order_params = LSPS1OrderParams { + lsp_balance_sat: 100_000, + client_balance_sat: 10_000_000, + required_channel_confirmations: 0, + funding_confirms_within_blocks: 6, + channel_expiry_blocks: 144, + token: None, + announce_channel: true, + }; + + let refund_onchain_address = + Address::from_str("bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr") + .unwrap() + .assume_checked(); + + // Send MAX_PENDING_REQUESTS_PER_PEER create_order requests, all should succeed. + for _ in 0..MAX_PENDING_REQUESTS_PER_PEER { + let _ = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address.clone()), + ); + let req_msg = get_lsps_message!(client_node, service_node_id); + let result = service_node.liquidity_manager.handle_custom_message(req_msg, client_node_id); + assert!(result.is_ok()); + let event = service_node.liquidity_manager.next_event().unwrap(); + assert!(matches!( + event, + LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::RequestForPaymentDetails { .. }) + )); + } + + // The next request should be rejected due to per-peer limit. + let rejected_req_id = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address), + ); + let rejected_req_msg = get_lsps_message!(client_node, service_node_id); + let result = + service_node.liquidity_manager.handle_custom_message(rejected_req_msg, client_node_id); + assert!(result.is_err(), "We should have hit the per-peer limit"); + + let error_response = get_lsps_message!(service_node, client_node_id); + let result = + client_node.liquidity_manager.handle_custom_message(error_response, service_node_id); + assert!(result.is_err()); + + let event = client_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderRequestFailed { + request_id, + counterparty_node_id, + error, + }) = event + { + assert_eq!(request_id, rejected_req_id); + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(error.code, 1); // LSPS0_CLIENT_REJECTED_ERROR_CODE + } else { + panic!("Expected LSPS1ClientEvent::OrderRequestFailed event"); + } +} + +#[test] +fn max_total_pending_requests_rejected() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let supported_options = LSPS1Options { + min_required_channel_confirmations: 0, + min_funding_confirms_within_blocks: 6, + supports_zero_channel_reserve: true, + max_channel_expiry_blocks: 144, + min_initial_client_balance_sat: 10_000_000, + max_initial_client_balance_sat: 100_000_000, + min_initial_lsp_balance_sat: 100_000, + max_initial_lsp_balance_sat: 100_000_000, + min_channel_balance_sat: 100_000, + max_channel_balance_sat: 100_000_000, + }; + + let LSPSNodes { service_node, client_node } = + setup_test_lsps1_nodes(nodes, supported_options.clone()); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_handler = client_node.liquidity_manager.lsps1_client_handler().unwrap(); + + let secp = Secp256k1::new(); + + let order_params = LSPS1OrderParams { + lsp_balance_sat: 100_000, + client_balance_sat: 10_000_000, + required_channel_confirmations: 0, + funding_confirms_within_blocks: 6, + channel_expiry_blocks: 144, + token: None, + announce_channel: true, + }; + + let refund_onchain_address = + Address::from_str("bc1p5uvtaxzkjwvey2tfy49k5vtqfpjmrgm09cvs88ezyy8h2zv7jhas9tu4yr") + .unwrap() + .assume_checked(); + + // Fill up the global limit by sending requests from many different synthetic peers. + let mut filled = 0; + let mut peer_idx: u8 = 1; + + while filled < MAX_TOTAL_PENDING_REQUESTS { + let sk = SecretKey::from_slice(&[peer_idx; 32]).unwrap(); + let peer_node_id = PublicKey::from_secret_key(&secp, &sk); + + for _ in 0..MAX_PENDING_REQUESTS_PER_PEER { + if filled >= MAX_TOTAL_PENDING_REQUESTS { + break; + } + + let _ = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address.clone()), + ); + let req_msg = get_lsps_message!(client_node, service_node_id); + let result = + service_node.liquidity_manager.handle_custom_message(req_msg, peer_node_id); + assert!(result.is_ok()); + + let event = service_node.liquidity_manager.next_event().unwrap(); + assert!(matches!( + event, + LiquidityEvent::LSPS1Service(LSPS1ServiceEvent::RequestForPaymentDetails { .. }) + )); + + filled += 1; + } + peer_idx += 1; + } + + // Now send one more request from a new peer -- should be rejected. + let new_sk = SecretKey::from_slice(&[peer_idx; 32]).unwrap(); + let new_peer_node_id = PublicKey::from_secret_key(&secp, &new_sk); + + let rejected_req_id = client_handler.create_order( + &service_node_id, + order_params.clone(), + Some(refund_onchain_address), + ); + let rejected_req_msg = get_lsps_message!(client_node, service_node_id); + let result = + service_node.liquidity_manager.handle_custom_message(rejected_req_msg, new_peer_node_id); + assert!(result.is_err(), "We should have hit the global pending requests limit"); + + let error_response = get_lsps_message!(service_node, new_peer_node_id); + let result = + client_node.liquidity_manager.handle_custom_message(error_response, service_node_id); + assert!(result.is_err()); + + let event = client_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderRequestFailed { + request_id, + counterparty_node_id, + error, + }) = event + { + assert_eq!(request_id, rejected_req_id); + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(error.code, 1); // LSPS0_CLIENT_REJECTED_ERROR_CODE + } else { + panic!("Expected LSPS1ClientEvent::OrderRequestFailed event"); + } +} From de78a031239c8c38fc6ff49774c63cce9d54eae1 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 18 Feb 2026 13:55:53 +0100 Subject: [PATCH 40/41] f Avoid double-lookup Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps1/service.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index e6d864042b7..199e62aa23d 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -316,10 +316,11 @@ where { let mut outer_state_lock = self.per_peer_state.write().unwrap(); + let num_peers = outer_state_lock.len(); - if !outer_state_lock.contains_key(counterparty_node_id) - && outer_state_lock.len() >= MAX_TOTAL_PEERS - { + let inner_state_entry = outer_state_lock.entry(*counterparty_node_id); + + if matches!(inner_state_entry, Entry::Vacant(_)) && num_peers >= MAX_TOTAL_PEERS { let response = LSPS1Response::CreateOrderError(LSPSResponseError { code: LSPS0_CLIENT_REJECTED_ERROR_CODE, message: "Reached maximum number of pending requests. Please try again later." @@ -355,10 +356,8 @@ where }); } - let inner_state_lock = outer_state_lock - .entry(*counterparty_node_id) - .or_insert(Mutex::new(PeerState::default())); - let mut peer_state_lock = inner_state_lock.lock().unwrap(); + let mut peer_state_lock = + inner_state_entry.or_insert(Mutex::new(PeerState::default())).lock().unwrap(); if peer_state_lock.pending_requests_and_channels() >= MAX_PENDING_REQUESTS_PER_PEER { let response = LSPS1Response::CreateOrderError(LSPSResponseError { From 104eb5fe532754d810de33da751e4b0c5e8767cf Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Feb 2026 13:48:19 +0100 Subject: [PATCH 41/41] Reject clients if request registration failed (e.g., duplicative Id) Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps1/service.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 199e62aa23d..98d15203593 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -380,6 +380,13 @@ where let request = LSPS1Request::CreateOrder(params.clone()); peer_state_lock.register_request(request_id.clone(), request).map_err(|e| { let err = format!("Failed to handle request due to: {}", e); + let response = LSPS1Response::CreateOrderError(LSPSResponseError { + code: LSPS0_CLIENT_REJECTED_ERROR_CODE, + message: err.clone(), + data: None, + }); + let msg = LSPS1Message::Response(request_id.clone(), response).into(); + message_queue_notifier.enqueue(counterparty_node_id, msg); let action = ErrorAction::IgnoreAndLog(Level::Error); LightningError { err, action } })?;