diff --git a/lightning-liquidity/Cargo.toml b/lightning-liquidity/Cargo.toml index 9e76b0c7c68..e0119afa8a8 100644 --- a/lightning-liquidity/Cargo.toml +++ b/lightning-liquidity/Cargo.toml @@ -17,6 +17,7 @@ categories = ["cryptography::cryptocurrencies"] default = ["std"] std = ["lightning/std"] backtrace = ["dep:backtrace"] +lsps5 = ["minreq", "url"] [dependencies] lightning = { version = "0.0.124", path = "../lightning", default-features = false } @@ -30,6 +31,9 @@ serde = { version = "1.0", default-features = false, features = ["derive", "allo serde_json = "1.0" backtrace = { version = "0.3", optional = true } +minreq = { version = "2.11.1", optional = true, features = ["https", "json-using-serde"] } +url = { version = "2.5.0", optional = true, features = ["serde"] } + [dev-dependencies] lightning = { version = "0.0.124", path = "../lightning", default-features = false, features = ["_test_utils"] } lightning-invoice = { version = "0.32.0", path = "../lightning-invoice", default-features = false, features = ["serde", "std"] } diff --git a/lightning-liquidity/src/events.rs b/lightning-liquidity/src/events.rs index 3db772deec8..df357b4b7b6 100644 --- a/lightning-liquidity/src/events.rs +++ b/lightning-liquidity/src/events.rs @@ -18,6 +18,8 @@ use crate::lsps0; use crate::lsps1; use crate::lsps2; +#[cfg(feature = "lsps5")] +use crate::lsps5; use crate::prelude::{Vec, VecDeque}; use crate::sync::{Arc, Mutex}; @@ -114,6 +116,12 @@ pub enum Event { LSPS2Client(lsps2::event::LSPS2ClientEvent), /// An LSPS2 (JIT Channel) server event. LSPS2Service(lsps2::event::LSPS2ServiceEvent), + /// An LSPS5 (Webhook Notifications) client event. + #[cfg(feature = "lsps5")] + LSPS5Client(lsps5::event::LSPS5ClientEvent), + /// An LSPS5 (Webhook Notifications) server event. + #[cfg(feature = "lsps5")] + LSPS5Service(lsps5::event::LSPS5ServiceEvent), } struct EventFuture { diff --git a/lightning-liquidity/src/lib.rs b/lightning-liquidity/src/lib.rs index 520c2009811..bac36567d47 100644 --- a/lightning-liquidity/src/lib.rs +++ b/lightning-liquidity/src/lib.rs @@ -64,6 +64,8 @@ pub mod events; pub mod lsps0; pub mod lsps1; pub mod lsps2; +#[cfg(feature = "lsps5")] +pub mod lsps5; mod manager; pub mod message_queue; #[allow(dead_code)] diff --git a/lightning-liquidity/src/lsps0/msgs.rs b/lightning-liquidity/src/lsps0/msgs.rs index 631cc9206c5..d8bb98dc2df 100644 --- a/lightning-liquidity/src/lsps0/msgs.rs +++ b/lightning-liquidity/src/lsps0/msgs.rs @@ -78,6 +78,8 @@ impl TryFrom for LSPS0Message { LSPSMessage::LSPS0(message) => Ok(message), LSPSMessage::LSPS1(_) => Err(()), LSPSMessage::LSPS2(_) => Err(()), + #[cfg(feature = "lsps5")] + LSPSMessage::LSPS5(_) => Err(()), } } } diff --git a/lightning-liquidity/src/lsps0/ser.rs b/lightning-liquidity/src/lsps0/ser.rs index afac232966a..be47b6465fe 100644 --- a/lightning-liquidity/src/lsps0/ser.rs +++ b/lightning-liquidity/src/lsps0/ser.rs @@ -14,6 +14,18 @@ use crate::lsps1::msgs::{ use crate::lsps2::msgs::{ LSPS2Message, LSPS2Request, LSPS2Response, LSPS2_BUY_METHOD_NAME, LSPS2_GET_INFO_METHOD_NAME, }; +#[cfg(feature = "lsps5")] +use crate::lsps5::msgs::{ + LSPS5Message, LSPS5Request, LSPS5Response, LSPS5_LIST_WEBHOOKS_METHOD_NAME, + LSPS5_REMOVE_WEBHOOK_METHOD_NAME, LSPS5_SET_WEBHOOK_METHOD_NAME, +}; +#[cfg(feature = "lsps5")] +use crate::lsps5::notifications::{ + LSPS5Notification, LSPS5_EXPIRY_SOON_METHOD_NAME, LSPS5_FEES_CHANGE_INCOMING_METHOD_NAME, + LSPS5_LIQUIDITY_MANAGEMENT_REQUEST_METHOD_NAME, LSPS5_ONION_MESSAGE_INCOMING_METHOD_NAME, + LSPS5_PAYMENT_INCOMING_METHOD_NAME, LSPS5_WEBHOOK_REGISTERED_METHOD_NAME, +}; + use crate::prelude::{HashMap, String}; use lightning::ln::msgs::LightningError; @@ -53,6 +65,24 @@ pub(crate) enum LSPSMethod { LSPS1CreateOrder, LSPS2GetInfo, LSPS2Buy, + #[cfg(feature = "lsps5")] + LSPS5SetWebhook, + #[cfg(feature = "lsps5")] + LSPS5ListWebhooks, + #[cfg(feature = "lsps5")] + LSPS5RemoveWebhook, + #[cfg(feature = "lsps5")] + LSPS5WebhookRegistered, + #[cfg(feature = "lsps5")] + LSPS5PaymentIncoming, + #[cfg(feature = "lsps5")] + LSPS5ExpirySoon, + #[cfg(feature = "lsps5")] + LSPS5LiquidityManagementRequest, + #[cfg(feature = "lsps5")] + LSPS5FeesChangeIncoming, + #[cfg(feature = "lsps5")] + LSPS5OnionMessageIncoming, } impl LSPSMethod { @@ -64,6 +94,24 @@ impl LSPSMethod { Self::LSPS1GetOrder => LSPS1_GET_ORDER_METHOD_NAME, Self::LSPS2GetInfo => LSPS2_GET_INFO_METHOD_NAME, Self::LSPS2Buy => LSPS2_BUY_METHOD_NAME, + #[cfg(feature = "lsps5")] + Self::LSPS5SetWebhook => LSPS5_SET_WEBHOOK_METHOD_NAME, + #[cfg(feature = "lsps5")] + Self::LSPS5ListWebhooks => LSPS5_LIST_WEBHOOKS_METHOD_NAME, + #[cfg(feature = "lsps5")] + Self::LSPS5RemoveWebhook => LSPS5_REMOVE_WEBHOOK_METHOD_NAME, + #[cfg(feature = "lsps5")] + Self::LSPS5WebhookRegistered => LSPS5_WEBHOOK_REGISTERED_METHOD_NAME, + #[cfg(feature = "lsps5")] + Self::LSPS5PaymentIncoming => LSPS5_PAYMENT_INCOMING_METHOD_NAME, + #[cfg(feature = "lsps5")] + Self::LSPS5ExpirySoon => LSPS5_EXPIRY_SOON_METHOD_NAME, + #[cfg(feature = "lsps5")] + Self::LSPS5LiquidityManagementRequest => LSPS5_LIQUIDITY_MANAGEMENT_REQUEST_METHOD_NAME, + #[cfg(feature = "lsps5")] + Self::LSPS5FeesChangeIncoming => LSPS5_FEES_CHANGE_INCOMING_METHOD_NAME, + #[cfg(feature = "lsps5")] + Self::LSPS5OnionMessageIncoming => LSPS5_ONION_MESSAGE_INCOMING_METHOD_NAME, } } } @@ -78,6 +126,24 @@ impl FromStr for LSPSMethod { LSPS1_GET_ORDER_METHOD_NAME => Ok(Self::LSPS1GetOrder), LSPS2_GET_INFO_METHOD_NAME => Ok(Self::LSPS2GetInfo), LSPS2_BUY_METHOD_NAME => Ok(Self::LSPS2Buy), + #[cfg(feature = "lsps5")] + LSPS5_SET_WEBHOOK_METHOD_NAME => Ok(Self::LSPS5SetWebhook), + #[cfg(feature = "lsps5")] + LSPS5_LIST_WEBHOOKS_METHOD_NAME => Ok(Self::LSPS5ListWebhooks), + #[cfg(feature = "lsps5")] + LSPS5_REMOVE_WEBHOOK_METHOD_NAME => Ok(Self::LSPS5RemoveWebhook), + #[cfg(feature = "lsps5")] + LSPS5_WEBHOOK_REGISTERED_METHOD_NAME => Ok(Self::LSPS5WebhookRegistered), + #[cfg(feature = "lsps5")] + LSPS5_PAYMENT_INCOMING_METHOD_NAME => Ok(Self::LSPS5PaymentIncoming), + #[cfg(feature = "lsps5")] + LSPS5_EXPIRY_SOON_METHOD_NAME => Ok(Self::LSPS5ExpirySoon), + #[cfg(feature = "lsps5")] + LSPS5_LIQUIDITY_MANAGEMENT_REQUEST_METHOD_NAME => Ok(Self::LSPS5LiquidityManagementRequest), + #[cfg(feature = "lsps5")] + LSPS5_FEES_CHANGE_INCOMING_METHOD_NAME => Ok(Self::LSPS5FeesChangeIncoming), + #[cfg(feature = "lsps5")] + LSPS5_ONION_MESSAGE_INCOMING_METHOD_NAME => Ok(Self::LSPS5OnionMessageIncoming), _ => Err(&"Unknown method name"), } } @@ -110,6 +176,33 @@ impl From<&LSPS2Request> for LSPSMethod { } } +#[cfg(feature = "lsps5")] +impl From<&LSPS5Request> for LSPSMethod { + fn from(value: &LSPS5Request) -> Self { + match value { + LSPS5Request::SetWebhook(_) => Self::LSPS5SetWebhook, + LSPS5Request::ListWebhooks(_) => Self::LSPS5ListWebhooks, + LSPS5Request::RemoveWebhook(_) => Self::LSPS5RemoveWebhook, + } + } +} + +#[cfg(feature = "lsps5")] +impl From<&LSPS5Notification> for LSPSMethod { + fn from(value: &LSPS5Notification) -> Self { + match value { + LSPS5Notification::WebhookRegistered(_) => Self::LSPS5WebhookRegistered, + LSPS5Notification::PaymentIncoming(_) => Self::LSPS5PaymentIncoming, + LSPS5Notification::ExpirySoon(_) => Self::LSPS5ExpirySoon, + LSPS5Notification::LiquidityManagementRequest(_) => { + Self::LSPS5LiquidityManagementRequest + }, + LSPS5Notification::FeesChangeIncoming(_) => Self::LSPS5FeesChangeIncoming, + LSPS5Notification::OnionMessageIncoming(_) => Self::LSPS5OnionMessageIncoming, + } + } +} + impl<'de> Deserialize<'de> for LSPSMethod { fn deserialize(deserializer: D) -> Result where @@ -209,6 +302,9 @@ pub enum LSPSMessage { LSPS1(LSPS1Message), /// An LSPS2 message. LSPS2(LSPS2Message), + /// An LSPS5 message. + #[cfg(feature = "lsps5")] + LSPS5(LSPS5Message), } impl LSPSMessage { @@ -236,6 +332,10 @@ impl LSPSMessage { LSPSMessage::LSPS2(LSPS2Message::Request(request_id, request)) => { Some((RequestId(request_id.0.clone()), request.into())) }, + #[cfg(feature = "lsps5")] + LSPSMessage::LSPS5(LSPS5Message::Request(request_id, request)) => { + Some((RequestId(request_id.0.clone()), request.into())) + }, _ => None, } } @@ -348,6 +448,72 @@ impl Serialize for LSPSMessage { }, } }, + #[cfg(feature = "lsps5")] + LSPSMessage::LSPS5(LSPS5Message::Request(request_id, request)) => { + jsonrpc_object.serialize_field(JSONRPC_ID_FIELD_KEY, &request_id.0)?; + jsonrpc_object + .serialize_field(JSONRPC_METHOD_FIELD_KEY, &LSPSMethod::from(request))?; + + match request { + LSPS5Request::SetWebhook(params) => { + jsonrpc_object.serialize_field(JSONRPC_PARAMS_FIELD_KEY, params)? + }, + LSPS5Request::ListWebhooks(params) => { + jsonrpc_object.serialize_field(JSONRPC_PARAMS_FIELD_KEY, params)? + }, + LSPS5Request::RemoveWebhook(params) => { + jsonrpc_object.serialize_field(JSONRPC_PARAMS_FIELD_KEY, params)? + }, + } + }, + #[cfg(feature = "lsps5")] + LSPSMessage::LSPS5(LSPS5Message::Response(request_id, response)) => { + jsonrpc_object.serialize_field(JSONRPC_ID_FIELD_KEY, &request_id.0)?; + + match response { + LSPS5Response::SetWebhook(result) => { + jsonrpc_object.serialize_field(JSONRPC_RESULT_FIELD_KEY, result)? + }, + LSPS5Response::SetWebhookError(error) => { + jsonrpc_object.serialize_field(JSONRPC_ERROR_FIELD_KEY, error)? + }, + LSPS5Response::ListWebhooks(result) => { + jsonrpc_object.serialize_field(JSONRPC_RESULT_FIELD_KEY, result)? + }, + LSPS5Response::RemoveWebhook(result) => { + jsonrpc_object.serialize_field(JSONRPC_RESULT_FIELD_KEY, result)? + }, + LSPS5Response::RemoveWebhookError(error) => { + jsonrpc_object.serialize_field(JSONRPC_ERROR_FIELD_KEY, error)? + }, + } + }, + #[cfg(feature = "lsps5")] + LSPSMessage::LSPS5(LSPS5Message::Notification(notification)) => { + jsonrpc_object + .serialize_field(JSONRPC_METHOD_FIELD_KEY, &LSPSMethod::from(notification))?; + + match notification { + LSPS5Notification::WebhookRegistered(params) => { + jsonrpc_object.serialize_field(JSONRPC_PARAMS_FIELD_KEY, params)? + }, + LSPS5Notification::PaymentIncoming(params) => { + jsonrpc_object.serialize_field(JSONRPC_PARAMS_FIELD_KEY, params)? + }, + LSPS5Notification::ExpirySoon(params) => { + jsonrpc_object.serialize_field(JSONRPC_PARAMS_FIELD_KEY, params)? + }, + LSPS5Notification::LiquidityManagementRequest(params) => { + jsonrpc_object.serialize_field(JSONRPC_PARAMS_FIELD_KEY, params)? + }, + LSPS5Notification::FeesChangeIncoming(params) => { + jsonrpc_object.serialize_field(JSONRPC_PARAMS_FIELD_KEY, params)? + }, + LSPS5Notification::OnionMessageIncoming(params) => { + jsonrpc_object.serialize_field(JSONRPC_PARAMS_FIELD_KEY, params)? + }, + } + }, LSPSMessage::Invalid(error) => { jsonrpc_object.serialize_field(JSONRPC_ID_FIELD_KEY, &serde_json::Value::Null)?; jsonrpc_object.serialize_field(JSONRPC_ERROR_FIELD_KEY, &error)?; @@ -402,179 +568,319 @@ impl<'de, 'a> Visitor<'de> for LSPSMessageVisitor<'a> { } } - let id = match id { - Some(id) => id, - None => { - if let Some(method) = method { - return Err(de::Error::custom(format!( - "Received unknown notification: {}", - method.as_static_str() - ))); - } else { - if let Some(error) = error { - if error.code == JSONRPC_INVALID_MESSAGE_ERROR_CODE { - return Ok(LSPSMessage::Invalid(error)); - } - } - - return Err(de::Error::custom("Received unknown error message")); - } - }, - }; - - match method { - Some(method) => match method { - LSPSMethod::LSPS0ListProtocols => Ok(LSPSMessage::LSPS0(LSPS0Message::Request( - id, - LSPS0Request::ListProtocols(ListProtocolsRequest {}), - ))), - LSPSMethod::LSPS1GetInfo => { - let request = serde_json::from_value(params.unwrap_or(json!({}))) - .map_err(de::Error::custom)?; - Ok(LSPSMessage::LSPS1(LSPS1Message::Request( - id, - LSPS1Request::GetInfo(request), - ))) - }, - LSPSMethod::LSPS1CreateOrder => { - let request = serde_json::from_value(params.unwrap_or(json!({}))) - .map_err(de::Error::custom)?; - Ok(LSPSMessage::LSPS1(LSPS1Message::Request( - id, - LSPS1Request::CreateOrder(request), - ))) - }, - LSPSMethod::LSPS1GetOrder => { - let request = serde_json::from_value(params.unwrap_or(json!({}))) - .map_err(de::Error::custom)?; - Ok(LSPSMessage::LSPS1(LSPS1Message::Request( - id, - LSPS1Request::GetOrder(request), - ))) - }, - LSPSMethod::LSPS2GetInfo => { - let request = serde_json::from_value(params.unwrap_or(json!({}))) - .map_err(de::Error::custom)?; - Ok(LSPSMessage::LSPS2(LSPS2Message::Request( - id, - LSPS2Request::GetInfo(request), - ))) - }, - LSPSMethod::LSPS2Buy => { - let request = serde_json::from_value(params.unwrap_or(json!({}))) - .map_err(de::Error::custom)?; - Ok(LSPSMessage::LSPS2(LSPS2Message::Request(id, LSPS2Request::Buy(request)))) - }, - }, - None => match self.request_id_to_method_map.remove(&id) { + match id { + Some(id) => match method { Some(method) => match method { LSPSMethod::LSPS0ListProtocols => { - if let Some(error) = error { - Ok(LSPSMessage::LSPS0(LSPS0Message::Response( - id, - LSPS0Response::ListProtocolsError(error), - ))) - } else if let Some(result) = result { - let list_protocols_response = - serde_json::from_value(result).map_err(de::Error::custom)?; - Ok(LSPSMessage::LSPS0(LSPS0Message::Response( - id, - LSPS0Response::ListProtocols(list_protocols_response), - ))) - } else { - Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) - } + Ok(LSPSMessage::LSPS0(LSPS0Message::Request( + id, + LSPS0Request::ListProtocols(ListProtocolsRequest {}), + ))) }, LSPSMethod::LSPS1GetInfo => { - if let Some(error) = error { - Ok(LSPSMessage::LSPS1(LSPS1Message::Response( - id, - LSPS1Response::GetInfoError(error), - ))) - } else if let Some(result) = result { - let response = - serde_json::from_value(result).map_err(de::Error::custom)?; - Ok(LSPSMessage::LSPS1(LSPS1Message::Response( - id, - LSPS1Response::GetInfo(response), - ))) - } else { - Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) - } + let request = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS1(LSPS1Message::Request( + id, + LSPS1Request::GetInfo(request), + ))) }, LSPSMethod::LSPS1CreateOrder => { - if let Some(error) = error { - Ok(LSPSMessage::LSPS1(LSPS1Message::Response( - id, - LSPS1Response::CreateOrderError(error), - ))) - } else if let Some(result) = result { - let response = - serde_json::from_value(result).map_err(de::Error::custom)?; - Ok(LSPSMessage::LSPS1(LSPS1Message::Response( - id, - LSPS1Response::CreateOrder(response), - ))) - } else { - Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) - } + let request = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS1(LSPS1Message::Request( + id, + LSPS1Request::CreateOrder(request), + ))) }, LSPSMethod::LSPS1GetOrder => { - if let Some(error) = error { - Ok(LSPSMessage::LSPS1(LSPS1Message::Response( - id, - LSPS1Response::GetOrderError(error), - ))) - } else if let Some(result) = result { - let response = - serde_json::from_value(result).map_err(de::Error::custom)?; - Ok(LSPSMessage::LSPS1(LSPS1Message::Response( - id, - LSPS1Response::GetOrder(response), - ))) - } else { - Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) - } + let request = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS1(LSPS1Message::Request( + id, + LSPS1Request::GetOrder(request), + ))) }, LSPSMethod::LSPS2GetInfo => { - if let Some(error) = error { - Ok(LSPSMessage::LSPS2(LSPS2Message::Response( - id, - LSPS2Response::GetInfoError(error), - ))) - } else if let Some(result) = result { - let response = - serde_json::from_value(result).map_err(de::Error::custom)?; - Ok(LSPSMessage::LSPS2(LSPS2Message::Response( - id, - LSPS2Response::GetInfo(response), - ))) - } else { - Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) - } + let request = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS2(LSPS2Message::Request( + id, + LSPS2Request::GetInfo(request), + ))) }, LSPSMethod::LSPS2Buy => { - if let Some(error) = error { - Ok(LSPSMessage::LSPS2(LSPS2Message::Response( - id, - LSPS2Response::BuyError(error), + let request = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS2(LSPS2Message::Request( + id, + LSPS2Request::Buy(request), + ))) + }, + #[cfg(feature = "lsps5")] + LSPSMethod::LSPS5SetWebhook => { + let request = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Request( + id, + LSPS5Request::SetWebhook(request), + ))) + }, + #[cfg(feature = "lsps5")] + LSPSMethod::LSPS5ListWebhooks => { + let request = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Request( + id, + LSPS5Request::ListWebhooks(request), + ))) + }, + #[cfg(feature = "lsps5")] + LSPSMethod::LSPS5RemoveWebhook => { + let request = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Request( + id, + LSPS5Request::RemoveWebhook(request), + ))) + }, + #[cfg(feature = "lsps5")] + _ => Err(de::Error::custom("invalid method")), + }, + None => match self.request_id_to_method_map.remove(&id) { + Some(method) => match method { + LSPSMethod::LSPS0ListProtocols => { + if let Some(error) = error { + Ok(LSPSMessage::LSPS0(LSPS0Message::Response( + id, + LSPS0Response::ListProtocolsError(error), + ))) + } else if let Some(result) = result { + let list_protocols_response = + serde_json::from_value(result).map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS0(LSPS0Message::Response( + id, + LSPS0Response::ListProtocols(list_protocols_response), + ))) + } else { + Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) + } + }, + LSPSMethod::LSPS1GetInfo => { + if let Some(error) = error { + Ok(LSPSMessage::LSPS1(LSPS1Message::Response( + id, + LSPS1Response::GetInfoError(error), + ))) + } else if let Some(result) = result { + let response = + serde_json::from_value(result).map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS1(LSPS1Message::Response( + id, + LSPS1Response::GetInfo(response), + ))) + } else { + Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) + } + }, + LSPSMethod::LSPS1CreateOrder => { + if let Some(error) = error { + Ok(LSPSMessage::LSPS1(LSPS1Message::Response( + id, + LSPS1Response::CreateOrderError(error), + ))) + } else if let Some(result) = result { + let response = + serde_json::from_value(result).map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS1(LSPS1Message::Response( + id, + LSPS1Response::CreateOrder(response), + ))) + } else { + Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) + } + }, + LSPSMethod::LSPS1GetOrder => { + if let Some(error) = error { + Ok(LSPSMessage::LSPS1(LSPS1Message::Response( + id, + LSPS1Response::GetOrderError(error), + ))) + } else if let Some(result) = result { + let response = + serde_json::from_value(result).map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS1(LSPS1Message::Response( + id, + LSPS1Response::GetOrder(response), + ))) + } else { + Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) + } + }, + LSPSMethod::LSPS2GetInfo => { + if let Some(error) = error { + Ok(LSPSMessage::LSPS2(LSPS2Message::Response( + id, + LSPS2Response::GetInfoError(error), + ))) + } else if let Some(result) = result { + let response = + serde_json::from_value(result).map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS2(LSPS2Message::Response( + id, + LSPS2Response::GetInfo(response), + ))) + } else { + Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) + } + }, + LSPSMethod::LSPS2Buy => { + if let Some(error) = error { + Ok(LSPSMessage::LSPS2(LSPS2Message::Response( + id, + LSPS2Response::BuyError(error), + ))) + } else if let Some(result) = result { + let response = + serde_json::from_value(result).map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS2(LSPS2Message::Response( + id, + LSPS2Response::Buy(response), + ))) + } else { + Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) + } + }, + #[cfg(feature = "lsps5")] + LSPSMethod::LSPS5SetWebhook => { + if let Some(error) = error { + Ok(LSPSMessage::LSPS5(LSPS5Message::Response( + id, + LSPS5Response::SetWebhookError(error), + ))) + } else if let Some(result) = result { + let response = + serde_json::from_value(result).map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Response( + id, + LSPS5Response::SetWebhook(response), + ))) + } else { + Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) + } + }, + #[cfg(feature = "lsps5")] + LSPSMethod::LSPS5ListWebhooks => { + if let Some(result) = result { + let response = + serde_json::from_value(result).map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Response( + id, + LSPS5Response::ListWebhooks(response), + ))) + } else { + Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) + } + }, + #[cfg(feature = "lsps5")] + LSPSMethod::LSPS5RemoveWebhook => { + if let Some(error) = error { + Ok(LSPSMessage::LSPS5(LSPS5Message::Response( + id, + LSPS5Response::RemoveWebhookError(error), + ))) + } else if let Some(result) = result { + let response = + serde_json::from_value(result).map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Response( + id, + LSPS5Response::RemoveWebhook(response), + ))) + } else { + Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) + } + }, + #[cfg(feature = "lsps5")] + _ => { + // TODO: fix error message + Err(de::Error::custom( + "Received invalid JSON-RPC object: method not recognized", + )) + }, + }, + None => Err(de::Error::custom(format!( + "Received response for unknown request id: {}", + id.0 + ))), + }, + }, + None => { + if let Some(method) = method { + match method { + #[cfg(feature = "lsps5")] + LSPSMethod::LSPS5WebhookRegistered => { + let notification = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Notification( + LSPS5Notification::WebhookRegistered(notification), + ))) + }, + #[cfg(feature = "lsps5")] + LSPSMethod::LSPS5PaymentIncoming => { + let notification = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Notification( + LSPS5Notification::PaymentIncoming(notification), ))) - } else if let Some(result) = result { - let response = - serde_json::from_value(result).map_err(de::Error::custom)?; - Ok(LSPSMessage::LSPS2(LSPS2Message::Response( - id, - LSPS2Response::Buy(response), + }, + #[cfg(feature = "lsps5")] + LSPSMethod::LSPS5ExpirySoon => { + let notification = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Notification( + LSPS5Notification::ExpirySoon(notification), ))) - } else { - Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) + }, + #[cfg(feature = "lsps5")] + LSPSMethod::LSPS5LiquidityManagementRequest => { + let notification = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Notification( + LSPS5Notification::LiquidityManagementRequest(notification), + ))) + }, + #[cfg(feature = "lsps5")] + LSPSMethod::LSPS5FeesChangeIncoming => { + let notification = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Notification( + LSPS5Notification::FeesChangeIncoming(notification), + ))) + }, + #[cfg(feature = "lsps5")] + LSPSMethod::LSPS5OnionMessageIncoming => { + let notification = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Notification( + LSPS5Notification::OnionMessageIncoming(notification), + ))) + }, + _ => { + return Err(de::Error::custom(format!( + "Received unknown notification: {:?}", + method + ))); + }, + } + } else { + if let Some(error) = error { + if error.code == JSONRPC_INVALID_MESSAGE_ERROR_CODE { + return Ok(LSPSMessage::Invalid(error)); } - }, - }, - None => Err(de::Error::custom(format!( - "Received response for unknown request id: {}", - id.0 - ))), + } + + return Err(de::Error::custom("Received unknown error message")); + } }, } } diff --git a/lightning-liquidity/src/lsps5/client.rs b/lightning-liquidity/src/lsps5/client.rs new file mode 100644 index 00000000000..a516f02efdb --- /dev/null +++ b/lightning-liquidity/src/lsps5/client.rs @@ -0,0 +1,393 @@ +// 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 the main LSPS5 client object, [`LSPS5ClientHandler`]. + +use crate::events::{Event, EventQueue}; +use crate::lsps0::ser::{ProtocolMessageHandler, RequestId, ResponseError}; +use crate::message_queue::MessageQueue; +use crate::prelude::{new_hash_map, new_hash_set, HashMap, HashSet, String}; +use crate::sync::{Arc, Mutex, RwLock}; + +use lightning::ln::msgs::{ErrorAction, LightningError}; +use lightning::sign::EntropySource; +use lightning::util::logger::Level; + +use bitcoin::secp256k1::PublicKey; + +use core::default::Default; +use core::ops::Deref; + +use crate::lsps5::msgs::{ + LSPS5Message, LSPS5Request, LSPS5Response, ListWebhooksRequest, ListWebhooksResponse, + RemoveWebhookRequest, RemoveWebhookResponse, SetWebhookRequest, SetWebhookResponse, +}; + +use super::event::LSPS5ClientEvent; + +/// Client-side configuration options for webhook notifications. +#[derive(Clone, Debug, Copy)] +pub struct LSPS5ClientConfig {} + +impl Default for LSPS5ClientConfig { + fn default() -> Self { + Self {} + } +} + +struct PeerState { + pending_set_webhook_requests: HashSet, + pending_list_webhooks_requests: HashSet, + pending_remove_webhook_requests: HashSet, +} + +impl PeerState { + fn new() -> Self { + let pending_set_webhook_requests = new_hash_set(); + let pending_list_webhooks_requests = new_hash_set(); + let pending_remove_webhook_requests = new_hash_set(); + Self { + pending_set_webhook_requests, + pending_list_webhooks_requests, + pending_remove_webhook_requests, + } + } +} + +/// The main object allowing to send and receive LSPS5 messages. +pub struct LSPS5ClientHandler +where + ES::Target: EntropySource, +{ + entropy_source: ES, + pending_messages: Arc, + pending_events: Arc, + per_peer_state: RwLock>>, + _config: LSPS5ClientConfig, +} + +impl LSPS5ClientHandler +where + ES::Target: EntropySource, +{ + /// Constructs an `LSPS5ClientHandler`. + pub(crate) fn new( + entropy_source: ES, pending_messages: Arc, pending_events: Arc, + _config: LSPS5ClientConfig, + ) -> Self { + Self { + entropy_source, + pending_messages, + pending_events, + per_peer_state: RwLock::new(new_hash_map()), + _config, + } + } + + /// Register a webhook for an app with the LSP. + /// + /// The user will receive the LSP's response via an [`WebhookSet`] event. + /// + /// `counterparty_node_id` is the `node_id` of the LSP you would like to use. + /// + /// `app_name` is a `String` that identifies the app this webhook is for. + /// + /// Returns the used [`RequestId`], which will be returned via [`WebhookSet`]. + /// + /// [`WebhookSet`]: crate::lsps5::event::LSPS5ClientEvent::WebhookSet + pub fn set_webhook( + &self, counterparty_node_id: PublicKey, app_name: String, webhook: String, + ) -> RequestId { + let request_id = crate::utils::generate_request_id(&self.entropy_source); + + { + let mut outer_state_lock = self.per_peer_state.write().unwrap(); + let inner_state_lock = outer_state_lock + .entry(counterparty_node_id) + .or_insert(Mutex::new(PeerState::new())); + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + peer_state_lock.pending_set_webhook_requests.insert(request_id.clone()); + } + + let request = LSPS5Request::SetWebhook(SetWebhookRequest { app_name, webhook }); + let msg = LSPS5Message::Request(request_id.clone(), request).into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + + request_id + } + + fn handle_set_webhook_response( + &self, request_id: RequestId, counterparty_node_id: &PublicKey, result: SetWebhookResponse, + ) -> Result<(), LightningError> { + 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 = inner_state_lock.lock().unwrap(); + + if !peer_state.pending_set_webhook_requests.remove(&request_id) { + return Err(LightningError { + err: format!( + "Received set_webhook response for an unknown request: {:?}", + request_id + ), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + self.pending_events.enqueue(Event::LSPS5Client(LSPS5ClientEvent::WebhookSet { + request_id, + counterparty_node_id: *counterparty_node_id, + num_webhooks: result.num_webhooks, + max_webhooks: result.max_webhooks, + no_change: result.no_change, + })); + }, + None => { + return Err(LightningError { + err: format!( + "Received set_webhook response from unknown peer: {:?}", + counterparty_node_id + ), + action: ErrorAction::IgnoreAndLog(Level::Info), + }) + }, + } + + Ok(()) + } + + fn handle_set_webhook_error( + &self, request_id: RequestId, counterparty_node_id: &PublicKey, _error: ResponseError, + ) -> Result<(), LightningError> { + 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 = inner_state_lock.lock().unwrap(); + + if !peer_state.pending_set_webhook_requests.remove(&request_id) { + return Err(LightningError { + err: format!( + "Received set_webhook error for an unknown request: {:?}", + request_id + ), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + Ok(()) + }, + None => { + return Err(LightningError { err: format!("Received error response for a get_info request from an unknown counterparty ({:?})",counterparty_node_id), action: ErrorAction::IgnoreAndLog(Level::Info)}); + }, + } + } + + /// List all webhooks registered with the LSP. + /// + /// The user will receive the LSP's response via an [`ListWebhooks`] event. + /// + /// `counterparty_node_id` is the `node_id` of the LSP you would like to use. + /// /// + /// Returns the used [`RequestId`], which will be returned via [`ListWebhooks`]. + /// + /// [`ListWebhooks`]: crate::lsps5::event::LSPS5ClientEvent::ListWebhooks + pub fn list_webhooks(&self, counterparty_node_id: PublicKey) -> RequestId { + let request_id = crate::utils::generate_request_id(&self.entropy_source); + + { + let mut outer_state_lock = self.per_peer_state.write().unwrap(); + let inner_state_lock = outer_state_lock + .entry(counterparty_node_id) + .or_insert(Mutex::new(PeerState::new())); + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + peer_state_lock.pending_list_webhooks_requests.insert(request_id.clone()); + } + + let request = LSPS5Request::ListWebhooks(ListWebhooksRequest {}); + let msg = LSPS5Message::Request(request_id.clone(), request).into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + + request_id + } + + fn handle_list_webhooks_response( + &self, request_id: RequestId, counterparty_node_id: &PublicKey, + result: ListWebhooksResponse, + ) -> Result<(), LightningError> { + 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 = inner_state_lock.lock().unwrap(); + + if !peer_state.pending_list_webhooks_requests.remove(&request_id) { + return Err(LightningError { + err: format!( + "Received list_webhooks response for an unknown request: {:?}", + request_id + ), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + self.pending_events.enqueue(Event::LSPS5Client(LSPS5ClientEvent::ListWebhooks { + request_id, + counterparty_node_id: *counterparty_node_id, + app_names: result.app_names.clone(), + max_webhooks: result.max_webhooks, + })); + }, + None => { + return Err(LightningError { + err: format!( + "Received list_webhooks response from unknown peer: {:?}", + counterparty_node_id + ), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + }, + } + Ok(()) + } + + /// Remove a webhook from the LSP. + /// + /// The user will receive the LSP's response via an [`WebhookRemoved`] event. + /// + /// `counterparty_node_id` is the `node_id` of the LSP you would like to use. + /// + /// `app_name` is a `String` that identifies the app this webhook is for. + /// + /// Returns the used [`RequestId`], which will be returned via [`WebhookRemoved`]. + /// + /// [`WebhookRemoved`]: crate::lsps5::event::LSPS5ClientEvent::WebhookRemoved + pub fn remove_webhook(&self, counterparty_node_id: PublicKey, app_name: String) -> RequestId { + let request_id = crate::utils::generate_request_id(&self.entropy_source); + + { + let mut outer_state_lock = self.per_peer_state.write().unwrap(); + let inner_state_lock = outer_state_lock + .entry(counterparty_node_id) + .or_insert(Mutex::new(PeerState::new())); + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + peer_state_lock.pending_remove_webhook_requests.insert(request_id.clone()); + } + + let request = LSPS5Request::RemoveWebhook(RemoveWebhookRequest { app_name }); + let msg = LSPS5Message::Request(request_id.clone(), request).into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + + request_id + } + + fn handle_remove_webhook_response( + &self, request_id: RequestId, counterparty_node_id: &PublicKey, + _result: RemoveWebhookResponse, + ) -> Result<(), LightningError> { + 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 = inner_state_lock.lock().unwrap(); + + if !peer_state.pending_remove_webhook_requests.remove(&request_id) { + return Err(LightningError { + err: format!( + "Received remove_webhook response for an unknown request: {:?}", + request_id + ), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + self.pending_events.enqueue(Event::LSPS5Client(LSPS5ClientEvent::WebhookRemoved { + request_id, + counterparty_node_id: *counterparty_node_id, + })); + }, + None => { + return Err(LightningError { + err: format!( + "Received remove_webhook response from unknown peer: {:?}", + counterparty_node_id + ), + action: ErrorAction::IgnoreAndLog(Level::Info), + }) + }, + } + + Ok(()) + } + + fn handle_remove_webhook_error( + &self, request_id: RequestId, counterparty_node_id: &PublicKey, _error: ResponseError, + ) -> Result<(), LightningError> { + 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 = inner_state_lock.lock().unwrap(); + + if !peer_state.pending_remove_webhook_requests.remove(&request_id) { + return Err(LightningError { + err: format!( + "Received remove_webhook error for an unknown request: {:?}", + request_id + ), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + Ok(()) + }, + None => { + return Err(LightningError { err: format!("Received error response for a remove_webhook request from an unknown counterparty ({:?})",counterparty_node_id), action: ErrorAction::IgnoreAndLog(Level::Info)}); + }, + } + } +} + +impl ProtocolMessageHandler for LSPS5ClientHandler +where + ES::Target: EntropySource, +{ + type ProtocolMessage = LSPS5Message; + const PROTOCOL_NUMBER: Option = Some(2); + + fn handle_message( + &self, message: Self::ProtocolMessage, counterparty_node_id: &PublicKey, + ) -> Result<(), LightningError> { + match message { + LSPS5Message::Response(request_id, response) => match response { + LSPS5Response::SetWebhook(result) => { + self.handle_set_webhook_response(request_id, counterparty_node_id, result) + }, + LSPS5Response::SetWebhookError(error) => { + self.handle_set_webhook_error(request_id, counterparty_node_id, error) + }, + LSPS5Response::ListWebhooks(result) => { + self.handle_list_webhooks_response(request_id, counterparty_node_id, result) + }, + LSPS5Response::RemoveWebhook(result) => { + self.handle_remove_webhook_response(request_id, counterparty_node_id, result) + }, + LSPS5Response::RemoveWebhookError(error) => { + self.handle_remove_webhook_error(request_id, counterparty_node_id, error) + }, + }, + _ => { + debug_assert!( + false, + "Client handler received LSPS5 request message. This should never happen." + ); + Err(LightningError { err: format!("Client handler received LSPS5 request message from node {:?}. This should never happen.", counterparty_node_id), action: ErrorAction::IgnoreAndLog(Level::Info)}) + }, + } + } +} + +#[cfg(test)] +mod tests {} diff --git a/lightning-liquidity/src/lsps5/event.rs b/lightning-liquidity/src/lsps5/event.rs new file mode 100644 index 00000000000..23580e901bb --- /dev/null +++ b/lightning-liquidity/src/lsps5/event.rs @@ -0,0 +1,70 @@ +// 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 LSPS5 event types + +use crate::lsps0::ser::RequestId; +use crate::prelude::{String, Vec}; + +use bitcoin::secp256k1::PublicKey; + +/// An event which an LSPS5 client should take some action in response to. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LSPS5ClientEvent { + /// Confirmation that a webhook has been registered with the LSP. + WebhookSet { + /// The identifier of the issued LSPS5 `set_webhook` request, as returned by + /// [`LSPS5ClientHandler::set_webhook`] + /// + /// This can be used to track which request this event corresponds to. + /// + /// [`LSPS5ClientHandler::set_webhook`]: crate::lsps2::client::LSPS5ClientHandler::set_webhook + request_id: RequestId, + /// The node id of the LSP that provided this response. + counterparty_node_id: PublicKey, + /// The number of webhooks already registered, including this one if it added a new webhook. + num_webhooks: u32, + /// The maximum number of webhooks the LSP allows per client. + max_webhooks: u32, + /// True if the exact app_name and webhook have already been set. + no_change: bool, + }, + /// The list of webhooks registered with the LSP. + ListWebhooks { + /// The identifier of the issued LSPS5 `list_webhooks` request, as returned by + /// [`LSPS5ClientHandler::list_webhooks`] + /// + /// This can be used to track which request this event corresponds to. + /// + /// [`LSPS5ClientHandler::list_webhooks`]: crate::lsps2::client::LSPS5ClientHandler::list_webhooks + request_id: RequestId, + /// The node id of the LSP that provided this response. + counterparty_node_id: PublicKey, + /// List of app names that have webhooks registered for the client. + app_names: Vec, + /// The maximum number of webhooks the LSP allows per client. + max_webhooks: u32, + }, + /// Confirmation that the webhook as been removed. + WebhookRemoved { + /// The identifier of the issued LSPS5 `remove_webhook` request, as returned by + /// [`LSPS5ClientHandler::remove_webhook`] + /// + /// This can be used to track which request this event corresponds to. + /// + /// [`LSPS5ClientHandler::remove_webhook`]: crate::lsps2::client::LSPS5ClientHandler::remove_webhook + request_id: RequestId, + /// The node id of the LSP that provided this response. + counterparty_node_id: PublicKey, + }, +} + +/// An event which an LSPS5 server should take some action in response to. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LSPS5ServiceEvent {} diff --git a/lightning-liquidity/src/lsps5/mod.rs b/lightning-liquidity/src/lsps5/mod.rs new file mode 100644 index 00000000000..133ff7017d9 --- /dev/null +++ b/lightning-liquidity/src/lsps5/mod.rs @@ -0,0 +1,17 @@ +// 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. + +//! Implementation of LSPS2: JIT Channel Negotiation specification. + +pub mod client; +pub mod event; +pub mod msgs; +pub mod notifications; +pub mod service; +pub mod utils; diff --git a/lightning-liquidity/src/lsps5/msgs.rs b/lightning-liquidity/src/lsps5/msgs.rs new file mode 100644 index 00000000000..449e7af3c75 --- /dev/null +++ b/lightning-liquidity/src/lsps5/msgs.rs @@ -0,0 +1,118 @@ +//! Message, request, and other primitive types used to implement LSPS5. + +use core::convert::TryFrom; +use serde::{Deserialize, Serialize}; + +use crate::lsps0::ser::{LSPSMessage, RequestId, ResponseError}; +use crate::prelude::{String, Vec}; + +use super::notifications::LSPS5Notification; + +pub(crate) const LSPS5_SET_WEBHOOK_METHOD_NAME: &str = "lsps5.set_webhook"; +pub(crate) const LSPS5_LIST_WEBHOOKS_METHOD_NAME: &str = "lsps5.list_webhooks"; +pub(crate) const LSPS5_REMOVE_WEBHOOK_METHOD_NAME: &str = "lsps5.remove_webhook"; + +pub(crate) const LSPS5_SET_WEBHOOK_REQUEST_TOO_LONG_ERROR_CODE: i32 = 1000; +pub(crate) const LSPS5_SET_WEBHOOK_REQUEST_UNSUPPORTED_PROTOCOL_ERROR_CODE: i32 = 1001; +pub(crate) const LSPS5_SET_WEBHOOK_REQUEST_TOO_MANY_WEBHOOKS_ERROR_CODE: i32 = 1002; + +pub(crate) const LSPS5_REMOVE_WEBHOOK_REQUEST_APP_NAME_NOT_FOUND_ERROR_CODE: i32 = 1010; + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +/// A request to specify the URI that the LSP should contact in order to send a push notification to the client user. +pub struct SetWebhookRequest { + /// a human-readable UTF-8 string that gives a name to the webhook. + pub app_name: String, + /// the URL of the webhook that the LSP can use to push a notification to the client + pub webhook: String, +} + +/// A response to a [`SetWebhookRequest`] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct SetWebhookResponse { + /// The number of webhooks already registered, including this one if it added a new webhook. + pub num_webhooks: u32, + /// The maximum number of webhooks the LSP allows per client. + pub max_webhooks: u32, + /// True if the exact app_name and webhook have already been set. + pub no_change: bool, +} + +/// A request to learn all app_names that have webhooks registered for the client. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct ListWebhooksRequest {} + +/// A response to a [`ListWebhooksRequest`]. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct ListWebhooksResponse { + /// List of app names that have webhooks registered for the client. + pub app_names: Vec, + /// The maximum number of webhooks the LSP allows per client. + pub max_webhooks: u32, +} + +/// A request to learn all app_names that have webhooks registered for the client. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct RemoveWebhookRequest { + /// the app_name of the webhook to remove. + pub app_name: String, +} + +/// A response to a [`RemoveWebhookRequest`]. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct RemoveWebhookResponse {} + +#[derive(Clone, Debug, PartialEq, Eq)] +/// An enum that captures all the valid JSON-RPC requests in the LSPS5 protocol. +pub enum LSPS5Request { + /// A request to set a webhook for an app. + SetWebhook(SetWebhookRequest), + /// A request to list all registered webhooks. + ListWebhooks(ListWebhooksRequest), + /// A request to remove a specific webhook. + RemoveWebhook(RemoveWebhookRequest), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +/// An enum that captures all the valid JSON-RPC responses in the LSPS5 protocol. +pub enum LSPS5Response { + /// A successful response to a [`LSPS5Request::SetWebhook`] request. + SetWebhook(SetWebhookResponse), + /// An error response to a [`LSPS5Request::SetWebhook`] request. + SetWebhookError(ResponseError), + /// A successful response to a [`LSPS5Request::ListWebhooks`] request. + ListWebhooks(ListWebhooksResponse), + /// An successfull response to a [`LSPS5Request::RemoveWebhook`] request. + RemoveWebhook(RemoveWebhookResponse), + /// An error response to a [`LSPS5Request::RemoveWebhook`] request. + RemoveWebhookError(ResponseError), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +/// An enum that captures all valid JSON-RPC messages in the LSPS5 protocol. +pub enum LSPS5Message { + /// An LSPS5 JSON-RPC request. + Request(RequestId, LSPS5Request), + /// An LSPS5 JSON-RPC response. + Response(RequestId, LSPS5Response), + /// An LSPS5 JSON-RPC notification. + Notification(LSPS5Notification), +} + +impl TryFrom for LSPS5Message { + type Error = (); + + fn try_from(message: LSPSMessage) -> Result { + if let LSPSMessage::LSPS5(message) = message { + return Ok(message); + } + + Err(()) + } +} + +impl From for LSPSMessage { + fn from(message: LSPS5Message) -> Self { + LSPSMessage::LSPS5(message) + } +} diff --git a/lightning-liquidity/src/lsps5/notifications.rs b/lightning-liquidity/src/lsps5/notifications.rs new file mode 100644 index 00000000000..95f314b6ef5 --- /dev/null +++ b/lightning-liquidity/src/lsps5/notifications.rs @@ -0,0 +1,79 @@ +// 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 LSPS5 webhook notification types +use serde::{Deserialize, Serialize}; + +pub(crate) const LSPS5_WEBHOOK_REGISTERED_METHOD_NAME: &str = "lsps5.webhook_registered"; +pub(crate) const LSPS5_PAYMENT_INCOMING_METHOD_NAME: &str = "lsps5.payment_incoming"; +pub(crate) const LSPS5_EXPIRY_SOON_METHOD_NAME: &str = "lsps5.expiry_soon"; +pub(crate) const LSPS5_LIQUIDITY_MANAGEMENT_REQUEST_METHOD_NAME: &str = + "lsps5.liquidity_management_request"; +pub(crate) const LSPS5_FEES_CHANGE_INCOMING_METHOD_NAME: &str = "lsps5.fees_change_incoming"; +pub(crate) const LSPS5_ONION_MESSAGE_INCOMING_METHOD_NAME: &str = "lsps5.onion_message_incoming"; + +/// The client has just recently successfully called the lsps5.set_webhook API. Only the newly-(re)registered webhook is notified. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct WebhookRegisteredParams {} + +/// The client has one or more payments pending to be received. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct PaymentIncomingParams {} + +/// There is an HTLC or other time-bound contract, in either direction, on one of the channels between the client and the LSP, +/// and it is within 24 blocks of being timed out, and the timeout would cause a channel closure +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct ExpirySoonParams { + /// The blockheight at which the LSP would be forced to close the channel in order to enforce the HTLC or other time-bound contract. + pub timeout: u32, +} + +/// The LSP wants to take back some of the liquidity it has towards the client, for example by closing one or more of the channels it has with the client, or by splicing out. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct LiquidityManagementRequestParams {} + +/// The direction of the incoming fee change. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub enum FeesChangeIncomingDirection { + /// The incoming fee change will be lower than the current fee. + Lower, + /// There are a mix of lower and higher fee changes coming. + Mixed, + /// The incoming fee change will be higher than the current fee. + Higher, +} + +/// The LSP wants to change Lightning Network feerates, either for the LSP-to-client channel(s), or for other auto-management services. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct FeesChangeIncomingParams { + /// A rough estimate of the direction of the fees. + pub direction: FeesChangeIncomingDirection, +} + +/// The client has one or more BOLT Onion Messages pending to be received. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct OnionMessageIncomingParams {} + +/// A LPSP5 notification intended to wake-up the client. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LSPS5Notification { + /// The client has just recently successfully called the lsps5.set_webhook API. Only the newly-(re)registered webhook is notified. + WebhookRegistered(WebhookRegisteredParams), + /// The client has one or more payments pending to be received. + PaymentIncoming(PaymentIncomingParams), + /// There is an HTLC or other time-bound contract, in either direction, on one of the channels between the client and the LSP, + /// and it is within 24 blocks of being timed out, and the timeout would cause a channel closure + ExpirySoon(ExpirySoonParams), + /// The LSP wants to take back some of the liquidity it has towards the client, for example by closing one or more of the channels it has with the client, or by splicing out. + LiquidityManagementRequest(LiquidityManagementRequestParams), + /// The LSP wants to change Lightning Network feerates, either for the LSP-to-client channel(s), or for other auto-management services. + FeesChangeIncoming(FeesChangeIncomingParams), + /// The client has one or more BOLT Onion Messages pending to be received. + OnionMessageIncoming(OnionMessageIncomingParams), +} diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs new file mode 100644 index 00000000000..4e6fb255ff5 --- /dev/null +++ b/lightning-liquidity/src/lsps5/service.rs @@ -0,0 +1,444 @@ +// 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 the main LSPS5 server-side object, [`LSPS5ServiceHandler`]. + +use crate::lsps0::ser::{LSPSMessage, ProtocolMessageHandler, RequestId, ResponseError}; +use crate::message_queue::MessageQueue; +use crate::prelude::{String, ToString, Vec}; +use crate::sync::Arc; + +use lightning::io::ErrorKind; +use lightning::ln::msgs::{ErrorAction, LightningError}; +use lightning::util::logger::Level; + +use bitcoin::secp256k1::PublicKey; +use lightning::util::persist::KVStore; + +use core::ops::Deref; +use url::Url; + +use crate::lsps5::msgs::{ + LSPS5Message, LSPS5Request, LSPS5Response, ListWebhooksRequest, ListWebhooksResponse, + RemoveWebhookRequest, RemoveWebhookResponse, SetWebhookRequest, SetWebhookResponse, + LSPS5_REMOVE_WEBHOOK_REQUEST_APP_NAME_NOT_FOUND_ERROR_CODE, + LSPS5_SET_WEBHOOK_REQUEST_TOO_LONG_ERROR_CODE, + LSPS5_SET_WEBHOOK_REQUEST_TOO_MANY_WEBHOOKS_ERROR_CODE, + LSPS5_SET_WEBHOOK_REQUEST_UNSUPPORTED_PROTOCOL_ERROR_CODE, +}; + +use super::notifications::{ + ExpirySoonParams, FeesChangeIncomingDirection, FeesChangeIncomingParams, LSPS5Notification, + LiquidityManagementRequestParams, OnionMessageIncomingParams, PaymentIncomingParams, + WebhookRegisteredParams, +}; + +const WEBHOOK_PRIMARY_NAMESPACE: &str = "webhooks"; + +/// Server-side configuration options for webhook notifications. +#[derive(Clone)] +pub struct LSPS5ServiceConfig { + /// The maximum number of webhooks that can be registered. + pub max_webhooks: u32, + /// The list of protocols in addition to 'https' that are supported. + pub supported_protocols: Vec, +} + +impl LSPS5ServiceConfig { + /// Create a new LSPS5ServiceConfig with the given maximum number of webhooks per client + /// and an optional list of protocols in addition to 'https' that are supported. + pub fn new(max_webhooks: u32, extra_protocols: Option>) -> Self { + let mut supported_protocols = vec!["https".to_string()]; + + if let Some(extra_protocols) = extra_protocols { + for extra_protocol in extra_protocols.into_iter() { + if !supported_protocols.contains(&extra_protocol) { + supported_protocols.push(extra_protocol); + } + } + } + + Self { max_webhooks, supported_protocols } + } +} + +/// The main object allowing to send and receive LSPS5 messages. +pub struct LSPS5ServiceHandler +where + KV::Target: KVStore, +{ + pending_messages: Arc, + kv_store: KV, + config: LSPS5ServiceConfig, +} + +impl LSPS5ServiceHandler +where + KV::Target: KVStore, +{ + /// Constructs a `LSPS5ServiceHandler`. + pub(crate) fn new( + kv_store: KV, pending_messages: Arc, config: LSPS5ServiceConfig, + ) -> Self { + Self { kv_store, pending_messages, config } + } + + fn send_webhook(&self, webhook_url: String, msg: LSPSMessage) -> Result<(), LightningError> { + let _response = + minreq::post(webhook_url).with_json(&msg).expect("json serialization").send().map_err( + |e| LightningError { + err: format!("failed to send webhook: {}", e.to_string()), + action: ErrorAction::IgnoreAndLog(Level::Error), + }, + )?; + Ok(()) + } + + fn send_webhook_registered_notification( + &self, webhook_url: String, + ) -> Result<(), LightningError> { + let msg = LSPSMessage::LSPS5(LSPS5Message::Notification( + LSPS5Notification::WebhookRegistered(WebhookRegisteredParams {}), + )); + self.send_webhook(webhook_url, msg) + } + + fn send_webhook_to_counterparty( + &self, counterparty_node_id: &PublicKey, msg: LSPSMessage, + ) -> Result<(), LightningError> { + let registered_app_names = self + .kv_store + .list(WEBHOOK_PRIMARY_NAMESPACE, &counterparty_node_id.to_string()) + .map_err(|e| LightningError { + err: format!("failed to list webhooks: {}", e.to_string()), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + for app_name in registered_app_names.into_iter() { + let webhook_bytes = self + .kv_store + .read(WEBHOOK_PRIMARY_NAMESPACE, &counterparty_node_id.to_string(), &app_name) + .map_err(|e| LightningError { + err: format!("failed to read webhook: {}", e.to_string()), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + let webhook = String::from_utf8(webhook_bytes).map_err(|e| LightningError { + err: format!("webhook is not a valid utf8 string: {}", e), + action: ErrorAction::IgnoreAndLog(Level::Info), + })?; + + let _ = self.send_webhook(webhook, msg.clone())?; + } + + Ok(()) + } + + /// Send payment incoming notifications to all registered webhooks + /// for this counterparty. + pub fn send_payment_incoming_notification( + &self, counterparty_node_id: &PublicKey, + ) -> Result<(), LightningError> { + let msg = LSPSMessage::LSPS5(LSPS5Message::Notification( + LSPS5Notification::PaymentIncoming(PaymentIncomingParams {}), + )); + self.send_webhook_to_counterparty(counterparty_node_id, msg) + } + + /// Send the expiry soon notification to this counterparty to avoid channel closure. + /// Timeout is the blockheight at which the LSP would be forced to close the channel + /// in order to enforce the HTLC or other time-bound contract. + pub fn send_expiry_soon_notification( + &self, counterparty_node_id: &PublicKey, timeout: u32, + ) -> Result<(), LightningError> { + let msg = LSPSMessage::LSPS5(LSPS5Message::Notification(LSPS5Notification::ExpirySoon( + ExpirySoonParams { timeout }, + ))); + self.send_webhook_to_counterparty(counterparty_node_id, msg) + } + + /// Send liquidity management request notifications to all registered webhooks + /// for this counterparty. + pub fn send_liquidity_management_request_notification( + &self, counterparty_node_id: &PublicKey, + ) -> Result<(), LightningError> { + let msg = LSPSMessage::LSPS5(LSPS5Message::Notification( + LSPS5Notification::LiquidityManagementRequest(LiquidityManagementRequestParams {}), + )); + self.send_webhook_to_counterparty(counterparty_node_id, msg) + } + + /// Send fees change incoming notifications to all registered webhooks + /// for this counterparty. + pub fn send_fees_change_incoming_notification( + &self, counterparty_node_id: &PublicKey, direction: FeesChangeIncomingDirection, + ) -> Result<(), LightningError> { + let msg = LSPSMessage::LSPS5(LSPS5Message::Notification( + LSPS5Notification::FeesChangeIncoming(FeesChangeIncomingParams { direction }), + )); + self.send_webhook_to_counterparty(counterparty_node_id, msg) + } + + /// Send onion message incoming notifications to all registered webhooks + /// for this counterparty. + pub fn send_onion_message_incoming_notification( + &self, counterparty_node_id: &PublicKey, + ) -> Result<(), LightningError> { + let msg = LSPSMessage::LSPS5(LSPS5Message::Notification( + LSPS5Notification::OnionMessageIncoming(OnionMessageIncomingParams {}), + )); + self.send_webhook_to_counterparty(counterparty_node_id, msg) + } + + fn respond_with_error( + &self, request_id: RequestId, counterparty_node_id: &PublicKey, code: i32, message: &str, + ) { + let response = LSPS5Response::SetWebhookError(ResponseError { + code, + message: message.to_string(), + data: None, + }); + + let msg = LSPS5Message::Response(request_id, response).into(); + self.pending_messages.enqueue(counterparty_node_id, msg); + } + + fn handle_set_webhook_request( + &self, request_id: RequestId, counterparty_node_id: &PublicKey, params: SetWebhookRequest, + ) -> Result<(), LightningError> { + if params.app_name.as_bytes().len() > 64 { + self.respond_with_error( + request_id.clone(), + counterparty_node_id, + LSPS5_SET_WEBHOOK_REQUEST_TOO_LONG_ERROR_CODE, + "app_name must be less than 64 bytes", + ); + return Err(LightningError { + err: "app_name must be les than 64 bytes".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + if params.webhook.to_ascii_lowercase().len() > 1024 { + self.respond_with_error( + request_id, + counterparty_node_id, + LSPS5_SET_WEBHOOK_REQUEST_TOO_LONG_ERROR_CODE, + "webhook must be less than 1024 ascii characters", + ); + return Err(LightningError { + err: "webhook must be less than 1024 ascii characters".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + let webhook_url = Url::parse(¶ms.webhook).map_err(|e| { + self.respond_with_error( + request_id.clone(), + counterparty_node_id, + LSPS5_SET_WEBHOOK_REQUEST_UNSUPPORTED_PROTOCOL_ERROR_CODE, + &format!("webhook is not a valid url: {}", e), + ); + LightningError { + err: format!("webhook is not a valid url: {}", e), + action: ErrorAction::IgnoreAndLog(Level::Info), + } + })?; + + if !self.config.supported_protocols.contains(&webhook_url.scheme().to_string()) { + self.respond_with_error( + request_id, + counterparty_node_id, + LSPS5_SET_WEBHOOK_REQUEST_UNSUPPORTED_PROTOCOL_ERROR_CODE, + "webhook protocol is not supported", + ); + return Err(LightningError { + err: "webhook protocol is not supported".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + let existing_webhook = match self.kv_store.read( + WEBHOOK_PRIMARY_NAMESPACE, + &counterparty_node_id.to_string(), + ¶ms.app_name, + ) { + Ok(webhook_bytes) => { + Some(String::from_utf8(webhook_bytes).map_err(|e| LightningError { + err: format!("webhook is not a valid utf8 string: {}", e), + action: ErrorAction::IgnoreAndLog(Level::Info), + })?) + }, + Err(e) => { + if e.kind() == ErrorKind::NotFound { + None + } else { + return Err(LightningError { + err: format!("failed to read existing webhook: {}", e), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + }, + }; + + let existing_webhooks = self + .kv_store + .list(WEBHOOK_PRIMARY_NAMESPACE, &counterparty_node_id.to_string()) + .map_err(|e| LightningError { + err: format!("failed to list existing webhooks: {}", e), + action: ErrorAction::IgnoreAndLog(Level::Info), + })?; + + let (no_change, num_webhooks) = match existing_webhook { + Some(existing_webhook) => { + (existing_webhook == params.webhook, existing_webhooks.len() as u32) + }, + None => (false, (existing_webhooks.len() + 1) as u32), + }; + + if num_webhooks > self.config.max_webhooks { + self.respond_with_error( + request_id, + counterparty_node_id, + LSPS5_SET_WEBHOOK_REQUEST_TOO_MANY_WEBHOOKS_ERROR_CODE, + "too many webhooks", + ); + return Err(LightningError { + err: "too many webhooks".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + if !no_change { + let _ = self + .kv_store + .write( + WEBHOOK_PRIMARY_NAMESPACE, + &counterparty_node_id.to_string(), + ¶ms.app_name, + params.webhook.as_bytes(), + ) + .map_err(|e| LightningError { + err: format!("failed to write webhook: {}", e), + action: ErrorAction::IgnoreAndLog(Level::Info), + })?; + + self.send_webhook_registered_notification(params.webhook)?; + } + + let response = LSPS5Response::SetWebhook(SetWebhookResponse { + num_webhooks, + max_webhooks: self.config.max_webhooks, + no_change, + }); + let msg = LSPS5Message::Response(request_id, response).into(); + self.pending_messages.enqueue(counterparty_node_id, msg); + Ok(()) + } + + fn handle_list_webhooks_request( + &self, request_id: RequestId, counterparty_node_id: &PublicKey, + _params: ListWebhooksRequest, + ) -> Result<(), LightningError> { + let app_names = self + .kv_store + .list(WEBHOOK_PRIMARY_NAMESPACE, &counterparty_node_id.to_string()) + .map_err(|e| LightningError { + err: format!("failed to list webhooks: {}", e), + action: ErrorAction::IgnoreAndLog(Level::Info), + })?; + let response = LSPS5Response::ListWebhooks(ListWebhooksResponse { + app_names, + max_webhooks: self.config.max_webhooks, + }); + let msg = LSPS5Message::Response(request_id, response).into(); + self.pending_messages.enqueue(counterparty_node_id, msg); + Ok(()) + } + + fn handle_remove_webhook_request( + &self, request_id: RequestId, counterparty_node_id: &PublicKey, + params: RemoveWebhookRequest, + ) -> Result<(), LightningError> { + let app_names = self + .kv_store + .list(WEBHOOK_PRIMARY_NAMESPACE, &counterparty_node_id.to_string()) + .map_err(|e| LightningError { + err: format!("failed to list webhooks: {}", e), + action: ErrorAction::IgnoreAndLog(Level::Info), + })?; + + if !app_names.contains(¶ms.app_name) { + self.respond_with_error( + request_id, + counterparty_node_id, + LSPS5_REMOVE_WEBHOOK_REQUEST_APP_NAME_NOT_FOUND_ERROR_CODE, + "app_name not found", + ); + return Err(LightningError { + err: format!("webhook app name not found: {}", params.app_name), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + let _ = self + .kv_store + .remove( + WEBHOOK_PRIMARY_NAMESPACE, + &counterparty_node_id.to_string(), + ¶ms.app_name, + false, + ) + .map_err(|e| LightningError { + err: format!("failed to remove webhook: {}", e), + action: ErrorAction::IgnoreAndLog(Level::Info), + })?; + + let response = LSPS5Response::RemoveWebhook(RemoveWebhookResponse {}); + let msg = LSPS5Message::Response(request_id, response).into(); + self.pending_messages.enqueue(counterparty_node_id, msg); + Ok(()) + } +} + +impl ProtocolMessageHandler for LSPS5ServiceHandler +where + KV::Target: KVStore, +{ + type ProtocolMessage = LSPS5Message; + const PROTOCOL_NUMBER: Option = Some(5); + + fn handle_message( + &self, message: Self::ProtocolMessage, counterparty_node_id: &PublicKey, + ) -> Result<(), LightningError> { + match message { + LSPS5Message::Request(request_id, request) => match request { + LSPS5Request::SetWebhook(params) => { + self.handle_set_webhook_request(request_id, counterparty_node_id, params) + }, + LSPS5Request::ListWebhooks(params) => { + self.handle_list_webhooks_request(request_id, counterparty_node_id, params) + }, + LSPS5Request::RemoveWebhook(params) => { + self.handle_remove_webhook_request(request_id, counterparty_node_id, params) + }, + }, + _ => { + debug_assert!( + false, + "Service handler received LSPS5 response message. This should never happen." + ); + Err(LightningError { err: format!("Service handler received LSPS5 response message from node {:?}. This should never happen.", counterparty_node_id), action: ErrorAction::IgnoreAndLog(Level::Info)}) + }, + } + } +} + +#[cfg(test)] +mod tests {} diff --git a/lightning-liquidity/src/lsps5/utils.rs b/lightning-liquidity/src/lsps5/utils.rs new file mode 100644 index 00000000000..8d7a64cf31f --- /dev/null +++ b/lightning-liquidity/src/lsps5/utils.rs @@ -0,0 +1 @@ +//! Utilities for implementing the LSPS5 standard. diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 1e467c302de..1e7a8b0c3ba 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -17,6 +17,14 @@ use crate::lsps1::service::{LSPS1ServiceConfig, LSPS1ServiceHandler}; use crate::lsps2::client::{LSPS2ClientConfig, LSPS2ClientHandler}; use crate::lsps2::msgs::LSPS2Message; use crate::lsps2::service::{LSPS2ServiceConfig, LSPS2ServiceHandler}; + +#[cfg(feature = "lsps5")] +use crate::lsps5::client::{LSPS5ClientConfig, LSPS5ClientHandler}; +#[cfg(feature = "lsps5")] +use crate::lsps5::msgs::LSPS5Message; +#[cfg(feature = "lsps5")] +use crate::lsps5::service::{LSPS5ServiceConfig, LSPS5ServiceHandler}; + use crate::prelude::{new_hash_map, new_hash_set, HashMap, HashSet, ToString, Vec}; use crate::sync::{Arc, Mutex, RwLock}; @@ -27,6 +35,7 @@ use lightning::ln::peer_handler::CustomMessageHandler; use lightning::ln::wire::CustomMessageReader; use lightning::sign::EntropySource; use lightning::util::logger::Level; +use lightning::util::persist::KVStore; use lightning::util::ser::Readable; use lightning_types::features::{InitFeatures, NodeFeatures}; @@ -48,6 +57,10 @@ pub struct LiquidityServiceConfig { /// Optional server-side configuration for JIT channels /// should you want to support them. pub lsps2_service_config: Option, + /// Optional server-side configuration for Webhook Notifications + /// should you want to support them. + #[cfg(feature = "lsps5")] + pub lsps5_service_config: Option, /// Controls whether the liquidity service should be advertised via setting the feature bit in /// node announcment and the init message. pub advertise_service: bool, @@ -62,6 +75,9 @@ pub struct LiquidityClientConfig { pub lsps1_client_config: Option, /// Optional client-side configuration for JIT channels. pub lsps2_client_config: Option, + /// Optional client-side configuration for Webhook Notifications. + #[cfg(feature = "lsps5")] + pub lsps5_client_config: Option, } /// The main interface into LSP functionality. @@ -87,11 +103,16 @@ pub struct LiquidityClientConfig { /// [`Event::ChannelReady`]: lightning::events::Event::ChannelReady /// [`Event::HTLCHandlingFailed`]: lightning::events::Event::HTLCHandlingFailed /// [`Event::PaymentForwarded`]: lightning::events::Event::PaymentForwarded -pub struct LiquidityManager -where +pub struct LiquidityManager< + ES: Deref + Clone, + CM: Deref + Clone, + C: Deref + Clone, + KV: Deref + Clone, +> where ES::Target: EntropySource, CM::Target: AChannelManager, C::Target: Filter, + KV::Target: KVStore, { pending_messages: Arc, pending_events: Arc, @@ -105,17 +126,24 @@ where lsps1_client_handler: Option>, lsps2_service_handler: Option>, lsps2_client_handler: Option>, + #[cfg(feature = "lsps5")] + lsps5_client_handler: Option>, + #[cfg(feature = "lsps5")] + lsps5_service_handler: Option>, service_config: Option, _client_config: Option, best_block: RwLock>, _chain_source: Option, + _kv_store: Option, } -impl LiquidityManager +impl + LiquidityManager where ES::Target: EntropySource, CM::Target: AChannelManager, C::Target: Filter, + KV::Target: KVStore, { /// Constructor for the [`LiquidityManager`]. /// @@ -124,7 +152,7 @@ where pub fn new( entropy_source: ES, channel_manager: CM, chain_source: Option, chain_params: Option, service_config: Option, - client_config: Option, + client_config: Option, kv_store: Option, ) -> Self where { let pending_messages = Arc::new(MessageQueue::new()); @@ -159,6 +187,31 @@ where { }) }); + #[cfg(feature = "lsps5")] + let lsps5_client_handler = client_config.as_ref().and_then(|config| { + config.lsps5_client_config.map(|config| { + LSPS5ClientHandler::new( + entropy_source.clone(), + Arc::clone(&pending_messages), + Arc::clone(&pending_events), + config.clone(), + ) + }) + }); + + #[cfg(feature = "lsps5")] + let lsps5_service_handler = service_config.as_ref().and_then(|config| { + config.lsps5_service_config.as_ref().and_then(|config| { + kv_store.as_ref().map(|kv_store| { + LSPS5ServiceHandler::new( + kv_store.clone(), + Arc::clone(&pending_messages), + config.clone(), + ) + }) + }) + }); + let lsps1_client_handler = client_config.as_ref().and_then(|config| { config.lsps1_client_config.as_ref().map(|config| { LSPS1ClientHandler::new( @@ -213,10 +266,15 @@ where { lsps1_service_handler, lsps2_client_handler, lsps2_service_handler, + #[cfg(feature = "lsps5")] + lsps5_client_handler, + #[cfg(feature = "lsps5")] + 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, + _kv_store: kv_store, } } @@ -260,6 +318,18 @@ where { self.lsps2_service_handler.as_ref() } + /// Returns a reference to the LSPS5 client-side handler. + #[cfg(feature = "lsps5")] + pub fn lsps5_client_handler(&self) -> Option<&LSPS5ClientHandler> { + self.lsps5_client_handler.as_ref() + } + + /// Returns a reference to the LSPS5 server-side handler. + #[cfg(feature = "lsps5")] + pub fn lsps5_service_handler(&self) -> Option<&LSPS5ServiceHandler> { + self.lsps5_service_handler.as_ref() + } + /// Allows to set a callback that will be called after new messages are pushed to the message /// queue. /// @@ -305,7 +375,7 @@ where { /// # type MyGossipSync = lightning::routing::gossip::P2PGossipSync, Arc, Arc>; /// # type MyChannelManager = lightning::ln::channelmanager::SimpleArcChannelManager; /// # type MyScorer = RwLock, Arc>>; - /// # type MyLiquidityManager = LiquidityManager, Arc, Arc>; + /// # type MyLiquidityManager = LiquidityManager, Arc, Arc, Arc>; /// # fn setup_background_processing(my_persister: Arc, my_event_handler: Arc, my_chain_monitor: Arc, my_channel_manager: Arc, my_logger: Arc, my_peer_manager: Arc, my_liquidity_manager: Arc) { /// let process_msgs_pm = Arc::clone(&my_peer_manager); /// let process_msgs_callback = move || process_msgs_pm.process_events(); @@ -365,7 +435,7 @@ where { /// # type MyGossipSync = lightning::routing::gossip::P2PGossipSync, Arc, Arc>; /// # type MyChannelManager = lightning::ln::channelmanager::SimpleArcChannelManager; /// # type MyScorer = RwLock, Arc>>; - /// # type MyLiquidityManager = LiquidityManager, Arc, Arc>; + /// # type MyLiquidityManager = LiquidityManager, Arc, Arc, Arc>; /// # fn setup_background_processing(my_persister: Arc, my_event_handler: Arc, my_chain_monitor: Arc, my_channel_manager: Arc, my_logger: Arc, my_peer_manager: Arc, my_liquidity_manager: Arc) { /// let process_msgs_pm = Arc::clone(&my_peer_manager); /// let process_msgs_callback = move || process_msgs_pm.process_events(); @@ -496,17 +566,40 @@ where { }, } }, + #[cfg(feature = "lsps5")] + LSPSMessage::LSPS5(msg @ LSPS5Message::Request(..)) => match &self.lsps5_service_handler { + Some(lsps5_service_handler) => { + lsps5_service_handler.handle_message(msg, sender_node_id)?; + }, + None => { + return Err(LightningError { err: format!("Received LSPS5 request message without LSPS5 service handler configured. From node = {:?}", sender_node_id), action: ErrorAction::IgnoreAndLog(Level::Info)}); + }, + }, + #[cfg(feature = "lsps5")] + LSPSMessage::LSPS5(msg @ LSPS5Message::Response(..)) => match &self.lsps5_client_handler { + Some(lsps5_client_handler) => { + lsps5_client_handler.handle_message(msg, sender_node_id)?; + }, + None => { + return Err(LightningError { err: format!("Received LSPS5 response message without LSPS5 client handler configured. From node = {:?}", sender_node_id), action: ErrorAction::IgnoreAndLog(Level::Info)}); + }, + }, + #[cfg(feature = "lsps5")] + LSPSMessage::LSPS5(_msg @ LSPS5Message::Notification(..)) => { + return Err(LightningError { err: format!("Received unexpected LSPS5 notification over custom message. From node = {:?}", sender_node_id), action: ErrorAction::IgnoreAndLog(Level::Info)}); + }, } Ok(()) } } -impl CustomMessageReader - for LiquidityManager +impl + CustomMessageReader for LiquidityManager where ES::Target: EntropySource, CM::Target: AChannelManager, C::Target: Filter, + KV::Target: KVStore, { type CustomMessage = RawLSPSMessage; @@ -520,12 +613,13 @@ where } } -impl CustomMessageHandler - for LiquidityManager +impl CustomMessageHandler + for LiquidityManager where ES::Target: EntropySource, CM::Target: AChannelManager, C::Target: Filter, + KV::Target: KVStore, { fn handle_custom_message( &self, msg: Self::CustomMessage, sender_node_id: PublicKey, @@ -632,11 +726,13 @@ where } } -impl Listen for LiquidityManager +impl Listen + for LiquidityManager where ES::Target: EntropySource, CM::Target: AChannelManager, C::Target: Filter, + KV::Target: KVStore, { fn filtered_block_connected( &self, header: &bitcoin::block::Header, txdata: &chain::transaction::TransactionData, @@ -669,11 +765,13 @@ where } } -impl Confirm for LiquidityManager +impl Confirm + for LiquidityManager where ES::Target: EntropySource, CM::Target: AChannelManager, C::Target: Filter, + KV::Target: KVStore, { fn transactions_confirmed( &self, _header: &bitcoin::block::Header, _txdata: &chain::transaction::TransactionData, diff --git a/lightning-liquidity/tests/common/mod.rs b/lightning-liquidity/tests/common/mod.rs index 8b8507a9f14..898af2ecdb6 100644 --- a/lightning-liquidity/tests/common/mod.rs +++ b/lightning-liquidity/tests/common/mod.rs @@ -127,13 +127,20 @@ pub(crate) struct Node { Arc, Arc, Arc, + Arc, >, >, Arc, >, >, - pub(crate) liquidity_manager: - Arc, Arc, Arc>>, + pub(crate) liquidity_manager: Arc< + LiquidityManager< + Arc, + Arc, + Arc, + Arc, + >, + >, pub(crate) check_msgs_processed: Arc, pub(crate) chain_monitor: Arc, pub(crate) kv_store: Arc, @@ -460,6 +467,7 @@ pub(crate) fn create_liquidity_node( Some(chain_params), service_config, client_config, + Some(kv_store.clone()), )); let msg_handler = MessageHandler { chan_handler: Arc::new(test_utils::TestChannelMessageHandler::new( diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 92e172606ab..1301abd3057 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -88,6 +88,8 @@ fn invoice_generation_flow() { #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: Some(lsps2_service_config), + #[cfg(feature = "lsps5")] + lsps5_service_config: None, advertise_service: true, }; @@ -95,6 +97,8 @@ fn invoice_generation_flow() { let client_config = LiquidityClientConfig { lsps1_client_config: None, lsps2_client_config: Some(lsps2_client_config), + #[cfg(feature = "lsps5")] + lsps5_client_config: None, }; let (service_node, client_node) = diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs new file mode 100644 index 00000000000..70dd5a255cf --- /dev/null +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -0,0 +1,174 @@ +#![cfg(all(test, feature = "std", feature = "lsps5"))] + +mod common; + +use common::{create_service_and_client_nodes, get_lsps_message}; + +use lightning_liquidity::events::Event; +use lightning_liquidity::lsps5::client::LSPS5ClientConfig; +use lightning_liquidity::lsps5::event::LSPS5ClientEvent; +use lightning_liquidity::lsps5::service::LSPS5ServiceConfig; +use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; + +use lightning::ln::peer_handler::CustomMessageHandler; + +#[test] +#[cfg(feature = "lsps5")] +fn webhook_management() { + let lsps5_service_config = + LSPS5ServiceConfig { max_webhooks: 5, supported_protocols: vec!["https".to_string()] }; + let service_config = LiquidityServiceConfig { + #[cfg(lsps1_service)] + lsps1_service_config: None, + lsps2_service_config: None, + lsps5_service_config: Some(lsps5_service_config), + advertise_service: true, + }; + + let lsps5_client_config = LSPS5ClientConfig::default(); + let client_config = LiquidityClientConfig { + lsps1_client_config: None, + lsps2_client_config: None, + lsps5_client_config: Some(lsps5_client_config), + }; + + let (service_node, client_node) = + create_service_and_client_nodes("webhook_management", service_config, client_config); + + let _service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap(); + let service_node_id = service_node.channel_manager.get_our_node_id(); + + let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + let client_node_id = client_node.channel_manager.get_our_node_id(); + + let set_webhook_request_id = client_handler.set_webhook( + service_node_id, + "test-app".to_string(), + "https://example.com/webhook".to_string(), + ); + let set_webhook_request = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(set_webhook_request, client_node_id) + .unwrap(); + + let set_webhook_response = get_lsps_message!(service_node, client_node_id); + + client_node + .liquidity_manager + .handle_custom_message(set_webhook_response, service_node_id) + .unwrap(); + + let webhook_set_event = client_node.liquidity_manager.next_event().unwrap(); + + match webhook_set_event { + Event::LSPS5Client(LSPS5ClientEvent::WebhookSet { + request_id, + counterparty_node_id, + num_webhooks, + max_webhooks, + no_change, + }) => { + assert_eq!(request_id, set_webhook_request_id); + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(num_webhooks, 1); + assert_eq!(max_webhooks, 5); + assert_eq!(no_change, false); + }, + _ => panic!("Unexpected event"), + }; + + let list_webhooks_request_id = client_handler.list_webhooks(service_node_id); + let list_webhooks_request = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(list_webhooks_request, client_node_id) + .unwrap(); + + let list_webhooks_response = get_lsps_message!(service_node, client_node_id); + + client_node + .liquidity_manager + .handle_custom_message(list_webhooks_response, service_node_id) + .unwrap(); + + let list_webhooks_event = client_node.liquidity_manager.next_event().unwrap(); + + match list_webhooks_event { + Event::LSPS5Client(LSPS5ClientEvent::ListWebhooks { + request_id, + counterparty_node_id, + app_names, + max_webhooks, + }) => { + assert_eq!(request_id, list_webhooks_request_id); + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(app_names, vec!["test-app".to_string()]); + assert_eq!(max_webhooks, 5); + }, + _ => panic!("Unexpected event"), + }; + + let remove_webhook_request_id = + client_handler.remove_webhook(service_node_id, "test-app".to_string()); + let remove_webhook_request = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(remove_webhook_request, client_node_id) + .unwrap(); + + let remove_webhook_response = get_lsps_message!(service_node, client_node_id); + + client_node + .liquidity_manager + .handle_custom_message(remove_webhook_response, service_node_id) + .unwrap(); + + let remove_webhook_event = client_node.liquidity_manager.next_event().unwrap(); + + match remove_webhook_event { + Event::LSPS5Client(LSPS5ClientEvent::WebhookRemoved { + request_id, + counterparty_node_id, + }) => { + assert_eq!(request_id, remove_webhook_request_id); + assert_eq!(counterparty_node_id, service_node_id); + }, + _ => panic!("Unexpected event"), + }; + + let list_webhooks_request_id = client_handler.list_webhooks(service_node_id); + let list_webhooks_request = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(list_webhooks_request, client_node_id) + .unwrap(); + + let list_webhooks_response = get_lsps_message!(service_node, client_node_id); + + client_node + .liquidity_manager + .handle_custom_message(list_webhooks_response, service_node_id) + .unwrap(); + + let list_webhooks_event = client_node.liquidity_manager.next_event().unwrap(); + + match list_webhooks_event { + Event::LSPS5Client(LSPS5ClientEvent::ListWebhooks { + request_id, + counterparty_node_id, + app_names, + max_webhooks, + }) => { + assert_eq!(request_id, list_webhooks_request_id); + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(app_names, Vec::::new()); + assert_eq!(max_webhooks, 5); + }, + _ => panic!("Unexpected event"), + }; +}