Skip to content

Commit eb8be4f

Browse files
committed
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>
1 parent a33eff4 commit eb8be4f

File tree

6 files changed

+378
-10
lines changed

6 files changed

+378
-10
lines changed

plugins/lsps-plugin/src/client.rs

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ use cln_lsps::lsps0::{
44
self,
55
transport::{Bolt8Transport, CustomMessageHookManager, WithCustomMessageHookManager},
66
};
7+
use cln_lsps::lsps2::model::{Lsps2GetInfoRequest, Lsps2GetInfoResponse};
78
use cln_lsps::util;
89
use cln_lsps::LSP_FEATURE_BIT;
910
use cln_plugin::options;
1011
use cln_rpc::model::requests::ListpeersRequest;
1112
use cln_rpc::primitives::PublicKey;
1213
use cln_rpc::ClnRpc;
1314
use log::debug;
14-
use serde::Deserialize;
15+
use serde::{Deserialize, Serialize};
1516
use std::path::Path;
1617
use std::str::FromStr as _;
1718

@@ -45,6 +46,11 @@ async fn main() -> Result<(), anyhow::Error> {
4546
"list protocols supported by lsp",
4647
on_lsps_listprotocols,
4748
)
49+
.rpcmethod(
50+
"lsps-lsps2-getinfo",
51+
"Low-level command to request the opening fee menu of an LSP",
52+
on_lsps_lsps2_getinfo,
53+
)
4854
.configure()
4955
.await?
5056
{
@@ -61,7 +67,47 @@ async fn main() -> Result<(), anyhow::Error> {
6167
}
6268
}
6369

