lsp_plugin: add primitives for messages

Adds some primitives defined in lsps0 for other protocol messages.

Signed-off-by: Peter Neuroth <pet.v.ne@gmail.com>
This commit is contained in:
Peter Neuroth
2025-09-19 16:44:51 +02:00
committed by Rusty Russell
parent 3606106c4f
commit e36fdeff81
4 changed files with 307 additions and 0 deletions

106
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -1,2 +1,3 @@
pub mod model;
pub mod primitives;
pub mod transport;

View File

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