lsp_plugin: add lsps2_buy request and handler
What changed, and why it matters
This commit adds a new feature to the experimental LSPS (Lightning Service Provider Specification) plugin, allowing a client to request a 'just-in-time' channel purchase from a service provider and the service to handle that request. It is a feature addition to a development plugin, not a fix for a known security bug. The code includes validation of fee parameters and basic bounds checks, but because it is new and touches payment-related logic, it introduces some risk of implementation flaws.
Treat this as a routine feature addition requiring normal review. Reviewers should focus on the fee calculation overflow handling, promise validation correctness, datastore key collision risks, and whether the generated temporary SCID could conflict with real on-chain SCIDs or previously issued JIT SCIDs. Since the plugin is experimental/dev-flagged, no urgent security response is indicated by the diff alone.
Security signals we found
New RPC surface added to an experimental plugin (lsps-lsps2-buy)
Client-side payment-size bounds check before sending request
Client-side opening-fee calculation with overflow handling via compute_opening_fee
Service-side HMAC/promise validation via fee_params.validate
Service-side datastore persistence of peer_id, fee params, and expected payment size
Temporary SCID generation uses current blockheight + 6 and random tx/output indices
No explicit duplicate-SCID or datastore collision check (marked FIXME)
No explicit rate-limiting or anti-spam controls visible in the diff
Default client_trusts_lsp set to false, reducing trust assumptions
Evidence from the diff
The commit implements the lsps2.buy JSON-RPC method on both client and service sides of the lsps-plugin. The client validates that the requested payment size is within the LSP’s advertised min/max, computes the opening fee, and sends a typed request over the BOLT8 transport. The service handler validates the opening_fee_params HMAC/promise and expiry, generates a temporary short-channel-id, persists the request in the CLN datastore, and returns a jit_channel_scid with a default CLTV expiry. Unit tests cover happy paths and several validation failure modes (invalid promise, expired params, fee >= payment size, fee overflow).
Changed components
plugins/lsps-plugin/src/client.rsplugins/lsps-plugin/src/lsps2/handler.rsplugins/lsps-plugin/src/lsps2/mod.rsplugins/lsps-plugin/src/service.rstests/test_cln_lsps.pyInspect captured patch +551 / −15
diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs
index b861ca54..99bae347 100644
--- a/plugins/lsps-plugin/src/client.rs
+++ b/plugins/lsps-plugin/src/client.rs
@@ -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,
diff --git a/plugins/lsps-plugin/src/lsps2/handler.rs b/plugins/lsps-plugin/src/lsps2/handler.rs
index 2f9162ad..a2858795 100644
--- a/plugins/lsps-plugin/src/lsps2/handler.rs
+++ b/plugins/lsps-plugin/src/lsps2/handler.rs
@@ -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);
+ }
}
diff --git a/plugins/lsps-plugin/src/lsps2/mod.rs b/plugins/lsps-plugin/src/lsps2/mod.rs
index b217f987..7ed8e744 100644
--- a/plugins/lsps-plugin/src/lsps2/mod.rs
+++ b/plugins/lsps-plugin/src/lsps2/mod.rs
@@ -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";
diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs
index 11905dbf..ca620f9e 100644
--- a/plugins/lsps-plugin/src/service.rs
+++ b/plugins/lsps-plugin/src/service.rs
@@ -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));
}
}
diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py
index 53dcf386..1d1a1886 100644
--- a/tests/test_cln_lsps.py
+++ b/tests/test_cln_lsps.py
@@ -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
Why this scored 23/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.