Files
palladum-lightning/plugins/lsps-plugin/src/lsps2/handler.rs
Peter Neuroth 581eb3076f 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>
2025-11-13 10:58:49 +10:30

240 lines
8.1 KiB
Rust

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"));
}
}