lsp_plugin: add lsps2_buy request and handler

Adds the lsps2.buy request to the client and the lsps2.buy handler to
the LSP service.

Signed-off-by: Peter Neuroth <pet.v.ne@gmail.com>
This commit is contained in:
Peter Neuroth
2025-09-21 23:24:13 +02:00
committed by Rusty Russell
parent 581eb3076f
commit ecb1f4c7e7
5 changed files with 551 additions and 15 deletions

View File

@@ -1,17 +1,21 @@
use anyhow::{anyhow, Context};
use cln_lsps::jsonrpc::client::JsonRpcClient;
use cln_lsps::lsps0::primitives::Msat;
use cln_lsps::lsps0::{
self,
transport::{Bolt8Transport, CustomMessageHookManager, WithCustomMessageHookManager},
};
use cln_lsps::lsps2::model::{Lsps2GetInfoRequest, Lsps2GetInfoResponse};
use cln_lsps::lsps2::model::{
compute_opening_fee, Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest,
Lsps2GetInfoResponse, OpeningFeeParams,
};
use cln_lsps::util;
use cln_lsps::LSP_FEATURE_BIT;
use cln_plugin::options;
use cln_rpc::model::requests::ListpeersRequest;
use cln_rpc::primitives::PublicKey;
use cln_rpc::primitives::{AmountOrAny, PublicKey};
use cln_rpc::ClnRpc;
use log::debug;
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::str::FromStr as _;
@@ -51,6 +55,11 @@ async fn main() -> Result<(), anyhow::Error> {
"Low-level command to request the opening fee menu of an LSP",
on_lsps_lsps2_getinfo,
)
.rpcmethod(
"lsps-lsps2-buy",
"Low-level command to return the lsps2.buy result from an ",
on_lsps_lsps2_buy,
)
.configure()
.await?
{
@@ -108,6 +117,103 @@ async fn on_lsps_lsps2_getinfo(
Ok(serde_json::to_value(info_res)?)
}
/// Rpc Method handler for `lsps-lsps2-buy`.
async fn on_lsps_lsps2_buy(
p: cln_plugin::Plugin<State>,
v: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
let req: ClnRpcLsps2BuyRequest =
serde_json::from_value(v).context("Failed to parse request JSON")?;
debug!(
"Asking for a channel from lsp {} with opening fee params {:?} and payment size {:?}",
req.lsp_id, req.opening_fee_params, req.payment_size_msat
);
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);
// Convert from AmountOrAny to Msat.
let payment_size_msat = if let Some(payment_size) = req.payment_size_msat {
match payment_size {
AmountOrAny::Amount(amount) => Some(Msat::from_msat(amount.msat())),
AmountOrAny::Any => None,
}
} else {
None
};
let selected_params = req.opening_fee_params;
if let Some(payment_size) = payment_size_msat {
if payment_size < selected_params.min_payment_size_msat {
return Err(anyhow!(
"Requested payment size {}msat is below minimum {}msat required by LSP",
payment_size,
selected_params.min_payment_size_msat
));
}
if payment_size > selected_params.max_payment_size_msat {
return Err(anyhow!(
"Requested payment size {}msat is above maximum {}msat allowed by LSP",
payment_size,
selected_params.max_payment_size_msat
));
}
let opening_fee = compute_opening_fee(
payment_size.msat(),
selected_params.min_fee_msat.msat(),
selected_params.proportional.ppm() as u64,
)
.ok_or_else(|| {
warn!(
"Opening fee calculation overflowed for payment size {}",
payment_size
);
anyhow!("failed to calculate opening fee")
})?;
info!(
"Calculated opening fee: {}msat for payment size {}msat",
opening_fee, payment_size
);
} else {
info!("No payment size specified, requesting JIT channel for a variable-amount invoice.");
// Check if the selected params allow for variable amount (implicitly they do if max > min)
if selected_params.min_payment_size_msat >= selected_params.max_payment_size_msat {
// This shouldn't happen if LSP follows spec, but good to check.
warn!("Selected fee params seem unsuitable for variable amount: min >= max");
}
}
debug!("Calling lsps2.buy for peer {}", req.lsp_id);
let buy_req = Lsps2BuyRequest {
opening_fee_params: selected_params, // Pass the chosen params back
payment_size_msat,
};
let buy_res: Lsps2BuyResponse = client
.call_typed(buy_req)
.await
.context("lsps2.buy call failed")?;
Ok(serde_json::to_value(buy_res)?)
}
async fn on_lsps_listprotocols(
p: cln_plugin::Plugin<State>,
v: serde_json::Value,
@@ -188,6 +294,14 @@ async fn ensure_lsp_connected(cln_client: &mut ClnRpc, lsp_id: &str) -> Result<(
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClnRpcLsps2BuyRequest {
lsp_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
payment_size_msat: Option<AmountOrAny>,
opening_fee_params: OpeningFeeParams,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClnRpcLsps2GetinfoRequest {
lsp_id: String,

View File

@@ -1,14 +1,27 @@
use crate::{
jsonrpc::{server::RequestHandler, JsonRpcResponse as _, RequestObject, RpcError},
lsps2::model::{
Lsps2GetInfoRequest, Lsps2GetInfoResponse, Lsps2PolicyGetInfoRequest,
Lsps2PolicyGetInfoResponse, OpeningFeeParams, Promise,
lsps0::primitives::ShortChannelId,
lsps2::{
model::{
DatastoreEntry, Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest,
Lsps2GetInfoResponse, Lsps2PolicyGetInfoRequest, Lsps2PolicyGetInfoResponse,
OpeningFeeParams, Promise,
},
DS_MAIN_KEY, DS_SUB_KEY,
},
util::unwrap_payload_with_peer_id,
};
use anyhow::{Context, Result as AnyResult};
use async_trait::async_trait;
use cln_rpc::ClnRpc;
use cln_rpc::{
model::{
requests::{DatastoreMode, DatastoreRequest, GetinfoRequest},
responses::{DatastoreResponse, GetinfoResponse},
},
ClnRpc,
};
use log::warn;
use rand::{rng, Rng as _};
use std::path::PathBuf;
#[async_trait]
@@ -17,8 +30,14 @@ pub trait ClnApi: Send + Sync {
&self,
params: &Lsps2PolicyGetInfoRequest,
) -> AnyResult<Lsps2PolicyGetInfoResponse>;
async fn cln_getinfo(&self, params: &GetinfoRequest) -> AnyResult<GetinfoResponse>;
async fn cln_datastore(&self, params: &DatastoreRequest) -> AnyResult<DatastoreResponse>;
}
const DEFAULT_CLTV_EXPIRY_DELTA: u32 = 144;
#[derive(Clone)]
pub struct ClnApiRpc {
rpc_path: PathBuf,
@@ -46,6 +65,22 @@ impl ClnApi for ClnApiRpc {
.map_err(anyhow::Error::new)
.with_context(|| "calling dev-lsps2-getpolicy")
}
async fn cln_getinfo(&self, params: &GetinfoRequest) -> AnyResult<GetinfoResponse> {
let mut rpc = self.create_rpc().await?;
rpc.call_typed(params)
.await
.map_err(anyhow::Error::new)
.with_context(|| "calling getinfo")
}
async fn cln_datastore(&self, params: &DatastoreRequest) -> AnyResult<DatastoreResponse> {
let mut rpc = self.create_rpc().await?;
rpc.call_typed(params)
.await
.map_err(anyhow::Error::new)
.with_context(|| "calling datastore")
}
}
/// Handler for the `lsps2.get_info` method.
@@ -124,6 +159,106 @@ impl<T: ClnApi + 'static> RequestHandler for Lsps2GetInfoHandler<T> {
}
}
pub struct Lsps2BuyHandler<A: ClnApi> {
pub api: A,
pub promise_secret: [u8; 32],
}
impl<A: ClnApi> Lsps2BuyHandler<A> {
pub fn new(api: A, promise_secret: [u8; 32]) -> Self {
Self {
api,
promise_secret,
}
}
}
#[async_trait]
impl<A: ClnApi + 'static> RequestHandler for Lsps2BuyHandler<A> {
async fn handle(&self, payload: &[u8]) -> core::result::Result<Vec<u8>, RpcError> {
let (payload, peer_id) = unwrap_payload_with_peer_id(payload);
let req: RequestObject<Lsps2BuyRequest> = 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 req_params = req
.params
.ok_or_else(|| RpcError::invalid_request("Missing params field"))?;
let fee_params = req_params.opening_fee_params;
// FIXME: In the future we should replace the \`None\` with a meaningful
// value that reflects the inbound capacity for this node from the
// public network for a better pre-condition check on the payment_size.
fee_params.validate(&self.promise_secret, req_params.payment_size_msat, None)?;
// Generate a tmp scid to identify jit channel request in htlc.
let get_info_req = GetinfoRequest {};
let info = self.api.cln_getinfo(&get_info_req).await.map_err(|e| {
warn!("Failed to call getinfo via rpc {}", e);
RpcError::internal_error("Internal error")
})?;
// FIXME: Future task: Check that we don't conflict with any jit scid we
// already handed out -> Check datastore entries.
let jit_scid_u64 = generate_jit_scid(info.blockheight);
let jit_scid = ShortChannelId::from(jit_scid_u64);
let ds_data = DatastoreEntry {
peer_id,
opening_fee_params: fee_params,
expected_payment_size: req_params.payment_size_msat,
};
let ds_json = serde_json::to_string(&ds_data).map_err(|e| {
warn!("Failed to serialize opening fee params to string {}", e);
RpcError::internal_error("Internal error")
})?;
let ds_req = DatastoreRequest {
generation: None,
hex: None,
mode: Some(DatastoreMode::MUST_CREATE),
string: Some(ds_json),
key: vec![
DS_MAIN_KEY.to_string(),
DS_SUB_KEY.to_string(),
jit_scid.to_string(),
],
};
let _ds_res = self.api.cln_datastore(&ds_req).await.map_err(|e| {
warn!("Failed to store jit request in ds via rpc {}", e);
RpcError::internal_error("Internal error")
})?;
let res = Lsps2BuyResponse {
jit_channel_scid: jit_scid,
// We can make this configurable if necessary.
lsp_cltv_expiry_delta: DEFAULT_CLTV_EXPIRY_DELTA,
// We can implement the other mode later on as we might have to do
// some additional work on core-lightning to enable this.
client_trusts_lsp: false,
}
.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)))
}
}
fn generate_jit_scid(best_blockheigt: u32) -> u64 {
let mut rng = rng();
let block = best_blockheigt + 6; // Approx 1 hour in the future and should avoid collision with confirmed channels
let tx_idx: u32 = rng.random_range(0..5000);
let output_idx: u16 = rng.random_range(0..10);
((block as u64) << 40) | ((tx_idx as u64) << 16) | (output_idx as u64)
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
@@ -136,8 +271,9 @@ mod tests {
util::wrap_payload_with_peer_id,
};
use chrono::{TimeZone, Utc};
use cln_rpc::primitives::PublicKey;
use cln_rpc::primitives::{Amount, PublicKey};
use cln_rpc::RpcError as ClnRpcError;
use serde::Serialize;
const PUBKEY: [u8; 33] = [
0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87,
@@ -149,15 +285,45 @@ mod tests {
PublicKey::from_slice(&PUBKEY).expect("Valid pubkey")
}
fn create_wrapped_request(request: &RequestObject<Lsps2GetInfoRequest>) -> Vec<u8> {
fn create_wrapped_request<T: Serialize>(request: &RequestObject<T>) -> Vec<u8> {
let payload = serde_json::to_vec(request).expect("Failed to serialize request");
wrap_payload_with_peer_id(&payload, create_peer_id())
}
/// Build a pair: policy params + buy params with a Promise derived from `secret`
fn params_with_promise(secret: &[u8; 32]) -> (PolicyOpeningFeeParams, OpeningFeeParams) {
let policy = PolicyOpeningFeeParams {
min_fee_msat: Msat(2_000),
proportional: Ppm(10_000),
valid_until: Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap(),
min_lifetime: 1000,
max_client_to_self_delay: 42,
min_payment_size_msat: Msat(1_000_000),
max_payment_size_msat: Msat(100_000_000),
};
let hex = policy.get_hmac_hex(secret);
let promise: Promise = hex.try_into().expect("hex->Promise");
let buy = OpeningFeeParams {
min_fee_msat: policy.min_fee_msat,
proportional: policy.proportional,
valid_until: policy.valid_until,
min_lifetime: policy.min_lifetime,
max_client_to_self_delay: policy.max_client_to_self_delay,
min_payment_size_msat: policy.min_payment_size_msat,
max_payment_size_msat: policy.max_payment_size_msat,
promise,
};
(policy, buy)
}
#[derive(Clone, Default)]
struct FakeCln {
lsps2_getpolicy_response: Arc<Mutex<Option<Lsps2PolicyGetInfoResponse>>>,
lsps2_getpolicy_error: Arc<Mutex<Option<ClnRpcError>>>,
cln_getinfo_response: Arc<Mutex<Option<GetinfoResponse>>>,
cln_getinfo_error: Arc<Mutex<Option<ClnRpcError>>>,
cln_datastore_response: Arc<Mutex<Option<DatastoreResponse>>>,
cln_datastore_error: Arc<Mutex<Option<ClnRpcError>>>,
}
#[async_trait]
@@ -174,6 +340,54 @@ mod tests {
};
panic!("No lsps2 response defined");
}
async fn cln_getinfo(
&self,
_params: &GetinfoRequest,
) -> Result<GetinfoResponse, anyhow::Error> {
if let Some(err) = self.cln_getinfo_error.lock().unwrap().take() {
return Err(anyhow::Error::new(err).context("from fake api"));
};
if let Some(res) = self.cln_getinfo_response.lock().unwrap().take() {
return Ok(res);
};
panic!("No cln getinfo response defined");
}
async fn cln_datastore(
&self,
_params: &DatastoreRequest,
) -> Result<DatastoreResponse, anyhow::Error> {
if let Some(err) = self.cln_datastore_error.lock().unwrap().take() {
return Err(anyhow::Error::new(err).context("from fake api"));
};
if let Some(res) = self.cln_datastore_response.lock().unwrap().take() {
return Ok(res);
};
panic!("No cln datastore response defined");
}
}
fn minimal_getinfo(height: u32) -> GetinfoResponse {
GetinfoResponse {
lightning_dir: String::default(),
alias: None,
our_features: None,
warning_bitcoind_sync: None,
warning_lightningd_sync: None,
address: None,
binding: None,
blockheight: height,
color: String::default(),
fees_collected_msat: Amount::from_msat(0),
id: PublicKey::from_slice(&PUBKEY).expect("pubkey from slice"),
network: String::default(),
num_active_channels: u32::default(),
num_inactive_channels: u32::default(),
num_peers: u32::default(),
num_pending_channels: u32::default(),
version: String::default(),
}
}
#[tokio::test]
@@ -236,4 +450,181 @@ mod tests {
assert_eq!(error.code, 200);
assert!(error.message.contains("failed to fetch policy"));
}
#[tokio::test]
async fn buy_ok_fixed_amount() {
let secret = [0u8; 32];
let fake = FakeCln::default();
*fake.cln_getinfo_response.lock().unwrap() = Some(minimal_getinfo(900_000));
*fake.cln_datastore_response.lock().unwrap() = Some(DatastoreResponse {
generation: Some(0),
hex: None,
string: None,
key: vec![],
});
let handler = Lsps2BuyHandler::new(fake, secret);
let (_policy, buy) = params_with_promise(&secret);
// Set payment_size_msat => "MPP+fixed-invoice" mode.
let req = Lsps2BuyRequest {
opening_fee_params: buy,
payment_size_msat: Some(Msat(2_000_000)),
}
.into_request(Some("ok-fixed".into()));
let payload = create_wrapped_request(&req);
let out = handler.handle(&payload).await.unwrap();
let resp: ResponseObject<Lsps2BuyResponse> = serde_json::from_slice(&out).unwrap();
let resp = resp.into_inner().unwrap();
assert_eq!(resp.lsp_cltv_expiry_delta, DEFAULT_CLTV_EXPIRY_DELTA);
assert!(!resp.client_trusts_lsp);
assert!(resp.jit_channel_scid.to_u64() > 0);
}
#[tokio::test]
async fn buy_ok_variable_amount_no_payment_size() {
let secret = [2u8; 32];
let fake = FakeCln::default();
*fake.cln_getinfo_response.lock().unwrap() = Some(minimal_getinfo(900_100));
*fake.cln_datastore_response.lock().unwrap() = Some(DatastoreResponse {
generation: Some(0),
hex: None,
string: None,
key: vec![],
});
let handler = Lsps2BuyHandler::new(fake, secret);
let (_policy, buy) = params_with_promise(&secret);
// No payment_size_msat => "no-MPP+var-invoice" mode.
let req = Lsps2BuyRequest {
opening_fee_params: buy,
payment_size_msat: None,
}
.into_request(Some("ok-var".into()));
let payload = create_wrapped_request(&req);
let out = handler.handle(&payload).await.unwrap();
let resp: ResponseObject<Lsps2BuyResponse> = serde_json::from_slice(&out).unwrap();
assert!(resp.into_inner().is_ok());
}
#[tokio::test]
async fn buy_rejects_invalid_promise_or_past_valid_until_with_201() {
let secret = [3u8; 32];
let handler = Lsps2BuyHandler::new(FakeCln::default(), secret);
// Case A: wrong promise (derive with different secret)
let (_policy_wrong, mut buy_wrong) = params_with_promise(&[9u8; 32]);
buy_wrong.valid_until = Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap(); // future, so only promise is wrong
let req_wrong = Lsps2BuyRequest {
opening_fee_params: buy_wrong,
payment_size_msat: Some(Msat(2_000_000)),
}
.into_request(Some("bad-promise".into()));
let err1 = handler
.handle(&create_wrapped_request(&req_wrong))
.await
.unwrap_err();
assert_eq!(err1.code, 201);
// Case B: past valid_until
let (_policy, mut buy_past) = params_with_promise(&secret);
buy_past.valid_until = Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(); // past
let req_past = Lsps2BuyRequest {
opening_fee_params: buy_past,
payment_size_msat: Some(Msat(2_000_000)),
}
.into_request(Some("past-valid".into()));
let err2 = handler
.handle(&create_wrapped_request(&req_past))
.await
.unwrap_err();
assert_eq!(err2.code, 201);
}
#[tokio::test]
async fn buy_rejects_when_opening_fee_ge_payment_size_with_202() {
let secret = [4u8; 32];
let handler = Lsps2BuyHandler::new(FakeCln::default(), secret);
// Make min_fee already >= payment_size to trigger 202
let policy = PolicyOpeningFeeParams {
min_fee_msat: Msat(10_000),
proportional: Ppm(0), // no extra percentage
valid_until: Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap(),
min_lifetime: 1000,
max_client_to_self_delay: 42,
min_payment_size_msat: Msat(1),
max_payment_size_msat: Msat(u64::MAX / 2),
};
let hex = policy.get_hmac_hex(&secret);
let promise: Promise = hex.try_into().unwrap();
let buy = OpeningFeeParams {
min_fee_msat: policy.min_fee_msat,
proportional: policy.proportional,
valid_until: policy.valid_until,
min_lifetime: policy.min_lifetime,
max_client_to_self_delay: policy.max_client_to_self_delay,
min_payment_size_msat: policy.min_payment_size_msat,
max_payment_size_msat: policy.max_payment_size_msat,
promise,
};
let req = Lsps2BuyRequest {
opening_fee_params: buy,
payment_size_msat: Some(Msat(9_999)), // strictly less than min_fee => opening_fee >= payment_size
}
.into_request(Some("too-small".into()));
let err = handler
.handle(&create_wrapped_request(&req))
.await
.unwrap_err();
assert_eq!(err.code, 202);
}
#[tokio::test]
async fn buy_rejects_on_fee_overflow_with_203() {
let secret = [5u8; 32];
let handler = Lsps2BuyHandler::new(FakeCln::default(), secret);
// Choose values likely to overflow if multiplication isn't checked:
// opening_fee = min_fee + payment_size * proportional / 1_000_000
let policy = PolicyOpeningFeeParams {
min_fee_msat: Msat(u64::MAX / 2),
proportional: Ppm(u32::MAX), // 4_294_967_295 ppm
valid_until: Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap(),
min_lifetime: 1000,
max_client_to_self_delay: 42,
min_payment_size_msat: Msat(1),
max_payment_size_msat: Msat(u64::MAX),
};
let hex = policy.get_hmac_hex(&secret);
let promise: Promise = hex.try_into().unwrap();
let buy = OpeningFeeParams {
min_fee_msat: policy.min_fee_msat,
proportional: policy.proportional,
valid_until: policy.valid_until,
min_lifetime: policy.min_lifetime,
max_client_to_self_delay: policy.max_client_to_self_delay,
min_payment_size_msat: policy.min_payment_size_msat,
max_payment_size_msat: policy.max_payment_size_msat,
promise,
};
let req = Lsps2BuyRequest {
opening_fee_params: buy,
payment_size_msat: Some(Msat(u64::MAX / 2)),
}
.into_request(Some("overflow".into()));
let err = handler
.handle(&create_wrapped_request(&req))
.await
.unwrap_err();
assert_eq!(err.code, 203);
}
}

