lsp_plugin: add primitives for messages
What changed, and why it matters
This commit adds basic building-block types for a new Lightning Service Provider (LSP) plugin in Core Lightning. It introduces Rust code to represent money amounts (millisatoshis), fee rates (parts-per-million), channel IDs, and timestamps, plus tests. There is no security fix or vulnerability here; it is ordinary feature development.
No security action required. Review as normal feature code if auditing the new lsps-plugin.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change creates plugins/lsps-plugin/src/lsps0/primitives.rs, adds the module to lsps0/mod.rs, and pulls in the chrono crate via Cargo.toml/Cargo.lock. It defines Msat (u64 wrapper with serde string serialization), Ppm (u32 wrapper), ShortChannelId alias, and DateTime alias. Unit tests cover Msat serialization/deserialization. No existing behavior is modified and no security-sensitive logic is introduced.
Changed components
plugins/lsps-plugin/src/lsps0/primitives.rsplugins/lsps-plugin/src/lsps0/mod.rsplugins/lsps-plugin/Cargo.tomlCargo.lockInspect captured patch +307 / −0
diff --git a/Cargo.lock b/Cargo.lock
index 90292f35..13a991ad 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -26,6 +26,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "anyhow"
version = "1.0.98"
@@ -464,6 +473,19 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
+[[package]]
+name = "chrono"
+version = "0.4.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-link",
+]
+
[[package]]
name = "cln-bip353"
version = "0.1.0"
@@ -524,6 +546,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
+ "chrono",
"cln-plugin",
"cln-rpc",
"hex",
@@ -1209,6 +1232,30 @@ dependencies = [
"tower-service",
]
+[[package]]
+name = "iana-time-zone"
+version = "0.1.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
[[package]]
name = "icu_collections"
version = "1.5.0"
@@ -3336,6 +3383,65 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+[[package]]
+name = "windows-core"
+version = "0.62.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
+
+[[package]]
+name = "windows-result"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda"
+dependencies = [
+ "windows-link",
+]
+
[[package]]
name = "windows-sys"
version = "0.48.0"
diff --git a/plugins/lsps-plugin/Cargo.toml b/plugins/lsps-plugin/Cargo.toml
index 13279da1..592fdbba 100644
--- a/plugins/lsps-plugin/Cargo.toml
+++ b/plugins/lsps-plugin/Cargo.toml
@@ -14,6 +14,7 @@ path = "src/service.rs"
[dependencies]
anyhow = "1.0"
async-trait = "0.1"
+chrono = "0.4.42"
cln-plugin = { version = "0.5", path = "../" }
cln-rpc = { version = "0.5", path = "../../cln-rpc" }
hex = "0.4"
diff --git a/plugins/lsps-plugin/src/lsps0/mod.rs b/plugins/lsps-plugin/src/lsps0/mod.rs
index e7716c92..df78189c 100644
--- a/plugins/lsps-plugin/src/lsps0/mod.rs
+++ b/plugins/lsps-plugin/src/lsps0/mod.rs
@@ -1,2 +1,3 @@
pub mod model;
+pub mod primitives;
pub mod transport;
diff --git a/plugins/lsps-plugin/src/lsps0/primitives.rs b/plugins/lsps-plugin/src/lsps0/primitives.rs
new file mode 100644
index 00000000..3b2ff52d
--- /dev/null
+++ b/plugins/lsps-plugin/src/lsps0/primitives.rs
@@ -0,0 +1,199 @@
+use core::fmt;
+use serde::{
+ de::{self},
+ Deserialize, Deserializer, Serialize, Serializer,
+};
+
+const MSAT_PER_SAT: u64 = 1_000;
+
+/// Represents a monetary amount as defined in LSPS0.msat. Is converted to a
+/// `String` in json messages with a suffix `_msat` or `_sat` and internally
+/// represented as Millisatoshi `u64`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub struct Msat(pub u64);
+
+impl Msat {
+ /// Constructs a new `Msat` struct from a `u64` msat value.
+ pub fn from_msat(msat: u64) -> Self {
+ Msat(msat)
+ }
+
+ /// Construct a new `Msat` struct from a `u64` sat value.
+ pub fn from_sat(sat: u64) -> Self {
+ Msat(sat * MSAT_PER_SAT)
+ }
+
+ /// Returns the sat amount of the field. Is a floored integer division e.g
+ /// 100678 becomes 100.
+ pub fn to_sats_floor(&self) -> u64 {
+ self.0 / 1000
+ }
+
+ /// Returns the msat value as `u64`. Is the inner value of `Msat`.
+ pub fn msat(&self) -> u64 {
+ self.0
+ }
+}
+
+impl core::fmt::Display for Msat {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}_msat", self.0)
+ }
+}
+
+impl Serialize for Msat {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ serializer.serialize_str(&self.0.to_string())
+ }
+}
+
+impl<'de> Deserialize<'de> for Msat {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ struct MsatVisitor;
+
+ impl<'de> de::Visitor<'de> for MsatVisitor {
+ type Value = Msat;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+ formatter.write_str("a string representing a number")
+ }
+
+ fn visit_str<E>(self, value: &str) -> Result<Msat, E>
+ where
+ E: de::Error,
+ {
+ value
+ .parse::<u64>()
+ .map(Msat::from_msat)
+ .map_err(|_| E::custom(format!("Invalid number string: {}", value)))
+ }
+
+ // Also handle if JSON mistakenly has a number instead of string
+ fn visit_u64<E>(self, value: u64) -> Result<Msat, E>
+ where
+ E: de::Error,
+ {
+ Ok(Msat::from_msat(value))
+ }
+
+ fn visit_i64<E>(self, value: i64) -> Result<Msat, E>
+ where
+ E: de::Error,
+ {
+ if value < 0 {
+ Err(E::custom("Msat cannot be negative"))
+ } else {
+ Ok(Msat::from_msat(value as u64))
+ }
+ }
+ }
+
+ deserializer.deserialize_any(MsatVisitor)
+ }
+}
+
+/// Represents parts-per-million as defined in LSPS0.ppm. Gets it's own type
+/// from the rationals: "This is its own type so that fractions can be expressed
+/// using this type, instead of as a floating-point type which might lose
+/// accuracy when serialized into text.". Having it as a separate type also
+/// provides more clarity.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(transparent)] // Key attribute! Serialize/Deserialize as the inner u32
+pub struct Ppm(pub u32); // u32 is sufficient as 1,000,000 fits easily
+
+impl Ppm {
+ /// Constructs a new `Ppm` from a u32.
+ pub const fn from_ppm(value: u32) -> Self {
+ Ppm(value)
+ }
+
+ /// Applies the proportion to a base amount (e.g., in msats).
+ pub fn apply_to(&self, base_msat: u64) -> u64 {
+ // Careful about integer division order and potential overflow
+ (base_msat as u128 * self.0 as u128 / 1_000_000) as u64
+ }
+
+ /// Returns the ppm.
+ pub fn ppm(&self) -> u32 {
+ self.0
+ }
+}
+
+impl core::fmt::Display for Ppm {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ write!(f, "{}ppm", self.0)
+ }
+}
+
+/// Represents a short channel id as defined in LSPS0.scid. Matches with the
+/// implementation in cln_rpc.
+pub type ShortChannelId = cln_rpc::primitives::ShortChannelId;
+
+/// Represents a datetime as defined in LSPS0.datetime. Uses ISO8601 in UTC
+/// timezone.
+pub type DateTime = chrono::DateTime<chrono::Utc>;
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json;
+
+ #[derive(Debug, Serialize, Deserialize)]
+ struct TestMessage {
+ amount: Msat,
+ }
+
+ /// Test serialization of a struct containing Msat.
+ #[test]
+ fn test_msat_serialization() {
+ let msg = TestMessage {
+ amount: Msat(12345000),
+ };
+
+ let expected_amount_json = r#""amount":"12345000""#;
+
+ // Assert that the field gets serialized as string.
+ let json_string = serde_json::to_string(&msg).expect("Serialization failed");
+ assert!(
+ json_string.contains(expected_amount_json),
+ "Serialized JSON should contain '{}'",
+ expected_amount_json
+ );
+
+ // Parse back to generic json value and check field.
+ let json_value: serde_json::Value =
+ serde_json::from_str(&json_string).expect("Failed to parse JSON back");
+ assert_eq!(
+ json_value
+ .get("amount")
+ .expect("JSON should have 'amount' field"),
+ &serde_json::Value::String("12345000".to_string()),
+ "JSON 'amount' field should have the correct string value"
+ );
+ }
+
+ /// Test deserialization into a struct containing Msat.
+ #[test]
+ fn test_msat_deserialization_and_errors() {
+ // Case 1: Input string uses "_msat" suffix
+ let json_ok = r#"{"amount":"987654321"}"#;
+ let expected_value_msat = Msat(987654321);
+ let message1: TestMessage =
+ serde_json::from_str(json_ok).expect("Deserialization from string failed");
+ assert_eq!(message1.amount, expected_value_msat);
+
+ // Case 2: Non-numeric Value before suffix
+ let json_non_numeric = r#"{"amount":"abc"}"#;
+ let result_non_numeric = serde_json::from_str::<TestMessage>(json_non_numeric);
+ assert!(
+ result_non_numeric.is_err(),
+ "Deserialization should fail for non-numeric value"
+ );
+ }
+}
Why this scored 16/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.