What changed, and why it matters
This commit adds new data models and configuration options for an experimental LSPS2 (Lightning Service Provider Specification 2) feature in Core Lightning's LSP plugin. It is a feature-implementation patch, not a security fix. There is no indication in the commit or supplied references that it addresses a vulnerability, incident, or security disclosure.
No security action required. Review as normal feature code; if deploying LSPS2, ensure the promise secret is generated and stored securely.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch introduces Rust modules and types for LSPS2 JIT-channel opening fee parameters, promise HMAC validation, and plugin options (dev-lsps2-service-enabled, dev-lsps2-promise-secret). It wires those options into the plugin startup, validates the promise secret as a 32-byte hex value, and updates lsps0_listprotocols to advertise protocol 2 when enabled. The change also bumps the bitcoin crate from 0.32.6 to 0.32.7 and adds bitcoin = "0.31" to the lsps-plugin dependencies. No security-relevant bug fixes, bounds checks beyond normal input validation, or incident references are present in the diff.
Changed components
plugins/lsps-plugin/src/lsps2/model.rsplugins/lsps-plugin/src/lsps2/mod.rsplugins/lsps-plugin/src/service.rsplugins/lsps-plugin/Cargo.tomltests/test_cln_lsps.pyInspect captured patch +718 / −10
diff --git a/Cargo.lock b/Cargo.lock
index 13a991ad..1f87d148 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -334,9 +334,9 @@ dependencies = [
[[package]]
name = "bitcoin"
-version = "0.32.6"
+version = "0.32.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ad8929a18b8e33ea6b3c09297b687baaa71fb1b97353243a3f1029fad5c59c5b"
+checksum = "0fda569d741b895131a88ee5589a467e73e9c4718e958ac9308e4f7dc44b6945"
dependencies = [
"base58ck",
"bech32 0.11.0",
@@ -376,7 +376,7 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f00d509810205bfef492f1d6cefe1e2ac35b5e66675d51642315ddc5cee0e78"
dependencies = [
- "bitcoin 0.32.6",
+ "bitcoin 0.32.7",
"dnssec-prover",
"getrandom 0.3.3",
"lightning",
@@ -482,6 +482,7 @@ dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
+ "serde",
"wasm-bindgen",
"windows-link",
]
@@ -491,7 +492,7 @@ name = "cln-bip353"
version = "0.1.0"
dependencies = [
"anyhow",
- "bitcoin 0.32.6",
+ "bitcoin 0.32.7",
"bitcoin-payment-instructions",
"bytes",
"cln-plugin",
@@ -546,6 +547,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
+ "bitcoin 0.31.2",
"chrono",
"cln-plugin",
"cln-rpc",
@@ -1513,7 +1515,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e540fcb289a76826c9c0b078d3dd1f05691972c5a53fb4d3120540862040a147"
dependencies = [
"bech32 0.11.0",
- "bitcoin 0.32.6",
+ "bitcoin 0.32.7",
"dnssec-prover",
"hashbrown 0.13.2",
"libm",
@@ -1529,7 +1531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11209f386879b97198b2bfc9e9c1e5d42870825c6bd4376f17f95357244d6600"
dependencies = [
"bech32 0.11.0",
- "bitcoin 0.32.6",
+ "bitcoin 0.32.7",
"lightning-types",
]
@@ -1539,7 +1541,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2cd84d4e71472035903e43caded8ecc123066ce466329ccd5ae537a8d5488c7"
dependencies = [
- "bitcoin 0.32.6",
+ "bitcoin 0.32.7",
]
[[package]]
diff --git a/plugins/lsps-plugin/Cargo.toml b/plugins/lsps-plugin/Cargo.toml
index 592fdbba..60b3b57e 100644
--- a/plugins/lsps-plugin/Cargo.toml
+++ b/plugins/lsps-plugin/Cargo.toml
@@ -14,7 +14,8 @@ path = "src/service.rs"
[dependencies]
anyhow = "1.0"
async-trait = "0.1"
-chrono = "0.4.42"
+bitcoin = "0.31"
+chrono = { version= "0.4.42", features = ["serde"] }
cln-plugin = { version = "0.5", path = "../" }
cln-rpc = { version = "0.5", path = "../../cln-rpc" }
hex = "0.4"
diff --git a/plugins/lsps-plugin/src/lib.rs b/plugins/lsps-plugin/src/lib.rs
index aa93d0ac..f14b96c7 100644
--- a/plugins/lsps-plugin/src/lib.rs
+++ b/plugins/lsps-plugin/src/lib.rs
@@ -1,5 +1,6 @@
pub mod jsonrpc;
pub mod lsps0;
+pub mod lsps2;
pub mod util;
pub const LSP_FEATURE_BIT: usize = 729;
diff --git a/plugins/lsps-plugin/src/lsps2/mod.rs b/plugins/lsps-plugin/src/lsps2/mod.rs
new file mode 100644
index 00000000..0d0c0b35
--- /dev/null
+++ b/plugins/lsps-plugin/src/lsps2/mod.rs
@@ -0,0 +1,14 @@
+use cln_plugin::options;
+
+pub mod model;
+
+pub const OPTION_ENABLED: options::FlagConfigOption = options::ConfigOption::new_flag(
+ "dev-lsps2-service-enabled",
+ "Enables lsps2 for the LSP service",
+);
+
+pub const OPTION_PROMISE_SECRET: options::StringConfigOption =
+ options::ConfigOption::new_str_no_default(
+ "dev-lsps2-promise-secret",
+ "A 64-character hex string that is the secret for promises",
+ );
diff --git a/plugins/lsps-plugin/src/lsps2/model.rs b/plugins/lsps-plugin/src/lsps2/model.rs
new file mode 100644
index 00000000..7a186db0
--- /dev/null
+++ b/plugins/lsps-plugin/src/lsps2/model.rs
@@ -0,0 +1,640 @@
+use crate::{
+ jsonrpc::{JsonRpcRequest, RpcError},
+ lsps0::primitives::{DateTime, Msat, Ppm, ShortChannelId},
+};
+use bitcoin::hashes::{sha256, Hash, HashEngine, Hmac, HmacEngine};
+use chrono::Utc;
+use log::debug;
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Error {
+ InvalidOpeningFeeParams,
+ PaymentSizeTooSmall,
+ PaymentSizeTooLarge,
+ ClientRejected,
+}
+
+impl core::fmt::Display for Error {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let err_str = match self {
+ Error::InvalidOpeningFeeParams => "invalid opening fee params",
+ Error::PaymentSizeTooSmall => "payment size too small",
+ Error::PaymentSizeTooLarge => "payment size too large",
+ Error::ClientRejected => "client rejected",
+ };
+ write!(f, "{}", &err_str)
+ }
+}
+
+impl From<Error> for RpcError {
+ fn from(value: Error) -> Self {
+ match value {
+ Error::InvalidOpeningFeeParams => RpcError {
+ code: 201,
+ message: "invalid opening fee params".to_string(),
+ data: None,
+ },
+ Error::PaymentSizeTooSmall => RpcError {
+ code: 202,
+ message: "payment size too small".to_string(),
+ data: None,
+ },
+ Error::PaymentSizeTooLarge => RpcError {
+ code: 203,
+ message: "payment size too large".to_string(),
+ data: None,
+ },
+ Error::ClientRejected => RpcError {
+ code: 001,
+ message: "client rejected".to_string(),
+ data: None,
+ },
+ }
+ }
+}
+
+impl core::error::Error for Error {}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Lsps2GetInfoRequest {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub token: Option<String>,
+}
+
+impl JsonRpcRequest for Lsps2GetInfoRequest {
+ const METHOD: &'static str = "lsps2.get_info";
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Lsps2GetInfoResponse {
+ pub opening_fee_params_menu: Vec<OpeningFeeParams>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum PromiseError {
+ TooLong { length: usize, max: usize },
+}
+
+impl core::fmt::Display for PromiseError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ PromiseError::TooLong { length, max } => {
+ write!(
+ f,
+ "promise string is too long: {} bytes (max allowed {})",
+ length, max
+ )
+ }
+ }
+ }
+}
+
+impl core::error::Error for PromiseError {}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(try_from = "String")]
+pub struct Promise(String);
+
+impl Promise {
+ pub const MAX_BYTES: usize = 512;
+}
+
+impl TryFrom<String> for Promise {
+ type Error = PromiseError;
+
+ fn try_from(s: String) -> Result<Self, Self::Error> {
+ let len = s.len();
+ if len <= Promise::MAX_BYTES {
+ Ok(Promise(s))
+ } else {
+ Err(PromiseError::TooLong {
+ length: len,
+ max: Promise::MAX_BYTES,
+ })
+ }
+ }
+}
+
+impl TryFrom<&str> for Promise {
+ type Error = PromiseError;
+
+ fn try_from(s: &str) -> Result<Self, Self::Error> {
+ let len = s.len();
+ if len <= Promise::MAX_BYTES {
+ Ok(Promise(s.to_owned()))
+ } else {
+ Err(PromiseError::TooLong {
+ length: len,
+ max: Promise::MAX_BYTES,
+ })
+ }
+ }
+}
+
+impl core::fmt::Display for Promise {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+/// Represents a set of parameters for calculating the opening fee for a JIT
+/// channel.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+#[serde(deny_unknown_fields)] // LSPS2 requires the client to fail if a field is unrecognized.
+pub struct OpeningFeeParams {
+ pub min_fee_msat: Msat,
+ pub proportional: Ppm,
+ pub valid_until: DateTime,
+ pub min_lifetime: u32,
+ pub max_client_to_self_delay: u32,
+ pub min_payment_size_msat: Msat,
+ pub max_payment_size_msat: Msat,
+ pub promise: Promise, // Max 512 bytes
+}
+
+impl OpeningFeeParams {
+ pub fn validate(
+ &self,
+ secret: &[u8],
+ payment_size_msat: Option<Msat>,
+ receivable: Option<Msat>,
+ ) -> Result<(), Error> {
+ // LSPs MUST check that the opening_fee_params.promise does in fact
+ // prove that it previously promised the specified opening_fee_params.
+ let mut hmac = HmacEngine::<sha256::Hash>::new(&secret);
+ hmac.input(&self.min_fee_msat.msat().to_be_bytes());
+ hmac.input(&self.proportional.ppm().to_be_bytes());
+ hmac.input(self.valid_until.to_rfc3339().as_bytes());
+ hmac.input(&self.min_lifetime.to_be_bytes());
+ hmac.input(&self.max_client_to_self_delay.to_be_bytes());
+ hmac.input(&self.min_payment_size_msat.msat().to_be_bytes());
+ hmac.input(&self.max_payment_size_msat.msat().to_be_bytes());
+ let promise: String = Hmac::from_engine(hmac)
+ .to_byte_array()
+ .iter()
+ .map(|b| format!("{:02x}", b))
+ .collect();
+ if self.promise != Promise(promise) {
+ return Err(Error::InvalidOpeningFeeParams);
+ }
+
+ // LSPs MUST check that the opening_fee_params.valid_until is not a past
+ // datetime.
+ let now = Utc::now();
+ if now > self.valid_until {
+ debug!("Got invalid opening fee params: timeout, {:?}", self);
+ return Err(Error::InvalidOpeningFeeParams);
+ }
+
+ // If the payment_size_msat is specified in the request, the LSP:
+ // - MUST compute the opening_fee and check that the computation did
+ // not hit an overflow failure.
+ // - MUST check that the resulting opening_fee is strictly less than
+ // the payment_size_msat.
+ // - SHOULD check that it has sufficient incoming liquidity from the
+ // public network to be able to receive at least
+ // payment_size_msat.
+ if let Some(payment_size_msat) = payment_size_msat {
+ let opening_fee = compute_opening_fee(
+ payment_size_msat.msat(),
+ self.min_fee_msat.msat(),
+ self.proportional.ppm() as u64,
+ )
+ .ok_or(Error::PaymentSizeTooLarge)?;
+ if opening_fee >= payment_size_msat.msat() {
+ return Err(Error::PaymentSizeTooSmall);
+ }
+
+ if let Some(rec) = receivable {
+ if opening_fee >= rec.msat() {
+ return Err(Error::PaymentSizeTooLarge);
+ }
+ }
+ }
+
+ Ok(())
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Lsps2BuyRequest {
+ pub opening_fee_params: OpeningFeeParams,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub payment_size_msat: Option<Msat>,
+}
+
+impl JsonRpcRequest for Lsps2BuyRequest {
+ const METHOD: &'static str = "lsps2.buy";
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+pub struct Lsps2BuyResponse {
+ pub jit_channel_scid: ShortChannelId,
+ pub lsp_cltv_expiry_delta: u32,
+ // is an optional Boolean. If not specified, it defaults to false. If
+ // specified and true, the client MUST trust the LSP to actually create and
+ // confirm a valid channel funding transaction.
+ #[serde(default)]
+ pub client_trusts_lsp: bool,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+pub struct Lsps2PolicyGetInfoRequest {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub token: Option<String>,
+}
+
+impl From<Lsps2GetInfoRequest> for Lsps2PolicyGetInfoRequest {
+ fn from(value: Lsps2GetInfoRequest) -> Self {
+ Self { token: value.token }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+pub struct Lsps2PolicyGetInfoResponse {
+ pub policy_opening_fee_params_menu: Vec<PolicyOpeningFeeParams>,
+}
+
+/// An internal representation of a policy of parameters for calculating the
+/// opening fee for a JIT channel.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+pub struct PolicyOpeningFeeParams {
+ pub min_fee_msat: Msat,
+ pub proportional: Ppm,
+ pub valid_until: DateTime,
+ pub min_lifetime: u32,
+ pub max_client_to_self_delay: u32,
+ pub min_payment_size_msat: Msat,
+ pub max_payment_size_msat: Msat,
+}
+
+impl PolicyOpeningFeeParams {
+ pub fn get_hmac_hex(&self, secret: &[u8]) -> String {
+ let mut hmac = HmacEngine::<sha256::Hash>::new(&secret);
+ hmac.input(&self.min_fee_msat.msat().to_be_bytes());
+ hmac.input(&self.proportional.ppm().to_be_bytes());
+ hmac.input(self.valid_until.to_rfc3339().as_bytes());
+ hmac.input(&self.min_lifetime.to_be_bytes());
+ hmac.input(&self.max_client_to_self_delay.to_be_bytes());
+ hmac.input(&self.min_payment_size_msat.msat().to_be_bytes());
+ hmac.input(&self.max_payment_size_msat.msat().to_be_bytes());
+ let promise = Hmac::from_engine(hmac)
+ .to_byte_array()
+ .iter()
+ .map(|b| format!("{:02x}", b))
+ .collect();
+ promise
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct DatastoreEntry {
+ pub peer_id: cln_rpc::primitives::PublicKey,
+ pub opening_fee_params: OpeningFeeParams,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub expected_payment_size: Option<Msat>,
+}
+
+/// Computes the opening fee in millisatoshis as described in LSPS2.
+/// Returns None if an arithmetic overflow occurs during calculation.
+///
+/// # Arguments
+/// * `payment_size_msat` - The size of the payment for which the channel is
+/// being opened.
+/// * `opening_fee_min_fee_msat` - The minimum fee to be paid by the client to
+/// the LSP
+/// * `opening_fee_proportional` - The proportional fee charged by the LSP
+pub fn compute_opening_fee(
+ payment_size_msat: u64,
+ opening_fee_min_fee_msat: u64,
+ opening_fee_proportional: u64,
+) -> Option<u64> {
+ payment_size_msat
+ .checked_mul(opening_fee_proportional)
+ .and_then(|f| f.checked_add(999999))
+ .and_then(|f| f.checked_div(1000000))
+ .map(|f| std::cmp::max(f, opening_fee_min_fee_msat))
+}
+
+#[cfg(test)]
+mod tests {
+ use chrono::Duration;
+
+ use super::*;
+
+ // Helper struct for testing Serde
+ #[derive(Serialize, Deserialize, Debug, PartialEq)]
+ struct TestData {
+ label: String,
+ value: Promise,
+ }
+
+ // Helper function to create valid opening fee params
+ fn create_valid_opening_fee_params(secret: &[u8]) -> OpeningFeeParams {
+ let params = OpeningFeeParams {
+ min_fee_msat: Msat::from_msat(1000),
+ proportional: Ppm::from_ppm(1000), // 0.1%
+ valid_until: Utc::now() + Duration::hours(1), // Valid for 1 hour
+ min_lifetime: 144, // blocks
+ max_client_to_self_delay: 2016, // blocks
+ min_payment_size_msat: Msat::from_msat(1000), // 1 Sat
+ max_payment_size_msat: Msat::from_msat(100_000_000_000), // 1 BTC
+ promise: Promise("placeholder".to_string()), // Will be replaced
+ };
+
+ // Compute the correct promise
+ let mut hmac = HmacEngine::<sha256::Hash>::new(secret);
+ hmac.input(¶ms.min_fee_msat.msat().to_be_bytes());
+ hmac.input(¶ms.proportional.ppm().to_be_bytes());
+ hmac.input(params.valid_until.to_rfc3339().as_bytes());
+ hmac.input(¶ms.min_lifetime.to_be_bytes());
+ hmac.input(¶ms.max_client_to_self_delay.to_be_bytes());
+ hmac.input(¶ms.min_payment_size_msat.msat().to_be_bytes());
+ hmac.input(¶ms.max_payment_size_msat.msat().to_be_bytes());
+ let promise: String = Hmac::from_engine(hmac)
+ .to_byte_array()
+ .iter()
+ .map(|b| format!("{:02x}", b))
+ .collect();
+
+ OpeningFeeParams {
+ promise: Promise(promise),
+ ..params
+ }
+ }
+
+ #[test]
+ fn test_serde_promise_ok() {
+ let json = r#"{"label": "short", "value": "This is valid"}"#;
+ let result = serde_json::from_str::<TestData>(json);
+ assert!(result.is_ok());
+ let data = result.unwrap();
+ assert_eq!(data.value.0, "This is valid");
+ }
+
+ #[test]
+ fn test_serde_promise_too_long() {
+ let long_value = "a".repeat(513); // Exceeds 512 bytes
+ let json = format!(r#"{{"label": "long", "value": "{}"}}"#, long_value);
+ let result = serde_json::from_str::<TestData>(&json);
+ assert!(result.is_err());
+ // Check the error message relates to our PromiseError
+ assert!(result
+ .unwrap_err()
+ .to_string()
+ .contains("promise string is too long"));
+ }
+
+ #[test]
+ fn test_serde_promise_wrong_type() {
+ // Input JSON has a number where a string is expected for 'value'
+ let json = r#"{"label": "wrong_type", "value": 123}"#;
+ let result = serde_json::from_str::<TestData>(json);
+ assert!(result.is_err());
+ // This error occurs when Serde tries to deserialize 123 as the String
+ // required by `try_from = "String"`.
+ assert!(result
+ .unwrap_err()
+ .to_string()
+ .contains("invalid type: integer"));
+ }
+
+ #[test]
+ fn test_validate_success_minimal() {
+ let secret = b"test_secret_key_32_bytes_long___";
+ let params = create_valid_opening_fee_params(secret);
+
+ let result = params.validate(secret, None, None);
+ assert!(
+ result.is_ok(),
+ "Valid params with no payment_size should succeed"
+ );
+ }
+
+ #[test]
+ fn test_validate_success_with_payment_size() {
+ let secret = b"test_secret_key_32_bytes_long___";
+ let params = create_valid_opening_fee_params(secret);
+ let payment_size = Msat::from_msat(10_000_000); // 10M msat
+
+ let result = params.validate(secret, Some(payment_size), None);
+ assert!(
+ result.is_ok(),
+ "Valid params with valid payment_size should succeed"
+ );
+ }
+
+ #[test]
+ fn test_validate_success_with_payment_size_and_receivable() {
+ let secret = b"test_secret_key_32_bytes_long___";
+ let params = create_valid_opening_fee_params(secret);
+ let payment_size = Msat::from_msat(10_000_000); // 10M msat
+ let receivable = Msat::from_msat(50_000_000); // 50M msat
+
+ let result = params.validate(secret, Some(payment_size), Some(receivable));
+ assert!(
+ result.is_ok(),
+ "Valid params with payment_size and receivable should succeed"
+ );
+ }
+
+ #[test]
+ fn test_validate_invalid_promise() {
+ let secret = b"test_secret_key_32_bytes_long___";
+ let mut params = create_valid_opening_fee_params(secret);
+ params.min_fee_msat = Msat(10);
+
+ let result = params.validate(secret, None, None);
+ assert!(
+ matches!(result, Err(Error::InvalidOpeningFeeParams)),
+ "Invalid promise should fail validation"
+ );
+ }
+
+ #[test]
+ fn test_validate_wrong_secret() {
+ let secret1 = b"test_secret_key_32_bytes_long___";
+ let secret2 = b"different_secret_key_32_bytes___";
+ let params = create_valid_opening_fee_params(secret1);
+
+ let result = params.validate(secret2, None, None);
+ assert!(
+ matches!(result, Err(Error::InvalidOpeningFeeParams)),
+ "Wrong secret should fail validation"
+ );
+ }
+
+ #[test]
+ fn test_validate_expired_timestamp() {
+ let secret = b"test_secret_key_32_bytes_long___";
+ let mut params = create_valid_opening_fee_params(secret);
+ params.valid_until = Utc::now() - Duration::hours(1); // Expired 1 hour ago
+
+ // Recompute promise with expired timestamp
+ let mut hmac = HmacEngine::<sha256::Hash>::new(secret);
+ hmac.input(¶ms.min_fee_msat.msat().to_be_bytes());
+ hmac.input(¶ms.proportional.ppm().to_be_bytes());
+ hmac.input(params.valid_until.to_rfc3339().as_bytes());
+ hmac.input(¶ms.min_lifetime.to_be_bytes());
+ hmac.input(¶ms.max_client_to_self_delay.to_be_bytes());
+ hmac.input(¶ms.min_payment_size_msat.msat().to_be_bytes());
+ hmac.input(¶ms.max_payment_size_msat.msat().to_be_bytes());
+ let promise: String = Hmac::from_engine(hmac)
+ .to_byte_array()
+ .iter()
+ .map(|b| format!("{:02x}", b))
+ .collect();
+ params.promise = Promise(promise);
+
+ let result = params.validate(secret, None, None);
+ assert!(
+ matches!(result, Err(Error::InvalidOpeningFeeParams)),
+ "Expired timestamp should fail validation"
+ );
+ }
+
+ #[test]
+ fn test_validate_payment_size_overflow() {
+ let secret = b"test_secret_key_32_bytes_long___";
+ let mut params = create_valid_opening_fee_params(secret);
+ // Set proportional fee high enough to cause overflow
+ params.proportional = Ppm::from_ppm(u32::MAX);
+
+ // Recompute promise
+ let mut hmac = HmacEngine::<sha256::Hash>::new(secret);
+ hmac.input(¶ms.min_fee_msat.msat().to_be_bytes());
+ hmac.input(¶ms.proportional.ppm().to_be_bytes());
+ hmac.input(params.valid_until.to_rfc3339().as_bytes());
+ hmac.input(¶ms.min_lifetime.to_be_bytes());
+ hmac.input(¶ms.max_client_to_self_delay.to_be_bytes());
+ hmac.input(¶ms.min_payment_size_msat.msat().to_be_bytes());
+ hmac.input(¶ms.max_payment_size_msat.msat().to_be_bytes());
+ let promise: String = Hmac::from_engine(hmac)
+ .to_byte_array()
+ .iter()
+ .map(|b| format!("{:02x}", b))
+ .collect();
+ params.promise = Promise(promise);
+
+ let payment_size = Msat::from_msat(u64::MAX);
+ let result = params.validate(secret, Some(payment_size), None);
+ assert!(
+ matches!(result, Err(Error::PaymentSizeTooLarge)),
+ "Overflow in fee calculation should return PaymentSizeTooLarge"
+ );
+ }
+
+ #[test]
+ fn test_validate_opening_fee_equals_payment_size() {
+ let secret = b"test_secret_key_32_bytes_long___";
+ let params = create_valid_opening_fee_params(secret);
+
+ // Find a payment size where opening fee equals payment size
+ // With min_fee_msat = 1000 and proportional = 1000 (0.1%)
+ // The opening fee will be max(1000, payment * 1000 / 1_000_000)
+ // So for small payments, fee = 1000
+ let payment_size = Msat::from_msat(1000); // Same as min_fee_msat
+
+ let result = params.validate(secret, Some(payment_size), None);
+ assert!(
+ matches!(result, Err(Error::PaymentSizeTooSmall)),
+ "Opening fee equal to payment size should fail"
+ );
+ }
+
+ #[test]
+ fn test_validate_opening_fee_greater_than_payment_size() {
+ let secret = b"test_secret_key_32_bytes_long___";
+ let params = create_valid_opening_fee_params(secret);
+
+ // Payment size smaller than minimum fee
+ let payment_size = Msat::from_msat(500); // Less than min_fee_msat (1000)
+
+ let result = params.validate(secret, Some(payment_size), None);
+ assert!(
+ matches!(result, Err(Error::PaymentSizeTooSmall)),
+ "Opening fee greater than payment size should fail"
+ );
+ }
+
+ #[test]
+ fn test_validate_opening_fee_equals_receivable() {
+ let secret = b"test_secret_key_32_bytes_long___";
+ let params = create_valid_opening_fee_params(secret);
+
+ let payment_size = Msat::from_msat(10_000_000); // 10M msat
+ let receivable = Msat::from_msat(1000); // Same as min_fee_msat
+
+ let result = params.validate(secret, Some(payment_size), Some(receivable));
+ assert!(
+ matches!(result, Err(Error::PaymentSizeTooLarge)),
+ "Opening fee equal to receivable should fail"
+ );
+ }
+
+ #[test]
+ fn test_validate_opening_fee_greater_than_receivable() {
+ let secret = b"test_secret_key_32_bytes_long___";
+ let params = create_valid_opening_fee_params(secret);
+
+ let payment_size = Msat::from_msat(10_000_000); // 10M msat
+ let receivable = Msat::from_msat(500); // Less than min_fee_msat (1000)
+
+ let result = params.validate(secret, Some(payment_size), Some(receivable));
+ assert!(
+ matches!(result, Err(Error::PaymentSizeTooLarge)),
+ "Opening fee greater than receivable should fail"
+ );
+ }
+
+ #[test]
+ fn test_validate_large_payment_proportional_fee() {
+ let secret = b"test_secret_key_32_bytes_long___";
+ let params = create_valid_opening_fee_params(secret);
+
+ // Large payment where proportional fee dominates
+ // Opening fee = max(1000, 1_000_000_000 * 1000 / 1_000_000) = max(1000, 1_000_000) = 1_000_000
+ let payment_size = Msat::from_msat(1_000_000_000);
+
+ let result = params.validate(secret, Some(payment_size), None);
+ assert!(
+ result.is_ok(),
+ "Large payment with proportional fee should succeed"
+ );
+ }
+
+ #[test]
+ fn test_validate_max_values() {
+ let secret = b"test_secret_key_32_bytes_long___";
+ let mut params = OpeningFeeParams {
+ min_fee_msat: Msat::from_msat(u64::MAX / 1000), // Avoid overflow
+ proportional: Ppm::from_ppm(100), // Small proportional to avoid overflow
+ valid_until: Utc::now() + Duration::hours(1),
+ min_lifetime: u32::MAX,
+ max_client_to_self_delay: u32::MAX,
+ min_payment_size_msat: Msat::from_msat(1),
+ max_payment_size_msat: Msat::from_msat(u64::MAX),
+ promise: Promise("placeholder".to_string()),
+ };
+
+ // Compute promise
+ let mut hmac = HmacEngine::<sha256::Hash>::new(secret);
+ hmac.input(¶ms.min_fee_msat.msat().to_be_bytes());
+ hmac.input(¶ms.proportional.ppm().to_be_bytes());
+ hmac.input(params.valid_until.to_rfc3339().as_bytes());
+ hmac.input(¶ms.min_lifetime.to_be_bytes());
+ hmac.input(¶ms.max_client_to_self_delay.to_be_bytes());
+ hmac.input(¶ms.min_payment_size_msat.msat().to_be_bytes());
+ hmac.input(¶ms.max_payment_size_msat.msat().to_be_bytes());
+ let promise: String = Hmac::from_engine(hmac)
+ .to_byte_array()
+ .iter()
+ .map(|b| format!("{:02x}", b))
+ .collect();
+ params.promise = Promise(promise);
+
+ let result = params.validate(secret, None, None);
+ assert!(result.is_ok(), "Maximum safe values should be valid");
+ }
+}
diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs
index 116e13f2..aff0fb6b 100644
--- a/plugins/lsps-plugin/src/service.rs
+++ b/plugins/lsps-plugin/src/service.rs
@@ -7,7 +7,7 @@ use cln_lsps::lsps0::handler::Lsps0ListProtocolsHandler;
use cln_lsps::lsps0::model::Lsps0listProtocolsRequest;
use cln_lsps::lsps0::transport::{self, CustomMsg};
use cln_lsps::util::wrap_payload_with_peer_id;
-use cln_lsps::{lsps0, util, LSP_FEATURE_BIT};
+use cln_lsps::{lsps0, lsps2, util, LSP_FEATURE_BIT};
use cln_plugin::options::ConfigOption;
use cln_plugin::{options, Plugin};
use cln_rpc::notifications::CustomMsgNotification;
@@ -32,6 +32,8 @@ struct State {
async fn main() -> Result<(), anyhow::Error> {
if let Some(plugin) = cln_plugin::Builder::new(tokio::io::stdin(), tokio::io::stdout())
.option(OPTION_ENABLED)
+ .option(lsps2::OPTION_ENABLED)
+ .option(lsps2::OPTION_PROMISE_SECRET)
.featurebits(
cln_plugin::FeatureBitsKind::Node,
util::feature_bit_to_hex(LSP_FEATURE_BIT),
@@ -50,12 +52,45 @@ async fn main() -> Result<(), anyhow::Error> {
.await;
}
+ if plugin.option(&lsps2::OPTION_ENABLED)? {
+ log::debug!("lsps2 enabled");
+ let secret_hex = plugin.option(&lsps2::OPTION_PROMISE_SECRET)?;
+ if let Some(secret_hex) = secret_hex {
+ let secret_hex = secret_hex.trim().to_lowercase();
+
+ let decoded_bytes = match hex::decode(&secret_hex) {
+ Ok(bytes) => bytes,
+ Err(_) => {
+ return plugin
+ .disable(&format!(
+ "Invalid hex string for promise secret: {}",
+ secret_hex
+ ))
+ .await;
+ }
+ };
+
+ let _: [u8; 32] = match decoded_bytes.try_into() {
+ Ok(array) => array,
+ Err(vec) => {
+ return plugin
+ .disable(&format!(
+ "Promise secret must be exactly 32 bytes, got {}",
+ vec.len()
+ ))
+ .await;
+ }
+ };
+ }
+ }
+
let lsps_builder = JsonRpcServer::builder().with_handler(
Lsps0listProtocolsRequest::METHOD.to_string(),
Arc::new(Lsps0ListProtocolsHandler {
- lsps2_enabled: false,
+ lsps2_enabled: plugin.option(&lsps2::OPTION_ENABLED)?,
}),
);
+
let lsps_service = lsps_builder.build();
let state = State { lsps_service };
diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py
index 512d432d..40a8ae68 100644
--- a/tests/test_cln_lsps.py
+++ b/tests/test_cln_lsps.py
@@ -29,3 +29,18 @@ 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},
+ {
+ "dev-lsps-service-enabled": None,
+ "dev-lsps2-service-enabled": None,
+ "dev-lsps2-promise-secret": "0" * 64
+ }
+ ])
+
+ node_factory.join_nodes([l1, l2], fundchannel=False)
+
+ res = l1.rpc.lsps_listprotocols(lsp_id=l2.info['id'])
+ assert res['protocols'] == [2]
Why this scored 12/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.