lsp_plugin: add lsps2_getinfo handler and call
This commit adds the lsps2_get_info call defined by BLIP052. It also adds a test policy plugin that the LSP service plugin uses to fetch the actual fee menu from to separate the concerns of providing a spec compliant implementation of an LSP and making business decisions about fee prices. Signed-off-by: Peter Neuroth <pet.v.ne@gmail.com>
This commit is contained in:
committed by
Rusty Russell
parent
17a9a928f5
commit
581eb3076f
@@ -4,6 +4,7 @@ use cln_lsps::lsps0::{
|
||||
self,
|
||||
transport::{Bolt8Transport, CustomMessageHookManager, WithCustomMessageHookManager},
|
||||
};
|
||||
use cln_lsps::lsps2::model::{Lsps2GetInfoRequest, Lsps2GetInfoResponse};
|
||||
use cln_lsps::util;
|
||||
use cln_lsps::LSP_FEATURE_BIT;
|
||||
use cln_plugin::options;
|
||||
@@ -11,7 +12,7 @@ use cln_rpc::model::requests::ListpeersRequest;
|
||||
use cln_rpc::primitives::PublicKey;
|
||||
use cln_rpc::ClnRpc;
|
||||
use log::debug;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::str::FromStr as _;
|
||||
|
||||
@@ -45,6 +46,11 @@ async fn main() -> Result<(), anyhow::Error> {
|
||||
"list protocols supported by lsp",
|
||||
on_lsps_listprotocols,
|
||||
)
|
||||
.rpcmethod(
|
||||
"lsps-lsps2-getinfo",
|
||||
"Low-level command to request the opening fee menu of an LSP",
|
||||
on_lsps_lsps2_getinfo,
|
||||
)
|
||||
.configure()
|
||||
.await?
|
||||
{
|
||||
@@ -61,7 +67,47 @@ async fn main() -> Result<(), anyhow::Error> {
|
||||
}
|
||||
}
|
||||
|
||||
/// RPC Method handler for `lsps-listprotocols`.
|
||||
/// Rpc Method handler for `lsps-lsps2-getinfo`.
|
||||
async fn on_lsps_lsps2_getinfo(
|
||||
p: cln_plugin::Plugin<State>,
|
||||
v: serde_json::Value,
|
||||
) -> Result<serde_json::Value, anyhow::Error> {
|
||||
let req: ClnRpcLsps2GetinfoRequest =
|
||||
serde_json::from_value(v).context("Failed to parse request JSON")?;
|
||||
debug!(
|
||||
"Requesting opening fee menu from lsp {} with token {:?}",
|
||||
req.lsp_id, req.token
|
||||
);
|
||||
|
||||
let dir = p.configuration().lightning_dir;
|
||||
let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file);
|
||||
let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?;
|
||||
|
||||
// Fail early: Check that we are connected to the peer and that it has the
|
||||
// LSP feature bit set.
|
||||
ensure_lsp_connected(&mut cln_client, &req.lsp_id).await?;
|
||||
|
||||
// Create Transport and Client
|
||||
let transport = Bolt8Transport::new(
|
||||
&req.lsp_id,
|
||||
rpc_path.clone(), // Clone path for potential reuse
|
||||
p.state().hook_manager.clone(),
|
||||
None, // Use default timeout
|
||||
)
|
||||
.context("Failed to create Bolt8Transport")?;
|
||||
let client = JsonRpcClient::new(transport);
|
||||
|
||||
// 1. Call lsps2.get_info.
|
||||
let info_req = Lsps2GetInfoRequest { token: req.token };
|
||||
let info_res: Lsps2GetInfoResponse = client
|
||||
.call_typed(info_req)
|
||||
.await
|
||||
.context("lsps2.get_info call failed")?;
|
||||
debug!("received lsps2.get_info response: {:?}", info_res);
|
||||
|
||||
Ok(serde_json::to_value(info_res)?)
|
||||
}
|
||||
|
||||
async fn on_lsps_listprotocols(
|
||||
p: cln_plugin::Plugin<State>,
|
||||
v: serde_json::Value,
|
||||
@@ -141,3 +187,9 @@ async fn ensure_lsp_connected(cln_client: &mut ClnRpc, lsp_id: &str) -> Result<(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ClnRpcLsps2GetinfoRequest {
|
||||
lsp_id: String,
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
239
plugins/lsps-plugin/src/lsps2/handler.rs
Normal file
239
plugins/lsps-plugin/src/lsps2/handler.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
use crate::{
|
||||
jsonrpc::{server::RequestHandler, JsonRpcResponse as _, RequestObject, RpcError},
|
||||
lsps2::model::{
|
||||
Lsps2GetInfoRequest, Lsps2GetInfoResponse, Lsps2PolicyGetInfoRequest,
|
||||
Lsps2PolicyGetInfoResponse, OpeningFeeParams, Promise,
|
||||
},
|
||||
util::unwrap_payload_with_peer_id,
|
||||
};
|
||||
use anyhow::{Context, Result as AnyResult};
|
||||
use async_trait::async_trait;
|
||||
use cln_rpc::ClnRpc;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ClnApi: Send + Sync {
|
||||
async fn lsps2_getpolicy(
|
||||
&self,
|
||||
params: &Lsps2PolicyGetInfoRequest,
|
||||
) -> AnyResult<Lsps2PolicyGetInfoResponse>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClnApiRpc {
|
||||
rpc_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ClnApiRpc {
|
||||
pub fn new(rpc_path: PathBuf) -> Self {
|
||||
Self { rpc_path }
|
||||
}
|
||||
|
||||
async fn create_rpc(&self) -> AnyResult<ClnRpc> {
|
||||
ClnRpc::new(&self.rpc_path).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ClnApi for ClnApiRpc {
|
||||
async fn lsps2_getpolicy(
|
||||
&self,
|
||||
params: &Lsps2PolicyGetInfoRequest,
|
||||
) -> AnyResult<Lsps2PolicyGetInfoResponse> {
|
||||
let mut rpc = self.create_rpc().await?;
|
||||
rpc.call_raw("dev-lsps2-getpolicy", params)
|
||||
.await
|
||||
.map_err(anyhow::Error::new)
|
||||
.with_context(|| "calling dev-lsps2-getpolicy")
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for the `lsps2.get_info` method.
|
||||
pub struct Lsps2GetInfoHandler<A: ClnApi> {
|
||||
pub api: A,
|
||||
pub promise_secret: [u8; 32],
|
||||
}
|
||||
|
||||
impl<A: ClnApi> Lsps2GetInfoHandler<A> {
|
||||
pub fn new(api: A, promise_secret: [u8; 32]) -> Self {
|
||||
Self {
|
||||
api,
|
||||
promise_secret,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The RequestHandler calls the internal rpc command `dev-lsps2-getinfo`. It
|
||||
/// expects a plugin has registered this command and manages policies for the
|
||||
/// LSPS2 service.
|
||||
#[async_trait]
|
||||
impl<T: ClnApi + 'static> RequestHandler for Lsps2GetInfoHandler<T> {
|
||||
async fn handle(&self, payload: &[u8]) -> core::result::Result<Vec<u8>, RpcError> {
|
||||
let (payload, _) = unwrap_payload_with_peer_id(payload);
|
||||
|
||||
let req: RequestObject<Lsps2GetInfoRequest> = serde_json::from_slice(&payload)
|
||||
.map_err(|e| RpcError::parse_error(format!("failed to parse request: {e}")))?;
|
||||
|
||||
if req.id.is_none() {
|
||||
// Is a notification we can not reply so we just return
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let params = req
|
||||
.params
|
||||
.ok_or(RpcError::invalid_params("expected params but was missing"))?;
|
||||
|
||||
let policy_params: Lsps2PolicyGetInfoRequest = params.into();
|
||||
let res_data: Lsps2PolicyGetInfoResponse = self
|
||||
.api
|
||||
.lsps2_getpolicy(&policy_params)
|
||||
.await
|
||||
.map_err(|e| RpcError {
|
||||
code: 200,
|
||||
message: format!("failed to fetch policy {e:#}"),
|
||||
data: None,
|
||||
})?;
|
||||
|
||||
let opening_fee_params_menu = res_data
|
||||
.policy_opening_fee_params_menu
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let promise: Promise = v
|
||||
.get_hmac_hex(&self.promise_secret)
|
||||
.try_into()
|
||||
.map_err(|e| RpcError::internal_error(format!("invalid promise: {e}")))?;
|
||||
Ok(OpeningFeeParams {
|
||||
min_fee_msat: v.min_fee_msat,
|
||||
proportional: v.proportional,
|
||||
valid_until: v.valid_until,
|
||||
min_lifetime: v.min_lifetime,
|
||||
max_client_to_self_delay: v.max_client_to_self_delay,
|
||||
min_payment_size_msat: v.min_payment_size_msat,
|
||||
max_payment_size_msat: v.max_payment_size_msat,
|
||||
promise,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, RpcError>>()?;
|
||||
|
||||
let res = Lsps2GetInfoResponse {
|
||||
opening_fee_params_menu,
|
||||
}
|
||||
.into_response(req.id.unwrap()); // We checked that we got an id before.
|
||||
|
||||
serde_json::to_vec(&res)
|
||||
.map_err(|e| RpcError::internal_error(format!("Failed to serialize response: {}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
jsonrpc::{JsonRpcRequest, ResponseObject},
|
||||
lsps0::primitives::{Msat, Ppm},
|
||||
lsps2::model::PolicyOpeningFeeParams,
|
||||
util::wrap_payload_with_peer_id,
|
||||
};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use cln_rpc::primitives::PublicKey;
|
||||
use cln_rpc::RpcError as ClnRpcError;
|
||||
|
||||
const PUBKEY: [u8; 33] = [
|
||||
0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87,
|
||||
0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16,
|
||||
0xf8, 0x17, 0x98,
|
||||
];
|
||||
|
||||
fn create_peer_id() -> PublicKey {
|
||||
PublicKey::from_slice(&PUBKEY).expect("Valid pubkey")
|
||||
}
|
||||
|
||||
fn create_wrapped_request(request: &RequestObject<Lsps2GetInfoRequest>) -> Vec<u8> {
|
||||
let payload = serde_json::to_vec(request).expect("Failed to serialize request");
|
||||
wrap_payload_with_peer_id(&payload, create_peer_id())
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct FakeCln {
|
||||
lsps2_getpolicy_response: Arc<Mutex<Option<Lsps2PolicyGetInfoResponse>>>,
|
||||
lsps2_getpolicy_error: Arc<Mutex<Option<ClnRpcError>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ClnApi for FakeCln {
|
||||
async fn lsps2_getpolicy(
|
||||
&self,
|
||||
_params: &Lsps2PolicyGetInfoRequest,
|
||||
) -> Result<Lsps2PolicyGetInfoResponse, anyhow::Error> {
|
||||
if let Some(err) = self.lsps2_getpolicy_error.lock().unwrap().take() {
|
||||
return Err(anyhow::Error::new(err).context("from fake api"));
|
||||
};
|
||||
if let Some(res) = self.lsps2_getpolicy_response.lock().unwrap().take() {
|
||||
return Ok(res);
|
||||
};
|
||||
panic!("No lsps2 response defined");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_successful_get_info() {
|
||||
let promise_secret = [0u8; 32];
|
||||
let params = Lsps2PolicyGetInfoResponse {
|
||||
policy_opening_fee_params_menu: vec![PolicyOpeningFeeParams {
|
||||
min_fee_msat: Msat(2000),
|
||||
proportional: Ppm(10000),
|
||||
valid_until: Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(),
|
||||
min_lifetime: 1000,
|
||||
max_client_to_self_delay: 42,
|
||||
min_payment_size_msat: Msat(1000000),
|
||||
max_payment_size_msat: Msat(100000000),
|
||||
}],
|
||||
};
|
||||
let promise = params.policy_opening_fee_params_menu[0].get_hmac_hex(&promise_secret);
|
||||
let fake = FakeCln::default();
|
||||
*fake.lsps2_getpolicy_response.lock().unwrap() = Some(params);
|
||||
let handler = Lsps2GetInfoHandler::new(fake, promise_secret);
|
||||
|
||||
let request = Lsps2GetInfoRequest { token: None }.into_request(Some("test-id".to_string()));
|
||||
let payload = create_wrapped_request(&request);
|
||||
|
||||
let result = handler.handle(&payload).await.unwrap();
|
||||
let response: ResponseObject<Lsps2GetInfoResponse> =
|
||||
serde_json::from_slice(&result).unwrap();
|
||||
let response = response.into_inner().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response.opening_fee_params_menu[0].min_payment_size_msat,
|
||||
Msat(1000000)
|
||||
);
|
||||
assert_eq!(
|
||||
response.opening_fee_params_menu[0].max_payment_size_msat,
|
||||
Msat(100000000)
|
||||
);
|
||||
assert_eq!(
|
||||
response.opening_fee_params_menu[0].promise,
|
||||
promise.try_into().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_info_rpc_error_handling() {
|
||||
let fake = FakeCln::default();
|
||||
*fake.lsps2_getpolicy_error.lock().unwrap() = Some(ClnRpcError {
|
||||
code: Some(-1),
|
||||
message: "not found".to_string(),
|
||||
data: None,
|
||||
});
|
||||
let handler = Lsps2GetInfoHandler::new(fake, [0; 32]);
|
||||
let request = Lsps2GetInfoRequest { token: None }.into_request(Some("test-id".to_string()));
|
||||
let payload = create_wrapped_request(&request);
|
||||
|
||||
let result = handler.handle(&payload).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let error = result.unwrap_err();
|
||||
assert_eq!(error.code, 200);
|
||||
assert!(error.message.contains("failed to fetch policy"));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use cln_plugin::options;
|
||||
|
||||
pub mod handler;
|
||||
pub mod model;
|
||||
|
||||
pub const OPTION_ENABLED: options::FlagConfigOption = options::ConfigOption::new_flag(
|
||||
|
||||
@@ -6,6 +6,7 @@ use cln_lsps::jsonrpc::{server::JsonRpcServer, JsonRpcRequest};
|
||||
use cln_lsps::lsps0::handler::Lsps0ListProtocolsHandler;
|
||||
use cln_lsps::lsps0::model::Lsps0listProtocolsRequest;
|
||||
use cln_lsps::lsps0::transport::{self, CustomMsg};
|
||||
use cln_lsps::lsps2::model::Lsps2GetInfoRequest;
|
||||
use cln_lsps::util::wrap_payload_with_peer_id;
|
||||
use cln_lsps::{lsps0, lsps2, util, LSP_FEATURE_BIT};
|
||||
use cln_plugin::options::ConfigOption;
|
||||
@@ -46,12 +47,22 @@ async fn main() -> Result<(), anyhow::Error> {
|
||||
.configure()
|
||||
.await?
|
||||
{
|
||||
let rpc_path =
|
||||
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file);
|
||||
|
||||
if !plugin.option(&OPTION_ENABLED)? {
|
||||
return plugin
|
||||
.disable(&format!("`{}` not enabled", OPTION_ENABLED.name))
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut lsps_builder = JsonRpcServer::builder().with_handler(
|
||||
Lsps0listProtocolsRequest::METHOD.to_string(),
|
||||
Arc::new(Lsps0ListProtocolsHandler {
|
||||
lsps2_enabled: plugin.option(&lsps2::OPTION_ENABLED)?,
|
||||
}),
|
||||
);
|
||||
|
||||
if plugin.option(&lsps2::OPTION_ENABLED)? {
|
||||
log::debug!("lsps2 enabled");
|
||||
let secret_hex = plugin.option(&lsps2::OPTION_PROMISE_SECRET)?;
|
||||
@@ -70,7 +81,7 @@ async fn main() -> Result<(), anyhow::Error> {
|
||||
}
|
||||
};
|
||||
|
||||
let _: [u8; 32] = match decoded_bytes.try_into() {
|
||||
let secret: [u8; 32] = match decoded_bytes.try_into() {
|
||||
Ok(array) => array,
|
||||
Err(vec) => {
|
||||
return plugin
|
||||
@@ -81,16 +92,16 @@ async fn main() -> Result<(), anyhow::Error> {
|
||||
.await;
|
||||
}
|
||||
};
|
||||
|
||||
let cln_api_rpc = lsps2::handler::ClnApiRpc::new(rpc_path);
|
||||
let getinfo_handler = lsps2::handler::Lsps2GetInfoHandler::new(cln_api_rpc, secret);
|
||||
lsps_builder = lsps_builder.with_handler(
|
||||
Lsps2GetInfoRequest::METHOD.to_string(),
|
||||
Arc::new(getinfo_handler),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let lsps_builder = JsonRpcServer::builder().with_handler(
|
||||
Lsps0listProtocolsRequest::METHOD.to_string(),
|
||||
Arc::new(Lsps0ListProtocolsHandler {
|
||||
lsps2_enabled: plugin.option(&lsps2::OPTION_ENABLED)?,
|
||||
}),
|
||||
);
|
||||
|
||||
let lsps_service = lsps_builder.build();
|
||||
|
||||
let state = State { lsps_service };
|
||||
|
||||
45
tests/plugins/lsps2_policy.py
Executable file
45
tests/plugins/lsps2_policy.py
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
""" A simple implementation of a LSPS2 compatible policy plugin. It is the job
|
||||
of this plugin to deliver a fee options menu to the LSPS2 service plugin.
|
||||
"""
|
||||
|
||||
from pyln.client import Plugin
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
plugin = Plugin()
|
||||
|
||||
|
||||
@plugin.method("dev-lsps2-getpolicy")
|
||||
def lsps2_getpolicy(request):
|
||||
""" Returns an opening fee menu for the LSPS2 plugin.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Is ISO 8601 format "YYYY-MM-DDThh:mm:ss.uuuZ"
|
||||
valid_until = (now + timedelta(hours=1)).isoformat().replace('+00:00', 'Z')
|
||||
|
||||
return { "policy_opening_fee_params_menu": [
|
||||
{
|
||||
"min_fee_msat": "1000",
|
||||
"proportional": 1000,
|
||||
"valid_until": valid_until,
|
||||
"min_lifetime": 2000,
|
||||
"max_client_to_self_delay": 2016,
|
||||
"min_payment_size_msat": "1000",
|
||||
"max_payment_size_msat": "100000000",
|
||||
},
|
||||
{
|
||||
"min_fee_msat": "1092000",
|
||||
"proportional": 2400,
|
||||
"valid_until": valid_until,
|
||||
"min_lifetime": 1008,
|
||||
"max_client_to_self_delay": 2016,
|
||||
"min_payment_size_msat": "1000",
|
||||
"max_payment_size_msat": "1000000",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
plugin.run()
|
||||
@@ -30,6 +30,7 @@ def test_lsps0_listprotocols(node_factory):
|
||||
res = l1.rpc.lsps_listprotocols(lsp_id=l2.info['id'])
|
||||
assert res
|
||||
|
||||
|
||||
def test_lsps2_enabled(node_factory):
|
||||
l1, l2 = node_factory.get_nodes(2, opts=[
|
||||
{"dev-lsps-client-enabled": None},
|
||||
@@ -44,3 +45,22 @@ def test_lsps2_enabled(node_factory):
|
||||
|
||||
res = l1.rpc.lsps_listprotocols(lsp_id=l2.info['id'])
|
||||
assert res['protocols'] == [2]
|
||||
|
||||
|
||||
def test_lsps2_getinfo(node_factory):
|
||||
plugin = os.path.join(os.path.dirname(__file__), 'plugins/lsps2_policy.py')
|
||||
|
||||
l1, l2 = node_factory.get_nodes(2, opts=[
|
||||
{"dev-lsps-client-enabled": None},
|
||||
{
|
||||
"dev-lsps-service-enabled": None,
|
||||
"dev-lsps2-service-enabled": None,
|
||||
"dev-lsps2-promise-secret": "0" * 64,
|
||||
"plugin": plugin
|
||||
}
|
||||
])
|
||||
|
||||
node_factory.join_nodes([l1, l2], fundchannel=False)
|
||||
|
||||
res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id'])
|
||||
assert res["opening_fee_params_menu"]
|
||||
|
||||
Reference in New Issue
Block a user