64-
/// RPC Method handler for `lsps-listprotocols`.
70+
/// Rpc Method handler for `lsps-lsps2-getinfo`.
71+
async fn on_lsps_lsps2_getinfo(
72+
p: cln_plugin::Plugin<State>,
73+
v: serde_json::Value,
74+
) -> Result<serde_json::Value, anyhow::Error> {
75+
let req: ClnRpcLsps2GetinfoRequest =
76+
serde_json::from_value(v).context("Failed to parse request JSON")?;
77+
debug!(
78+
"Requesting opening fee menu from lsp {} with token {:?}",
79+
req.lsp_id, req.token
80+
);
81+
82+
let dir = p.configuration().lightning_dir;
83+
let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file);
84+
let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?;
85+
86+
// Fail early: Check that we are connected to the peer and that it has the
87+
// LSP feature bit set.
88+
ensure_lsp_connected(&mut cln_client, &req.lsp_id).await?;
89+
90+
// Create Transport and Client
91+
let transport = Bolt8Transport::new(
92+
&req.lsp_id,
93+
rpc_path.clone(), // Clone path for potential reuse
94+
p.state().hook_manager.clone(),
95+
None, // Use default timeout
96+
)
97+
.context("Failed to create Bolt8Transport")?;
98+
let client = JsonRpcClient::new(transport);
99+
100+
// 1. Call lsps2.get_info.
101+
let info_req = Lsps2GetInfoRequest { token: req.token };
102+
let info_res: Lsps2GetInfoResponse = client
103+
.call_typed(info_req)
104+
.await
105+
.context("lsps2.get_info call failed")?;
106+
debug!("received lsps2.get_info response: {:?}", info_res);
107+
108+
Ok(serde_json::to_value(info_res)?)
109+
}
110+
65111
async fn on_lsps_listprotocols(
66112
p: cln_plugin::Plugin<State>,
67113
v: serde_json::Value,
@@ -141,3 +187,9 @@ async fn ensure_lsp_connected(cln_client: &mut ClnRpc, lsp_id: &str) -> Result<(
141187

142188
Ok(())
143189
}
190+
191+
#[derive(Debug, Clone, Serialize, Deserialize)]
192+
struct ClnRpcLsps2GetinfoRequest {
193+
lsp_id: String,
194+
token: Option<String>,
195+
}
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
use crate::{
2+
jsonrpc::{server::RequestHandler, JsonRpcResponse as _, RequestObject, RpcError},
3+
lsps2::model::{
4+
Lsps2GetInfoRequest, Lsps2GetInfoResponse, Lsps2PolicyGetInfoRequest,
5+
Lsps2PolicyGetInfoResponse, OpeningFeeParams, Promise,
6+
},
7+
util::unwrap_payload_with_peer_id,
8+
};
9+
use anyhow::{Context, Result as AnyResult};
10+
use async_trait::async_trait;
11+
use cln_rpc::ClnRpc;
12+
use std::path::PathBuf;
13+
14+
#[async_trait]
15+
pub trait ClnApi: Send + Sync {
16+
async fn lsps2_getpolicy(
17+
&self,
18+
params: &Lsps2PolicyGetInfoRequest,
19+
) -> AnyResult<Lsps2PolicyGetInfoResponse>;
20+
}
21+
22+
#[derive(Clone)]
23+
pub struct ClnApiRpc {
24+
rpc_path: PathBuf,
25+
}
26+
27+
impl ClnApiRpc {
28+
pub fn new(rpc_path: PathBuf) -> Self {
29+
Self { rpc_path }
30+
}
31+
32+
async fn create_rpc(&self) -> AnyResult<ClnRpc> {
33+
ClnRpc::new(&self.rpc_path).await
34+
}
35+
}
36+
37+
#[async_trait]
38+
impl ClnApi for ClnApiRpc {
39+
async fn lsps2_getpolicy(
40+
&self,
41+
params: &Lsps2PolicyGetInfoRequest,
42+
) -> AnyResult<Lsps2PolicyGetInfoResponse> {
43+
let mut rpc = self.create_rpc().await?;
44+
rpc.call_raw("dev-lsps2-getpolicy", params)
45+
.await
46+
.map_err(anyhow::Error::new)
47+
.with_context(|| "calling dev-lsps2-getpolicy")
48+
}
49+
}
50+
51+
/// Handler for the `lsps2.get_info` method.
52+
pub struct Lsps2GetInfoHandler<A: ClnApi> {
53+
pub api: A,
54+
pub promise_secret: [u8; 32],
55+
}
56+
57+
impl<A: ClnApi> Lsps2GetInfoHandler<A> {
58+
pub fn new(api: A, promise_secret: [u8; 32]) -> Self {
59+
Self {
60+
api,
61+
promise_secret,
62+
}
63+
}
64+
}
65+
66+
/// The RequestHandler calls the internal rpc command `dev-lsps2-getinfo`. It
67+
/// expects a plugin has registered this command and manages policies for the
68+
/// LSPS2 service.
69+
#[async_trait]
70+
impl<T: ClnApi + 'static> RequestHandler for Lsps2GetInfoHandler<T> {
71+
async fn handle(&self, payload: &[u8]) -> core::result::Result<Vec<u8>, RpcError> {
72+
let (payload, _) = unwrap_payload_with_peer_id(payload);
73+
74+
let req: RequestObject<Lsps2GetInfoRequest> = serde_json::from_slice(&payload)
75+
.map_err(|e| RpcError::parse_error(format!("failed to parse request: {e}")))?;
76+
77+
if req.id.is_none() {
78+
// Is a notification we can not reply so we just return
79+
return Ok(vec![]);
80+
}
81+
let params = req
82+
.params
83+
.ok_or(RpcError::invalid_params("expected params but was missing"))?;
84+
85+
let policy_params: Lsps2PolicyGetInfoRequest = params.into();
86+
let res_data: Lsps2PolicyGetInfoResponse = self
87+
.api
88+
.lsps2_getpolicy(&policy_params)
89+
.await
90+
.map_err(|e| RpcError {
91+
code: 200,
92+
message: format!("failed to fetch policy {e:#}"),
93+
data: None,
94+
})?;
95+
96+
let opening_fee_params_menu = res_data
97+
.policy_opening_fee_params_menu
98+
.iter()
99+
.map(|v| {
100+
let promise: Promise = v
101+
.get_hmac_hex(&self.promise_secret)
102+
.try_into()
103+
.map_err(|e| RpcError::internal_error(format!("invalid promise: {e}")))?;
104+
Ok(OpeningFeeParams {
105+
min_fee_msat: v.min_fee_msat,
106+
proportional: v.proportional,
107+
valid_until: v.valid_until,
108+
min_lifetime: v.min_lifetime,
109+
max_client_to_self_delay: v.max_client_to_self_delay,
110+
min_payment_size_msat: v.min_payment_size_msat,
111+
max_payment_size_msat: v.max_payment_size_msat,
112+
promise,
113+
})
114+
})
115+
.collect::<Result<Vec<_>, RpcError>>()?;
116+
117+
let res = Lsps2GetInfoResponse {
118+
opening_fee_params_menu,
119+
}
120+
.into_response(req.id.unwrap()); // We checked that we got an id before.
121+
122+
serde_json::to_vec(&res)
123+
.map_err(|e| RpcError::internal_error(format!("Failed to serialize response: {}", e)))
124+
}
125+
}
126+
127+
#[cfg(test)]
128+
mod tests {
129+
use std::sync::{Arc, Mutex};
130+
131+
use super::*;
132+
use crate::{
133+
jsonrpc::{JsonRpcRequest, ResponseObject},
134+
lsps0::primitives::{Msat, Ppm},
135+
lsps2::model::PolicyOpeningFeeParams,
136+
util::wrap_payload_with_peer_id,
137+
};
138+
use chrono::{TimeZone, Utc};
139+
use cln_rpc::primitives::PublicKey;
140+
use cln_rpc::RpcError as ClnRpcError;
141+
142+
const PUBKEY: [u8; 33] = [
143+
0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87,
144+
0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16,
145+
0xf8, 0x17, 0x98,
146+
];
147+
148+
fn create_peer_id() -> PublicKey {
149+
PublicKey::from_slice(&PUBKEY).expect("Valid pubkey")
150+
}
151+
152+
fn create_wrapped_request(request: &RequestObject<Lsps2GetInfoRequest>) -> Vec<u8> {
153+
let payload = serde_json::to_vec(request).expect("Failed to serialize request");
154+
wrap_payload_with_peer_id(&payload, create_peer_id())
155+
}
156+
157+
#[derive(Clone, Default)]
158+
struct FakeCln {
159+
lsps2_getpolicy_response: Arc<Mutex<Option<Lsps2PolicyGetInfoResponse>>>,
160+
lsps2_getpolicy_error: Arc<Mutex<Option<ClnRpcError>>>,
161+
}
162+
163+
#[async_trait]
164+
impl ClnApi for FakeCln {
165+
async fn lsps2_getpolicy(
166+
&self,
167+
_params: &Lsps2PolicyGetInfoRequest,
168+
) -> Result<Lsps2PolicyGetInfoResponse, anyhow::Error> {
169+
if let Some(err) = self.lsps2_getpolicy_error.lock().unwrap().take() {
170+
return Err(anyhow::Error::new(err).context("from fake api"));
171+
};
172+
if let Some(res) = self.lsps2_getpolicy_response.lock().unwrap().take() {
173+
return Ok(res);
174+
};
175+
panic!("No lsps2 response defined");
176+
}
177+
}
178+
179+
#[tokio::test]
180+
async fn test_successful_get_info() {
181+
let promise_secret = [0u8; 32];
182+
let params = Lsps2PolicyGetInfoResponse {
183+
policy_opening_fee_params_menu: vec![PolicyOpeningFeeParams {
184+
min_fee_msat: Msat(2000),
185+
proportional: Ppm(10000),
186+
valid_until: Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(),
187+
min_lifetime: 1000,
188+
max_client_to_self_delay: 42,
189+
min_payment_size_msat: Msat(1000000),
190+
max_payment_size_msat: Msat(100000000),
191+
}],
192+
};
193+
let promise = params.policy_opening_fee_params_menu[0].get_hmac_hex(&promise_secret);
194+
let fake = FakeCln::default();
195+
*fake.lsps2_getpolicy_response.lock().unwrap() = Some(params);
196+
let handler = Lsps2GetInfoHandler::new(fake, promise_secret);
197+
198+
let request = Lsps2GetInfoRequest { token: None }.into_request(Some("test-id".to_string()));
199+
let payload = create_wrapped_request(&request);
200+
201+
let result = handler.handle(&payload).await.unwrap();
202+
let response: ResponseObject<Lsps2GetInfoResponse> =
203+
serde_json::from_slice(&result).unwrap();
204+
let response = response.into_inner().unwrap();
205+
206+
assert_eq!(
207+
response.opening_fee_params_menu[0].min_payment_size_msat,
208+
Msat(1000000)
209+
);
210+
assert_eq!(
211+
response.opening_fee_params_menu[0].max_payment_size_msat,
212+
Msat(100000000)
213+
);
214+
assert_eq!(
215+
response.opening_fee_params_menu[0].promise,
216+
promise.try_into().unwrap()
217+
);
218+
}
219+
220+
#[tokio::test]
221+
async fn test_get_info_rpc_error_handling() {
222+
let fake = FakeCln::default();
223+
*fake.lsps2_getpolicy_error.lock().unwrap() = Some(ClnRpcError {
224+
code: Some(-1),
225+
message: "not found".to_string(),
226+
data: None,
227+
});
228+
let handler = Lsps2GetInfoHandler::new(fake, [0; 32]);
229+
let request = Lsps2GetInfoRequest { token: None }.into_request(Some("test-id".to_string()));
230+
let payload = create_wrapped_request(&request);
231+
232+
let result = handler.handle(&payload).await;
233+
234+
assert!(result.is_err());
235+
let error = result.unwrap_err();
236+
assert_eq!(error.code, 200);
237+
assert!(error.message.contains("failed to fetch policy"));
238+
}
239+
}