View File

@@ -13,3 +13,6 @@ pub const OPTION_PROMISE_SECRET: options::StringConfigOption =
"dev-lsps2-promise-secret",
"A 64-character hex string that is the secret for promises",
);
pub const DS_MAIN_KEY: &'static str = "lsps";
pub const DS_SUB_KEY: &'static str = "lsps2";

View File

@@ -6,7 +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::lsps2::model::{Lsps2BuyRequest, Lsps2GetInfoRequest};
use cln_lsps::util::wrap_payload_with_peer_id;
use cln_lsps::{lsps0, lsps2, util, LSP_FEATURE_BIT};
use cln_plugin::options::ConfigOption;
@@ -94,11 +94,15 @@ async fn main() -> Result<(), anyhow::Error> {
};
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 getinfo_handler =
lsps2::handler::Lsps2GetInfoHandler::new(cln_api_rpc.clone(), secret);
let buy_handler = lsps2::handler::Lsps2BuyHandler::new(cln_api_rpc, secret);
lsps_builder = lsps_builder
.with_handler(
Lsps2GetInfoRequest::METHOD.to_string(),
Arc::new(getinfo_handler),
)
.with_handler(Lsps2BuyRequest::METHOD.to_string(), Arc::new(buy_handler));
}
}

View File

@@ -64,3 +64,27 @@ def test_lsps2_getinfo(node_factory):
res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id'])
assert res["opening_fee_params_menu"]
def test_lsps2_buy(node_factory):
# We need a policy service to fetch from.
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
}
])
# We don't need a channel to query for lsps services
node_factory.join_nodes([l1, l2], fundchannel=False)
res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id'])
params = res["opening_fee_params_menu"][0]
res = l1.rpc.lsps_lsps2_buy(lsp_id=l2.info['id'], payment_size_msat=None, opening_fee_params=params)
assert res