From 000c0a3b20cbbc3e307edc780e7bd0f702e6ecc7 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 7 Apr 2025 13:01:30 -0300 Subject: [PATCH 1/2] 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 bf68edac3310fb087e64867b93dae2b91a70a9a7 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 7 Apr 2025 13:02:25 -0300 Subject: [PATCH 2/2] 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), + } +}