From b0c35aebc6d203a1fdf2e0c0b43a180a7821bcb7 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 7 Apr 2025 13:01:30 -0300 Subject: [PATCH 1/3] add failed & abandoned HTLC open handlers Add two LSPS2Service methods: 'Abandoned' prunes all channel open state. 'Failed' resets JIT channel to fail HTLCs. It allows a retry on channel open. Closes #3479. --- lightning-liquidity/src/lsps2/service.rs | 128 ++++++++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 42a83891fc2..63c0515913e 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -32,10 +32,11 @@ use crate::prelude::{new_hash_map, HashMap}; use crate::sync::{Arc, Mutex, MutexGuard, RwLock}; use lightning::events::HTLCHandlingFailureType; -use lightning::ln::channelmanager::{AChannelManager, InterceptId}; +use lightning::ln::channelmanager::{AChannelManager, FailureCode, InterceptId}; use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::ln::types::ChannelId; use lightning::util::errors::APIError; +use lightning::util::hash_tables::HashSet; use lightning::util::logger::Level; use lightning_types::payment::PaymentHash; @@ -985,6 +986,131 @@ where Ok(()) } + /// Abandons a pending JIT‐open flow for `user_channel_id`, removing all local state. + /// + /// This removes the intercept SCID, any outbound channel state, and associated + /// channel‐ID mappings for the specified `user_channel_id`, but only while the JIT + /// channel is still in `PendingInitialPayment` or `PendingChannelOpen`. + /// + /// Returns an error if: + /// - there is no channel matching `user_channel_id`, or + /// - the channel has already advanced past `PendingChannelOpen` (e.g. to + /// `PendingPaymentForward` or beyond). + /// + /// Note: this does *not* close or roll back any on‐chain channel which may already + /// have been opened. The caller must only invoke this before a channel is actually + /// confirmed (or else provide its own on‐chain cleanup) and is responsible for + /// managing any pending channel open attempts separately. + pub fn channel_open_abandoned( + &self, counterparty_node_id: &PublicKey, user_channel_id: u128, + ) -> Result<(), APIError> { + let outer_state_lock = self.per_peer_state.read().unwrap(); + let inner_state_lock = + outer_state_lock.get(counterparty_node_id).ok_or_else(|| APIError::APIMisuseError { + err: format!("No counterparty state for: {}", counterparty_node_id), + })?; + let mut peer_state = inner_state_lock.lock().unwrap(); + + let intercept_scid = peer_state + .intercept_scid_by_user_channel_id + .remove(&user_channel_id) + .ok_or_else(|| APIError::APIMisuseError { + err: format!("Could not find a channel with user_channel_id {}", user_channel_id), + })?; + + if let Some(jit_channel) = + peer_state.outbound_channels_by_intercept_scid.get(&intercept_scid) + { + if !matches!( + jit_channel.state, + OutboundJITChannelState::PendingInitialPayment { .. } + | OutboundJITChannelState::PendingChannelOpen { .. } + ) { + return Err(APIError::APIMisuseError { + err: "Cannot abandon channel open after channel creation or payment forwarding" + .to_string(), + }); + } + } + + peer_state.outbound_channels_by_intercept_scid.remove(&intercept_scid); + + peer_state.intercept_scid_by_channel_id.retain(|_, &mut scid| scid != intercept_scid); + + Ok(()) + } + + /// Used to fail intercepted HTLCs backwards when a channel open attempt ultimately fails. + /// + /// This function should be called after receiving an [`LSPS2ServiceEvent::OpenChannel`] event + /// but only if the channel could not be successfully established. It resets the JIT channel + /// state so that the payer may try the payment again. + /// + /// [`LSPS2ServiceEvent::OpenChannel`]: crate::lsps2::event::LSPS2ServiceEvent::OpenChannel + pub fn channel_open_failed( + &self, counterparty_node_id: &PublicKey, user_channel_id: u128, + ) -> Result<(), APIError> { + let outer_state_lock = self.per_peer_state.read().unwrap(); + + let inner_state_lock = + outer_state_lock.get(counterparty_node_id).ok_or_else(|| APIError::APIMisuseError { + err: format!("No counterparty state for: {}", counterparty_node_id), + })?; + + let mut peer_state = inner_state_lock.lock().unwrap(); + + let intercept_scid = peer_state + .intercept_scid_by_user_channel_id + .get(&user_channel_id) + .copied() + .ok_or_else(|| APIError::APIMisuseError { + err: format!("Could not find a channel with user_channel_id {}", user_channel_id), + })?; + + let jit_channel = peer_state + .outbound_channels_by_intercept_scid + .get_mut(&intercept_scid) + .ok_or_else(|| APIError::APIMisuseError { + err: format!( + "Failed to map intercept_scid {} for user_channel_id {} to a channel.", + intercept_scid, user_channel_id, + ), + })?; + + if !matches!(jit_channel.state, OutboundJITChannelState::PendingChannelOpen { .. }) { + return Err(APIError::APIMisuseError { + err: "Channel is not in the PendingChannelOpen state.".to_string(), + }); + } + + let mut payment_queue = match &jit_channel.state { + OutboundJITChannelState::PendingChannelOpen { payment_queue, .. } => { + payment_queue.clone() + }, + _ => { + return Err(APIError::APIMisuseError { + err: "Channel is not in the PendingChannelOpen state.".to_string(), + }); + }, + }; + let payment_hashes: Vec<_> = payment_queue + .clear() + .into_iter() + .map(|htlc| htlc.payment_hash) + .collect::>() + .into_iter() + .collect(); + for payment_hash in payment_hashes { + self.channel_manager + .get_cm() + .fail_htlc_backwards_with_reason(&payment_hash, FailureCode::TemporaryNodeFailure); + } + + jit_channel.state = OutboundJITChannelState::PendingInitialPayment { payment_queue }; + + Ok(()) + } + /// Forward [`Event::ChannelReady`] event parameters into this function. /// /// Will forward the intercepted HTLC if it matches a channel From de5fdcc889e52b9b0d9df912b0bb5ed1321d223e Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 12 May 2025 13:56:49 -0300 Subject: [PATCH 2/3] fixup: Clean up LSPS2 service code - Update docs to focus on behavior not internal states - Fix channel_open_abandoned to check before removing SCID - Use mem::take instead of clone in channel_open_failed - Remove unnecessary HashSet when failing HTLCs - Improve error messages --- lightning-liquidity/src/lsps2/service.rs | 91 ++++++++++++------------ 1 file changed, 44 insertions(+), 47 deletions(-) diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 63c0515913e..38564a082cf 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -36,7 +36,6 @@ use lightning::ln::channelmanager::{AChannelManager, FailureCode, InterceptId}; use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::ln::types::ChannelId; use lightning::util::errors::APIError; -use lightning::util::hash_tables::HashSet; use lightning::util::logger::Level; use lightning_types::payment::PaymentHash; @@ -989,18 +988,18 @@ where /// Abandons a pending JIT‐open flow for `user_channel_id`, removing all local state. /// /// This removes the intercept SCID, any outbound channel state, and associated - /// channel‐ID mappings for the specified `user_channel_id`, but only while the JIT - /// channel is still in `PendingInitialPayment` or `PendingChannelOpen`. + /// channel‐ID mappings for the specified `user_channel_id`, but only while no payment + /// has been forwarded yet and no channel has been opened on-chain. /// /// Returns an error if: /// - there is no channel matching `user_channel_id`, or - /// - the channel has already advanced past `PendingChannelOpen` (e.g. to - /// `PendingPaymentForward` or beyond). + /// - a payment has already been forwarded or a channel has already been opened /// /// Note: this does *not* close or roll back any on‐chain channel which may already - /// have been opened. The caller must only invoke this before a channel is actually - /// confirmed (or else provide its own on‐chain cleanup) and is responsible for - /// managing any pending channel open attempts separately. + /// have been opened. The caller must call this before or instead of initiating the channel + /// open, as it only affects the local LSPS2 state and doesn't affect any channels that + /// might already exist on-chain. Any pending channel open attempts must be managed + /// separately. pub fn channel_open_abandoned( &self, counterparty_node_id: &PublicKey, user_channel_id: u128, ) -> Result<(), APIError> { @@ -1013,28 +1012,37 @@ where let intercept_scid = peer_state .intercept_scid_by_user_channel_id - .remove(&user_channel_id) + .get(&user_channel_id) + .copied() .ok_or_else(|| APIError::APIMisuseError { err: format!("Could not find a channel with user_channel_id {}", user_channel_id), })?; - if let Some(jit_channel) = - peer_state.outbound_channels_by_intercept_scid.get(&intercept_scid) - { - if !matches!( - jit_channel.state, - OutboundJITChannelState::PendingInitialPayment { .. } - | OutboundJITChannelState::PendingChannelOpen { .. } - ) { - return Err(APIError::APIMisuseError { - err: "Cannot abandon channel open after channel creation or payment forwarding" - .to_string(), - }); - } + let jit_channel = peer_state + .outbound_channels_by_intercept_scid + .get(&intercept_scid) + .ok_or_else(|| APIError::APIMisuseError { + err: format!( + "Failed to map intercept_scid {} for user_channel_id {} to a channel.", + intercept_scid, user_channel_id, + ), + })?; + + let is_pending = matches!( + jit_channel.state, + OutboundJITChannelState::PendingInitialPayment { .. } + | OutboundJITChannelState::PendingChannelOpen { .. } + ); + + if !is_pending { + return Err(APIError::APIMisuseError { + err: "Cannot abandon channel open after channel creation or payment forwarding" + .to_string(), + }); } + peer_state.intercept_scid_by_user_channel_id.remove(&user_channel_id); peer_state.outbound_channels_by_intercept_scid.remove(&intercept_scid); - peer_state.intercept_scid_by_channel_id.retain(|_, &mut scid| scid != intercept_scid); Ok(()) @@ -1077,33 +1085,22 @@ where ), })?; - if !matches!(jit_channel.state, OutboundJITChannelState::PendingChannelOpen { .. }) { - return Err(APIError::APIMisuseError { - err: "Channel is not in the PendingChannelOpen state.".to_string(), - }); - } - - let mut payment_queue = match &jit_channel.state { - OutboundJITChannelState::PendingChannelOpen { payment_queue, .. } => { - payment_queue.clone() - }, - _ => { + let mut payment_queue = + if let OutboundJITChannelState::PendingChannelOpen { payment_queue, .. } = + &mut jit_channel.state + { + core::mem::take(payment_queue) + } else { return Err(APIError::APIMisuseError { err: "Channel is not in the PendingChannelOpen state.".to_string(), }); - }, - }; - let payment_hashes: Vec<_> = payment_queue - .clear() - .into_iter() - .map(|htlc| htlc.payment_hash) - .collect::>() - .into_iter() - .collect(); - for payment_hash in payment_hashes { - self.channel_manager - .get_cm() - .fail_htlc_backwards_with_reason(&payment_hash, FailureCode::TemporaryNodeFailure); + }; + + for htlc in payment_queue.clear() { + self.channel_manager.get_cm().fail_htlc_backwards_with_reason( + &htlc.payment_hash, + FailureCode::TemporaryNodeFailure, + ); } jit_channel.state = OutboundJITChannelState::PendingInitialPayment { payment_queue }; From f6aebb90637e14de1793c203c3b601f9614d74c3 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 7 Apr 2025 13:02:25 -0300 Subject: [PATCH 3/3] test: add int tests for failed & abandoned open Add integration tests to verify channel open reset and pruning handlers. Tests cover: - channel_open_failed resetting state to allow retry. - channel_open_failed error on invalid state. - channel_open_abandoned pruning all open state. - error handling for nonexistent channels. --- lightning-liquidity/tests/common/mod.rs | 55 ++- .../tests/lsps2_integration_tests.rs | 365 ++++++++++++++++-- 2 files changed, 391 insertions(+), 29 deletions(-) diff --git a/lightning-liquidity/tests/common/mod.rs b/lightning-liquidity/tests/common/mod.rs index 2259d1eae06..c06b06473c0 100644 --- a/lightning-liquidity/tests/common/mod.rs +++ b/lightning-liquidity/tests/common/mod.rs @@ -9,6 +9,7 @@ use lightning::sign::EntropySource; use bitcoin::blockdata::constants::{genesis_block, ChainHash}; use bitcoin::blockdata::transaction::Transaction; +use bitcoin::secp256k1::SecretKey; use bitcoin::Network; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; use lightning::chain::{chainmonitor, BestBlock, Confirm}; @@ -34,6 +35,8 @@ use lightning::util::persist::{ SCORER_PERSISTENCE_SECONDARY_NAMESPACE, }; use lightning::util::test_utils; +use lightning_liquidity::lsps2::client::{LSPS2ClientConfig, LSPS2ClientHandler}; +use lightning_liquidity::lsps2::service::{LSPS2ServiceConfig, LSPS2ServiceHandler}; use lightning_liquidity::{LiquidityClientConfig, LiquidityManager, LiquidityServiceConfig}; use lightning_persister::fs_store::FilesystemStore; @@ -487,7 +490,7 @@ pub(crate) fn create_liquidity_node( } } -pub(crate) fn create_service_and_client_nodes( +fn create_service_and_client_nodes( persist_dir: &str, service_config: LiquidityServiceConfig, client_config: LiquidityClientConfig, ) -> (Node, Node) { let persist_temp_path = env::temp_dir().join(persist_dir); @@ -671,3 +674,53 @@ fn advance_chain(node: &mut Node, num_blocks: u32) { } } } + +pub(crate) fn setup_test_lsps2() -> ( + &'static LSPS2ClientHandler>, + &'static LSPS2ServiceHandler>, + bitcoin::secp256k1::PublicKey, + bitcoin::secp256k1::PublicKey, + &'static Node, + &'static Node, + [u8; 32], +) { + let promise_secret = [42; 32]; + let signing_key = SecretKey::from_slice(&promise_secret).unwrap(); + 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), + advertise_service: true, + }; + + let lsps2_client_config = LSPS2ClientConfig::default(); + let client_config = LiquidityClientConfig { + lsps1_client_config: None, + lsps2_client_config: Some(lsps2_client_config), + }; + + let (service_node, client_node) = + create_service_and_client_nodes("webhook_registration_flow", service_config, client_config); + + // Leak the nodes to extend their lifetime to 'static since this is test code + let service_node = Box::leak(Box::new(service_node)); + let client_node = Box::leak(Box::new(client_node)); + + let client_handler = client_node.liquidity_manager.lsps2_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps2_service_handler().unwrap(); + + let secp = bitcoin::secp256k1::Secp256k1::new(); + let service_node_id = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &signing_key); + let client_node_id = client_node.channel_manager.get_our_node_id(); + + ( + client_handler, + service_handler, + service_node_id, + client_node_id, + service_node, + client_node, + promise_secret, + ) +} diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 5a3f88dacac..802b6157bb5 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -2,25 +2,25 @@ mod common; -use common::{create_service_and_client_nodes, get_lsps_message, Node}; +use common::{get_lsps_message, setup_test_lsps2, Node}; use lightning_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps0::ser::LSPSDateTime; -use lightning_liquidity::lsps2::client::LSPS2ClientConfig; use lightning_liquidity::lsps2::event::{LSPS2ClientEvent, LSPS2ServiceEvent}; use lightning_liquidity::lsps2::msgs::LSPS2RawOpeningFeeParams; -use lightning_liquidity::lsps2::service::LSPS2ServiceConfig; use lightning_liquidity::lsps2::utils::is_valid_opening_fee_params; -use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; -use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; +use lightning::ln::channelmanager::{InterceptId, MIN_FINAL_CLTV_EXPIRY_DELTA}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning::log_error; use lightning::routing::router::{RouteHint, RouteHintHop}; +use lightning::util::errors::APIError; use lightning::util::logger::Logger; use lightning_invoice::{Bolt11Invoice, InvoiceBuilder, RoutingFees}; +use lightning_types::payment::PaymentHash; + use bitcoin::hashes::{sha256, Hash}; use bitcoin::secp256k1::{PublicKey, Secp256k1}; use bitcoin::Network; @@ -82,29 +82,15 @@ fn create_jit_invoice( #[test] fn invoice_generation_flow() { - 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), - advertise_service: true, - }; - - let lsps2_client_config = LSPS2ClientConfig::default(); - let client_config = LiquidityClientConfig { - lsps1_client_config: None, - lsps2_client_config: Some(lsps2_client_config), - }; - - let (service_node, client_node) = - create_service_and_client_nodes("invoice_generation_flow", service_config, client_config); - - let service_handler = service_node.liquidity_manager.lsps2_service_handler().unwrap(); - let service_node_id = service_node.channel_manager.get_our_node_id(); - - let client_handler = client_node.liquidity_manager.lsps2_client_handler().unwrap(); - let client_node_id = client_node.channel_manager.get_our_node_id(); + let ( + client_handler, + service_handler, + service_node_id, + client_node_id, + service_node, + client_node, + promise_secret, + ) = setup_test_lsps2(); let get_info_request_id = client_handler.request_opening_params(service_node_id, None); let get_info_request = get_lsps_message!(client_node, service_node_id); @@ -239,3 +225,326 @@ fn invoice_generation_flow() { ) .unwrap(); } + +#[test] +fn channel_open_failed() { + let (_, service_handler, service_node_id, client_node_id, service_node, client_node, _) = + setup_test_lsps2(); + + let get_info_request_id = client_node + .liquidity_manager + .lsps2_client_handler() + .unwrap() + .request_opening_params(service_node_id, None); + let get_info_request = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(get_info_request, client_node_id).unwrap(); + + let _get_info_event = service_node.liquidity_manager.next_event().unwrap(); + + let raw_opening_params = LSPS2RawOpeningFeeParams { + min_fee_msat: 100, + proportional: 21, + valid_until: LSPSDateTime::from_str("2035-05-20T08:30:45Z").unwrap(), + min_lifetime: 144, + max_client_to_self_delay: 128, + min_payment_size_msat: 1, + max_payment_size_msat: 100_000_000, + }; + service_handler + .opening_fee_params_generated( + &client_node_id, + get_info_request_id.clone(), + vec![raw_opening_params], + ) + .unwrap(); + + let get_info_response = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(get_info_response, service_node_id) + .unwrap(); + + let opening_fee_params = match client_node.liquidity_manager.next_event().unwrap() { + LiquidityEvent::LSPS2Client(LSPS2ClientEvent::OpeningParametersReady { + opening_fee_params_menu, + .. + }) => opening_fee_params_menu.first().unwrap().clone(), + _ => panic!("Unexpected event"), + }; + + let payment_size_msat = Some(1_000_000); + let buy_request_id = client_node + .liquidity_manager + .lsps2_client_handler() + .unwrap() + .select_opening_params(service_node_id, payment_size_msat, opening_fee_params.clone()) + .unwrap(); + let buy_request = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(buy_request, client_node_id).unwrap(); + + let _buy_event = service_node.liquidity_manager.next_event().unwrap(); + let user_channel_id = 42; + let cltv_expiry_delta = 144; + let intercept_scid = service_node.channel_manager.get_intercept_scid(); + let client_trusts_lsp = true; + + service_handler + .invoice_parameters_generated( + &client_node_id, + buy_request_id.clone(), + intercept_scid, + cltv_expiry_delta, + client_trusts_lsp, + user_channel_id, + ) + .unwrap(); + + let buy_response = get_lsps_message!(service_node, client_node_id); + client_node.liquidity_manager.handle_custom_message(buy_response, service_node_id).unwrap(); + let _invoice_params_event = client_node.liquidity_manager.next_event().unwrap(); + + let htlc_amount_msat = 1_000_000; + let intercept_id = InterceptId([0; 32]); + let payment_hash = PaymentHash([1; 32]); + + // This should trigger an OpenChannel event + service_handler + .htlc_intercepted(intercept_scid, intercept_id, htlc_amount_msat, payment_hash) + .unwrap(); + + let _ = match service_node.liquidity_manager.next_event().unwrap() { + LiquidityEvent::LSPS2Service(LSPS2ServiceEvent::OpenChannel { + user_channel_id: channel_id, + intercept_scid: scid, + .. + }) => { + assert_eq!(channel_id, user_channel_id); + assert_eq!(scid, intercept_scid); + true + }, + _ => panic!("Expected OpenChannel event"), + }; + + service_handler.channel_open_failed(&client_node_id, user_channel_id).unwrap(); + + // Verify we can restart the flow with another HTLC + let new_intercept_id = InterceptId([1; 32]); + service_handler + .htlc_intercepted(intercept_scid, new_intercept_id, htlc_amount_msat, payment_hash) + .unwrap(); + + // Should get another OpenChannel event which confirms the reset worked + let _ = match service_node.liquidity_manager.next_event().unwrap() { + LiquidityEvent::LSPS2Service(LSPS2ServiceEvent::OpenChannel { + user_channel_id: channel_id, + intercept_scid: scid, + .. + }) => { + assert_eq!(channel_id, user_channel_id); + assert_eq!(scid, intercept_scid); + true + }, + _ => panic!("Expected OpenChannel event after reset"), + }; +} + +#[test] +fn channel_open_failed_invalid_state() { + let (_, service_handler, service_node_id, client_node_id, service_node, client_node, _) = + setup_test_lsps2(); + + let get_info_request_id = client_node + .liquidity_manager + .lsps2_client_handler() + .unwrap() + .request_opening_params(service_node_id, None); + let get_info_request = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(get_info_request, client_node_id).unwrap(); + let _get_info_event = service_node.liquidity_manager.next_event().unwrap(); + + let raw_opening_params = LSPS2RawOpeningFeeParams { + min_fee_msat: 100, + proportional: 21, + valid_until: LSPSDateTime::from_str("2035-05-20T08:30:45Z").unwrap(), + min_lifetime: 144, + max_client_to_self_delay: 128, + min_payment_size_msat: 1, + max_payment_size_msat: 100_000_000, + }; + service_handler + .opening_fee_params_generated( + &client_node_id, + get_info_request_id.clone(), + vec![raw_opening_params], + ) + .unwrap(); + + let get_info_response = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(get_info_response, service_node_id) + .unwrap(); + + let opening_fee_params = match client_node.liquidity_manager.next_event().unwrap() { + LiquidityEvent::LSPS2Client(LSPS2ClientEvent::OpeningParametersReady { + opening_fee_params_menu, + .. + }) => opening_fee_params_menu.first().unwrap().clone(), + _ => panic!("Unexpected event"), + }; + + let payment_size_msat = Some(1_000_000); + let buy_request_id = client_node + .liquidity_manager + .lsps2_client_handler() + .unwrap() + .select_opening_params(service_node_id, payment_size_msat, opening_fee_params.clone()) + .unwrap(); + let buy_request = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(buy_request, client_node_id).unwrap(); + + let _buy_event = service_node.liquidity_manager.next_event().unwrap(); + let user_channel_id = 42; + let cltv_expiry_delta = 144; + let intercept_scid = service_node.channel_manager.get_intercept_scid(); + let client_trusts_lsp = true; + + service_handler + .invoice_parameters_generated( + &client_node_id, + buy_request_id.clone(), + intercept_scid, + cltv_expiry_delta, + client_trusts_lsp, + user_channel_id, + ) + .unwrap(); + + // We're purposely not intercepting an HTLC, so the state remains PendingInitialPayment + + // Try to call channel_open_failed, which should fail because the channel is not in PendingChannelOpen state + let result = service_handler.channel_open_failed(&client_node_id, user_channel_id); + + assert!(result.is_err()); + match result.unwrap_err() { + APIError::APIMisuseError { err } => { + assert!(err.contains("Channel is not in the PendingChannelOpen state.")); + }, + other => panic!("Unexpected error type: {:?}", other), + } +} + +#[test] +fn channel_open_failed_nonexistent_channel() { + let (_, service_handler, _, client_node_id, _, _, _) = setup_test_lsps2(); + + // Call channel_open_failed with a nonexistent user_channel_id + let nonexistent_user_channel_id = 999; + let result = service_handler.channel_open_failed(&client_node_id, nonexistent_user_channel_id); + + assert!(result.is_err()); + match result.unwrap_err() { + APIError::APIMisuseError { err } => { + assert!(err.contains("No counterparty state for")); + }, + other => panic!("Unexpected error type: {:?}", other), + } +} + +#[test] +fn channel_open_abandoned() { + let (_, service_handler, service_node_id, client_node_id, service_node, client_node, _) = + setup_test_lsps2(); + + // Set up a JIT channel + let get_info_request_id = client_node + .liquidity_manager + .lsps2_client_handler() + .unwrap() + .request_opening_params(service_node_id, None); + let get_info_request = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(get_info_request, client_node_id).unwrap(); + let _get_info_event = service_node.liquidity_manager.next_event().unwrap(); + + let raw_opening_params = LSPS2RawOpeningFeeParams { + min_fee_msat: 100, + proportional: 21, + valid_until: LSPSDateTime::from_str("2035-05-20T08:30:45Z").unwrap(), + min_lifetime: 144, + max_client_to_self_delay: 128, + min_payment_size_msat: 1, + max_payment_size_msat: 100_000_000, + }; + service_handler + .opening_fee_params_generated( + &client_node_id, + get_info_request_id.clone(), + vec![raw_opening_params], + ) + .unwrap(); + + let get_info_response = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(get_info_response, service_node_id) + .unwrap(); + + let opening_fee_params = match client_node.liquidity_manager.next_event().unwrap() { + LiquidityEvent::LSPS2Client(LSPS2ClientEvent::OpeningParametersReady { + opening_fee_params_menu, + .. + }) => opening_fee_params_menu.first().unwrap().clone(), + _ => panic!("Unexpected event"), + }; + + let payment_size_msat = Some(1_000_000); + let buy_request_id = client_node + .liquidity_manager + .lsps2_client_handler() + .unwrap() + .select_opening_params(service_node_id, payment_size_msat, opening_fee_params.clone()) + .unwrap(); + let buy_request = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(buy_request, client_node_id).unwrap(); + + let _buy_event = service_node.liquidity_manager.next_event().unwrap(); + let user_channel_id = 42; + let cltv_expiry_delta = 144; + let intercept_scid = service_node.channel_manager.get_intercept_scid(); + let client_trusts_lsp = true; + + service_handler + .invoice_parameters_generated( + &client_node_id, + buy_request_id.clone(), + intercept_scid, + cltv_expiry_delta, + client_trusts_lsp, + user_channel_id, + ) + .unwrap(); + + // Call channel_open_abandoned + service_handler.channel_open_abandoned(&client_node_id, user_channel_id).unwrap(); + + // Verify the channel is gone by trying to abandon it again, which should fail + let result = service_handler.channel_open_abandoned(&client_node_id, user_channel_id); + assert!(result.is_err()); +} + +#[test] +fn channel_open_abandoned_nonexistent_channel() { + let (_, service_handler, _, client_node_id, _, _, _) = setup_test_lsps2(); + + // Call channel_open_abandoned with a nonexistent user_channel_id + let nonexistent_user_channel_id = 999; + let result = + service_handler.channel_open_abandoned(&client_node_id, nonexistent_user_channel_id); + assert!(result.is_err()); + match result.unwrap_err() { + APIError::APIMisuseError { err } => { + assert!(err.contains("No counterparty state for")); + }, + other => panic!("Unexpected error type: {:?}", other), + } +}