From a024a760f366eac594ac4ab8e1d3412a81c7f675 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 16 Jun 2025 11:58:23 -0300 Subject: [PATCH 01/35] 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 9a64a6595ca67e748336aa771fdaf83d240216d4 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 12:28:40 +0100 Subject: [PATCH 02/35] 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 0d7408bfe6b6d47bb1bf3a412da66e906b90a65d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Dec 2025 12:08:55 +0100 Subject: [PATCH 03/35] 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 8ad5101b8993cefb098d977349b879fd5e6028ea Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Dec 2025 12:24:41 +0100 Subject: [PATCH 04/35] 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 da415c70a32..fc58eda8eee 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 c6465f2ff573fe762fc368354895f02688e85018 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 12:44:55 +0100 Subject: [PATCH 05/35] 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 fa867c27b2ca6723f7190604cf62c676aac83fc3 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 13:07:09 +0100 Subject: [PATCH 06/35] 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 0c8e26a32969f5a7a30b6dee732a9e38f846d243 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 13:01:51 +0100 Subject: [PATCH 07/35] 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 bc8deb371d09740dc1f7c9717f2277bd9160e510 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 13:35:36 +0100 Subject: [PATCH 08/35] Use `PeerState::{get_order, has_active_orders}` 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. Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps1/peer_state.rs | 27 ++++++++----- lightning-liquidity/src/lsps1/service.rs | 45 ++++++++++----------- lightning-liquidity/src/manager.rs | 8 ++-- 3 files changed, 42 insertions(+), 38 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 729d6827330..172ace6db8f 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_orders(&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..0e5eacc7666 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -92,11 +92,11 @@ where /// `CreateOrder` request and replied with a `CreateOrder` response containing /// an `order_id`. /// Pending requests that are still awaiting our response are deliberately NOT counted. - pub(crate) fn has_active_requests(&self, counterparty_node_id: &PublicKey) -> bool { + pub(crate) fn has_active_orders(&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| { let peer_state = inner.lock().unwrap(); - !peer_state.outbound_channels_by_order_id.is_empty() + peer_state.has_active_orders() }) } @@ -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), diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 45a85e72003..db05d71a524 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -716,17 +716,17 @@ where .as_ref() .is_some_and(|h| h.has_active_requests(sender_node_id)); #[cfg(lsps1_service)] - let lsps1_has_active_requests = self + let lsps1_has_active_orders = self .lsps1_service_handler .as_ref() - .is_some_and(|h| h.has_active_requests(sender_node_id)); + .is_some_and(|h| h.has_active_orders(sender_node_id)); #[cfg(not(lsps1_service))] - let lsps1_has_active_requests = false; + let lsps1_has_active_orders = false; lsps5_service_handler.enforce_prior_activity_or_reject( sender_node_id, lsps2_has_active_requests, - lsps1_has_active_requests, + lsps1_has_active_orders, req_id.clone(), )? } From 15130047febb932db707b6bf452206b098fa492a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 14:11:22 +0100 Subject: [PATCH 09/35] 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 172ace6db8f..9adc3c9f6fb 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_orders(&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 0e5eacc7666..a75db346682 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 9cc5257eca166f5b2389ccaf550609aed47a67c0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 14:16:00 +0100 Subject: [PATCH 10/35] 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 9adc3c9f6fb..8f7c5a9c7ba 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 eb21bfd4944dcc44277a17d9e0cee3f8341b79a2 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Sun, 16 Nov 2025 14:44:46 +0100 Subject: [PATCH 11/35] 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 8f7c5a9c7ba..a3d2000b62e 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 a75db346682..52d97157798 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 5db2fcc2d42a54aa81082181ff09da6116a4b8e4 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Dec 2025 09:49:46 +0100 Subject: [PATCH 12/35] `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 52d97157798..0b0adc0947c 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 db05d71a524..85c8ba3ebe2 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 7adf95409ee396cd8464059b3243fe808792ca03 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Dec 2025 10:35:12 +0100 Subject: [PATCH 13/35] 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 0b0adc0947c..9ce5e67681a 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 a7f388842b0f99d1b4485f059915d8e2bc03ddc0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Dec 2025 11:13:22 +0100 Subject: [PATCH 14/35] 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 a3d2000b62e..31ea41db3d3 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 9ce5e67681a..f4e1c1d273d 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 38775f7cdea55ea91465de505fcf3e9477ffd0d6 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Dec 2025 13:32:02 +0100 Subject: [PATCH 15/35] 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 | 37 ++++++++++ 3 files changed, 133 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 31ea41db3d3..5af7537dd79 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..50665152a96 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,39 @@ 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 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 d33a701f4f411b79c8349296ee15995f36909c49 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Dec 2025 14:24:04 +0100 Subject: [PATCH 16/35] 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 | 309 ++++++++++++++++-- lightning-liquidity/src/manager.rs | 21 +- lightning-liquidity/src/persist.rs | 6 + .../tests/lsps1_integration_tests.rs | 2 +- 5 files changed, 365 insertions(+), 28 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 5af7537dd79..a4c477fe2f2 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_orders(&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 f4e1c1d273d..71587ae079c 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_orders(&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_orders() }) } + 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,18 +336,17 @@ 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| { - debug_assert!(false, "Failed to send response due to: {}", e); let err = format!("Failed to send response due to: {}", e); APIError::APIMisuseError { err } })?; @@ -208,6 +363,7 @@ where created_at, payment_details, ); + should_persist |= peer_state_lock.needs_persist(); let response = LSPS1Response::CreateOrder(LSPS1CreateOrderResponse { order: order.order_params, @@ -219,8 +375,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 +391,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 +470,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 +483,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 +547,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 85c8ba3ebe2..da87b4cac3e 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 58faa5e25c279881fac8835ca448318654d73058 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 16:17:31 +0100 Subject: [PATCH 17/35] 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 a4c477fe2f2..2b94f76665c 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 71587ae079c..459406e117d 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 da87b4cac3e..99a0c8f0306 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 b7b2f7ef3957b452d0105517026ab48af424c1bc Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 16:01:30 +0100 Subject: [PATCH 18/35] 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 8304ebefc0333de1d5b27923331ceb7a8aed75a0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 13:23:43 +0100 Subject: [PATCH 19/35] 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. Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps1/event.rs | 6 +++ lightning-liquidity/src/lsps1/peer_state.rs | 6 +++ lightning-liquidity/src/lsps1/service.rs | 52 ++++++++++++++++++- .../tests/lsps1_integration_tests.rs | 16 ++++-- 4 files changed, 76 insertions(+), 4 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/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 2b94f76665c..1b51f64a583 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -82,6 +82,12 @@ impl PeerState { Ok(()) } + pub(super) fn get_request( + &self, request_id: &LSPSRequestId, + ) -> Result<&LSPS1Request, PeerStateError> { + self.pending_requests.get(request_id).ok_or(PeerStateError::UnknownRequestId) + } + pub(super) fn remove_request( &mut self, request_id: &LSPSRequestId, ) -> Result { diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 459406e117d..478fc294b60 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -22,7 +22,7 @@ use super::event::LSPS1ServiceEvent; use super::msgs::{ LSPS1ChannelInfo, LSPS1CreateOrderRequest, LSPS1CreateOrderResponse, LSPS1GetInfoResponse, LSPS1GetOrderRequest, LSPS1Message, LSPS1Options, LSPS1OrderId, LSPS1OrderParams, - LSPS1OrderState, LSPS1PaymentInfo, LSPS1Request, LSPS1Response, + LSPS1OrderState, LSPS1PaymentInfo, LSPS1PaymentState, LSPS1Request, LSPS1Response, LSPS1_CREATE_ORDER_REQUEST_ORDER_MISMATCH_ERROR_CODE, LSPS1_GET_ORDER_REQUEST_ORDER_NOT_FOUND_ERROR_CODE, }; @@ -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, @@ -343,9 +347,54 @@ where let mut message_queue_notifier = self.pending_messages.notifier(); let mut should_persist = false; + 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 payment_details + .bolt11 + .as_ref() + .is_some_and(|b| b.state != LSPS1PaymentState::ExpectPayment) + || payment_details + .bolt12 + .as_ref() + .is_some_and(|b| b.state != LSPS1PaymentState::ExpectPayment) + || payment_details + .onchain + .as_ref() + .is_some_and(|o| o.state != LSPS1PaymentState::ExpectPayment) + { + return Err(APIError::APIMisuseError { + err: "All payment methods must start in ExpectPayment state".to_string(), + }); + } + 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(); + + // Validate payment_details against the pending request before removing it, + // so the LSP operator can retry on failure. + if payment_details.onchain.is_some() { + let request = peer_state_lock.get_request(&request_id).map_err(|e| { + let err = format!("Failed to send response due to: {}", e); + APIError::APIMisuseError { err } + })?; + let has_refund_addr = matches!( + request, + LSPS1Request::CreateOrder(p) if p.refund_onchain_address.is_some() + ); + if !has_refund_addr { + // 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 request = peer_state_lock.remove_request(&request_id).map_err(|e| { let err = format!("Failed to send response due to: {}", e); APIError::APIMisuseError { err } @@ -357,6 +406,7 @@ where 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, diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 01c9a38b982..0261a088244 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.clone()), + ); let create_order = get_lsps_message!(client_node, service_node_id); service_node.liquidity_manager.handle_custom_message(create_order, client_node_id).unwrap(); @@ -157,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"); } @@ -339,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 d210b889a0e7219f1f2743e587844fc78f1f408a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 14:37:41 +0100 Subject: [PATCH 20/35] 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 478fc294b60..d02d0f369c3 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 f043b2e113763f0b9a2b6d00f1b311fe02fdcf57 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 15:01:05 +0100 Subject: [PATCH 21/35] 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. Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps1/event.rs | 4 ++ lightning-liquidity/src/lsps1/msgs.rs | 1 + lightning-liquidity/src/lsps1/service.rs | 49 +++++++++++++++++-- .../tests/lsps0_integration_tests.rs | 2 +- .../tests/lsps1_integration_tests.rs | 3 +- 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs index cdd09955163..c9a1844da85 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 + /// part of 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`]. /// 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 d02d0f369c3..e81213cc523 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, LSPS1PaymentState, 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, } @@ -462,6 +461,41 @@ 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| { + 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, @@ -653,6 +687,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 be5c2c1a9ee05868dd6121f4a610ac61380900d4 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 15:25:55 +0100 Subject: [PATCH 22/35] 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 23223851c5d369134c9511cc4f6a1e63ab063a42 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 10:45:39 +0100 Subject: [PATCH 23/35] Drop `lsps1_service` cfg flag Signed-off-by: Elias Rohrer --- ci/ci-tests-cfg-flags.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-cfg-flags.sh b/ci/ci-tests-cfg-flags.sh index 5380c986f3f..2bdc94b0cfc 100755 --- a/ci/ci-tests-cfg-flags.sh +++ b/ci/ci-tests-cfg-flags.sh @@ -9,6 +9,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 99a0c8f0306..f1b098dbfaa 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_orders = self .lsps1_service_handler .as_ref() .is_some_and(|h| h.has_active_orders(sender_node_id)); - #[cfg(not(lsps1_service))] - let lsps1_has_active_orders = 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 ed9a8672028385ffabb92898f9bfb9c09437d622 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Dec 2025 17:04:09 +0100 Subject: [PATCH 24/35] 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 e81213cc523..c4a678dad8e 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(); @@ -509,7 +509,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(); @@ -534,7 +535,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 c7db17de48151c8270c1ca5c230815fa48cca42e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Feb 2026 13:16:55 +0100 Subject: [PATCH 25/35] 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 | 180 +++++- 2 files changed, 709 insertions(+), 56 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index 1b51f64a583..1d13d07d206 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(()) } @@ -132,11 +377,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 { @@ -145,6 +391,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), } } } @@ -152,18 +399,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()) @@ -175,8 +435,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 } @@ -184,8 +447,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 c4a678dad8e..bc10116b14e 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, LSPS1PaymentState, LSPS1Request, LSPS1Response, + LSPS1PaymentInfo, LSPS1PaymentState, 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; @@ -415,13 +416,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); @@ -523,10 +523,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); @@ -551,23 +551,108 @@ 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: + /// - 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( + &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 => { @@ -697,19 +782,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 c5139d0f329ed97b9e9928848d8c430ed445d66e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Feb 2026 13:28:16 +0100 Subject: [PATCH 26/35] 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 | 294 +++++++++++++++++- 1 file changed, 290 insertions(+), 4 deletions(-) diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index d2ca559e577..91825f9540b 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; @@ -550,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); @@ -564,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"); @@ -600,3 +605,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 248487381a9042085b64b0441c87c7d669d5c8ef Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Feb 2026 13:32:02 +0100 Subject: [PATCH 27/35] 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 029ad804ea89605108ca62929570d6eb3f1d8d05 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Feb 2026 13:44:17 +0100 Subject: [PATCH 28/35] 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 36e11982c4d2d8b71c834a8fdccdb4ff21b35c5b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Feb 2026 13:51:28 +0100 Subject: [PATCH 29/35] 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 | 49 +++++++++++++++---- .../tests/lsps1_integration_tests.rs | 8 +-- 3 files changed, 54 insertions(+), 15 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 1d13d07d206..d2b806c6dbd 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(()) @@ -276,7 +296,7 @@ impl PeerState { /// Transition: ExpectingPayment -> OrderPaid /// - /// Updates the specified payment method's state to PAID. + /// Updates the specified payment method's state to HOLD. pub(super) fn order_payment_received( &mut self, order_id: &LSPS1OrderId, method: PaymentMethod, ) -> Result<(), PeerStateError> { @@ -530,7 +550,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) @@ -542,9 +563,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 ); } @@ -555,12 +577,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 @@ -586,10 +613,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 c0cef54b21904559de7718fb212138ebf969320a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Feb 2026 10:52:25 +0100 Subject: [PATCH 30/35] 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 98f71f5df5b51b4c1956db0abb3f187129adb235 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Feb 2026 11:13:37 +0100 Subject: [PATCH 31/35] 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 | 6 ++- lightning-liquidity/src/lsps1/msgs.rs | 2 +- lightning-liquidity/src/lsps1/service.rs | 57 ++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/lightning-liquidity/src/lsps1/event.rs b/lightning-liquidity/src/lsps1/event.rs index 1d188421e9d..8868790da31 100644 --- a/lightning-liquidity/src/lsps1/event.rs +++ b/lightning-liquidity/src/lsps1/event.rs @@ -170,8 +170,10 @@ 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. + /// 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 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 bc10116b14e..9d58ea07862 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, LSPS1PaymentState, 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( @@ -496,6 +497,45 @@ where } } + /// Used by LSP to inform a client that an order was rejected because they require onchain + /// payments and the client didn't provide 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, @@ -640,7 +680,6 @@ 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( @@ -782,6 +821,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 provide 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 b272235708bd9b280dd47c8914a784cde77572f0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Feb 2026 13:09:43 +0100 Subject: [PATCH 32/35] 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 | 30 +++++- lightning-liquidity/src/lsps1/service.rs | 53 ++++++++--- .../tests/lsps1_integration_tests.rs | 91 +++++++++++++++++++ 3 files changed, 158 insertions(+), 16 deletions(-) diff --git a/lightning-liquidity/src/lsps1/peer_state.rs b/lightning-liquidity/src/lsps1/peer_state.rs index d2b806c6dbd..6e1889749ae 100644 --- a/lightning-liquidity/src/lsps1/peer_state.rs +++ b/lightning-liquidity/src/lsps1/peer_state.rs @@ -22,6 +22,8 @@ use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use core::fmt; +const MAX_PENDING_REQUESTS_PER_PEER: usize = 10; + /// Indicates which payment method was used for the order. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PaymentMethod { @@ -340,6 +342,9 @@ impl PeerState { pub(super) fn register_request( &mut self, request_id: LSPSRequestId, request: LSPS1Request, ) -> Result<(), PeerStateError> { + if self.pending_requests_and_unpaid_orders() >= MAX_PENDING_REQUESTS_PER_PEER { + return Err(PeerStateError::TooManyPendingRequests); + } if self.pending_requests.contains_key(&request_id) { return Err(PeerStateError::DuplicateRequestId); } @@ -376,8 +381,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) { @@ -389,6 +396,23 @@ impl PeerState { true }); } + + fn pending_requests_and_unpaid_orders(&self) -> usize { + let pending_requests = self.pending_requests.len(); + // We exclude paid and completed orders. + let unpaid_orders = self + .outbound_channels_by_order_id + .iter() + .filter(|(_, v)| { + !matches!( + v.state, + ChannelOrderState::OrderPaid { .. } + | ChannelOrderState::CompletedAndChannelOpened { .. } + ) + }) + .count(); + pending_requests + unpaid_orders + } } impl_writeable_tlv_based!(PeerState, { @@ -403,6 +427,7 @@ pub(super) enum PeerStateError { DuplicateRequestId, UnknownOrderId, InvalidStateTransition(ChannelOrderStateError), + TooManyPendingRequests, } impl fmt::Display for PeerStateError { @@ -412,6 +437,7 @@ impl fmt::Display for PeerStateError { Self::DuplicateRequestId => write!(f, "duplicate request id"), Self::UnknownOrderId => write!(f, "unknown order id"), Self::InvalidStateTransition(e) => write!(f, "{}", e), + Self::TooManyPendingRequests => write!(f, "too many pending requests"), } } } diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 9d58ea07862..7cf0412f14e 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,8 @@ pub struct LSPS1ServiceConfig { pub supported_options: LSPS1Options, } +const MAX_TOTAL_PEERS: usize = 100000; + /// The main object allowing to send and receive bLIP-51 / LSPS1 messages. pub struct LSPS1ServiceHandler< ES: EntropySource, @@ -308,11 +311,30 @@ where { let mut outer_state_lock = self.per_peer_state.write().unwrap(); + let num_peers = outer_state_lock.len(); - 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 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." + .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), + }); + } + + let mut peer_state_lock = + inner_state_entry.or_insert(Mutex::new(PeerState::default())).lock().unwrap(); let request = LSPS1Request::CreateOrder(params.clone()); peer_state_lock.register_request(request_id.clone(), request).map_err(|e| { @@ -734,16 +756,19 @@ 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) + }, + }; + res }, _ => { debug_assert!( diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index 63185669bbf..a177b338ad7 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -34,6 +34,8 @@ 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; + fn build_lsps1_configs( supported_options: LSPS1Options, ) -> (LiquidityServiceConfig, LiquidityClientConfig) { @@ -1139,3 +1141,92 @@ 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"); + } +} From 15dbb21790fb9f38a345d7ec50a481ddd483baf0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Feb 2026 13:48:19 +0100 Subject: [PATCH 33/35] 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 7cf0412f14e..e776ae262d1 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -339,6 +339,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 } })?; From 4bec6db53b5be5ee2e3218eb1d380a1aaf7c0dec Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 17 Mar 2026 12:24:10 +0100 Subject: [PATCH 34/35] Validate all common fields in LSPS1 `is_valid` order check Add missing cross-validation of `LSPS1OrderParams` against `LSPS1Options` as required by bLIP-51: - Check `required_channel_confirmations` >= `min_required_channel_confirmations` - Check `funding_confirms_within_blocks` >= `min_funding_confirms_within_blocks` - Check total channel balance (`lsp_balance_sat` + `client_balance_sat`) is within [`min_channel_balance_sat`, `max_channel_balance_sat`], using `checked_add` to guard against overflow Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps1/service.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index e776ae262d1..0ac24203353 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -929,6 +929,11 @@ fn check_range(min: u64, max: u64, value: u64) -> bool { } fn is_valid(order: &LSPS1OrderParams, options: &LSPS1Options) -> bool { + let channel_balance_sat = match order.lsp_balance_sat.checked_add(order.client_balance_sat) { + Some(sum) => sum, + None => return false, + }; + check_range( options.min_initial_client_balance_sat, options.max_initial_client_balance_sat, @@ -941,5 +946,10 @@ fn is_valid(order: &LSPS1OrderParams, options: &LSPS1Options) -> bool { 1, options.max_channel_expiry_blocks.into(), order.channel_expiry_blocks.into(), - ) + ) && check_range( + options.min_channel_balance_sat, + options.max_channel_balance_sat, + channel_balance_sat, + ) && order.required_channel_confirmations >= options.min_required_channel_confirmations + && order.funding_confirms_within_blocks >= options.min_funding_confirms_within_blocks } From 47e5c04f64492ade647652463dd6e3435f1aa815 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 17 Mar 2026 14:31:45 +0100 Subject: [PATCH 35/35] Reset `persistence_in_flight` counter on error in LSPS1/LSPS2 Previously, if any `.await?` in the persist loop returned an error, the `?` would propagate out of `persist()` before reaching the `fetch_sub` at the end of the loop. This left the counter permanently > 0, causing all subsequent `persist()` calls to early-return and effectively disabling persistence for the lifetime of the handler. Fix this by extracting the loop into `do_persist()` and unconditionally resetting the counter via `store(0, Release)` in the outer `persist()` after `do_persist()` returns, regardless of success or failure. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps1/service.rs | 12 ++++++++++-- lightning-liquidity/src/lsps2/service.rs | 12 ++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 0ac24203353..0e139907589 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -145,14 +145,22 @@ where // 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); + return Ok(false); } + let res = self.do_persist().await; + debug_assert!(res.is_err() || self.persistence_in_flight.load(Ordering::Acquire) == 0); + self.persistence_in_flight.store(0, Ordering::Release); + res + } + + async fn do_persist(&self) -> Result { + let mut did_persist = false; + loop { let mut need_remove = Vec::new(); let mut need_persist = Vec::new(); diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 665cda1df89..b7f6f2fc64d 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -1786,14 +1786,22 @@ where // 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); + return Ok(false); } + let res = self.do_persist().await; + debug_assert!(res.is_err() || self.persistence_in_flight.load(Ordering::Acquire) == 0); + self.persistence_in_flight.store(0, Ordering::Release); + res + } + + async fn do_persist(&self) -> Result { + let mut did_persist = false; + loop { let mut need_remove = Vec::new(); let mut need_persist = Vec::new();