diff --git a/Cargo.lock b/Cargo.lock index 90292f355..13a991ad2 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 13279da12..592fdbba1 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 e7716c921..df78189cd 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 000000000..3b2ff52d1 --- /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(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0.to_string()) + } +} + +impl<'de> Deserialize<'de> for Msat { + fn deserialize(deserializer: D) -> Result + 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(self, value: &str) -> Result + where + E: de::Error, + { + value + .parse::() + .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(self, value: u64) -> Result + where + E: de::Error, + { + Ok(Msat::from_msat(value)) + } + + fn visit_i64(self, value: i64) -> Result + 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; + +#[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::(json_non_numeric); + assert!( + result_non_numeric.is_err(), + "Deserialization should fail for non-numeric value" + ); + } +}