plugins/lsps-plugin/src/lsps2/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use cln_plugin::options;
22

3+
pub mod handler;
34
pub mod model;
45

56
pub const OPTION_ENABLED: options::FlagConfigOption = options::ConfigOption::new_flag(

plugins/lsps-plugin/src/service.rs

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use cln_lsps::jsonrpc::{server::JsonRpcServer, JsonRpcRequest};
66
use cln_lsps::lsps0::handler::Lsps0ListProtocolsHandler;
77
use cln_lsps::lsps0::model::Lsps0listProtocolsRequest;
88
use cln_lsps::lsps0::transport::{self, CustomMsg};
9+
use cln_lsps::lsps2::model::Lsps2GetInfoRequest;
910
use cln_lsps::util::wrap_payload_with_peer_id;
1011
use cln_lsps::{lsps0, lsps2, util, LSP_FEATURE_BIT};
1112
use cln_plugin::options::ConfigOption;
@@ -46,12 +47,22 @@ async fn main() -> Result<(), anyhow::Error> {
4647
.configure()
4748
.await?
4849
{
50+
let rpc_path =
51+
Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file);
52+
4953
if !plugin.option(&OPTION_ENABLED)? {
5054
return plugin
5155
.disable(&format!("`{}` not enabled", OPTION_ENABLED.name))
5256
.await;
5357
}
5458

59+
let mut lsps_builder = JsonRpcServer::builder().with_handler(
60+
Lsps0listProtocolsRequest::METHOD.to_string(),
61+
Arc::new(Lsps0ListProtocolsHandler {
62+
lsps2_enabled: plugin.option(&lsps2::OPTION_ENABLED)?,
63+
}),
64+
);
65+
5566
if plugin.option(&lsps2::OPTION_ENABLED)? {
5667
log::debug!("lsps2 enabled");
5768
let secret_hex = plugin.option(&lsps2::OPTION_PROMISE_SECRET)?;
@@ -70,7 +81,7 @@ async fn main() -> Result<(), anyhow::Error> {
7081
}
7182
};
7283

73-
let _: [u8; 32] = match decoded_bytes.try_into() {
84+
let secret: [u8; 32] = match decoded_bytes.try_into() {
7485
Ok(array) => array,
7586
Err(vec) => {
7687
return plugin
@@ -81,16 +92,16 @@ async fn main() -> Result<(), anyhow::Error> {
8192
.await;
8293
}
8394
};
95+
96+
let cln_api_rpc = lsps2::handler::ClnApiRpc::new(rpc_path);
97+
let getinfo_handler = lsps2::handler::Lsps2GetInfoHandler::new(cln_api_rpc, secret);
98+
lsps_builder = lsps_builder.with_handler(
99+
Lsps2GetInfoRequest::METHOD.to_string(),
100+
Arc::new(getinfo_handler),
101+
);
84102
}
85103
}
86104

87-
let lsps_builder = JsonRpcServer::builder().with_handler(
88-
Lsps0listProtocolsRequest::METHOD.to_string(),
89-
Arc::new(Lsps0ListProtocolsHandler {
90-
lsps2_enabled: plugin.option(&lsps2::OPTION_ENABLED)?,
91-
}),
92-
);
93-
94105
let lsps_service = lsps_builder.build();
95106

96107
let state = State { lsps_service };

0 commit comments

Comments
 (0)