From 98847cfdb329dc7e110bed4627c0f65ee7285e5d Mon Sep 17 00:00:00 2001 From: Andezion <245122@edu.p.lodz.pl> Date: Thu, 23 Jul 2026 23:38:38 +0200 Subject: [PATCH] lsps-plugin: add cln-lsps-policy plugin for LSPS2 fee policy management Changelog-None --- plugins/Makefile | 6 +- plugins/lsps-plugin/Cargo.toml | 4 + plugins/lsps-plugin/src/policy.rs | 236 ++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 plugins/lsps-plugin/src/policy.rs diff --git a/plugins/Makefile b/plugins/Makefile index d302cc74d597..a817e20d1d31 100644 --- a/plugins/Makefile +++ b/plugins/Makefile @@ -153,7 +153,7 @@ $(shell test -d plugins/clnrest && $(RM) -r plugins/clnrest || true) $(shell test -d plugins/wss-proxy && $(RM) -r plugins/wss-proxy || true) ifneq ($(RUST),0) -RUST_PLUGIN_NAMES := cln-grpc clnrest cln-lsps-client cln-lsps-service wss-proxy cln-bip353 cln-currencyrate +RUST_PLUGIN_NAMES := cln-grpc clnrest cln-lsps-client cln-lsps-service cln-lsps-policy wss-proxy cln-bip353 cln-currencyrate # Builtin plugins must be in this plugins dir to work when we're executed # *without* make install. @@ -283,6 +283,8 @@ $(RUST_TARGET_DIR)/cln-lsps-client: ${CLN_LSPS_PLUGIN_SRC} $(CARGO) build ${CARGO_OPTS} --bin cln-lsps-client $(RUST_TARGET_DIR)/cln-lsps-service: ${CLN_LSPS_PLUGIN_SRC} $(CARGO) build ${CARGO_OPTS} --bin cln-lsps-service +$(RUST_TARGET_DIR)/cln-lsps-policy: ${CLN_LSPS_PLUGIN_SRC} + $(CARGO) build ${CARGO_OPTS} --bin cln-lsps-policy $(RUST_TARGET_DIR)/wss-proxy: ${CLN_WSS_PROXY_PLUGIN_SRC} $(CARGO) build ${CARGO_OPTS} --bin wss-proxy $(RUST_TARGET_DIR)/cln-bip353: ${CLN_BIP353_PLUGIN_SRC} @@ -293,7 +295,7 @@ $(RUST_TARGET_DIR)/cln-currencyrate: ${CLN_CURRENCYRATE_PLUGIN_SRC} ifneq ($(RUST),0) include plugins/rest-plugin/Makefile include plugins/wss-proxy-plugin/Makefile -DEFAULT_TARGETS += $(CLN_PLUGIN_EXAMPLES) plugins/cln-grpc plugins/clnrest plugins/cln-lsps-client plugins/cln-lsps-service plugins/wss-proxy plugins/cln-bip353 plugins/cln-currencyrate +DEFAULT_TARGETS += $(CLN_PLUGIN_EXAMPLES) plugins/cln-grpc plugins/clnrest plugins/cln-lsps-client plugins/cln-lsps-service plugins/cln-lsps-policy plugins/wss-proxy plugins/cln-bip353 plugins/cln-currencyrate endif clean: plugins-clean diff --git a/plugins/lsps-plugin/Cargo.toml b/plugins/lsps-plugin/Cargo.toml index 51610e80436d..0038d5e9e430 100644 --- a/plugins/lsps-plugin/Cargo.toml +++ b/plugins/lsps-plugin/Cargo.toml @@ -12,6 +12,10 @@ path = "src/client.rs" name = "cln-lsps-service" path = "src/service.rs" +[[bin]] +name = "cln-lsps-policy" +path = "src/policy.rs" + [dependencies] anyhow = "1.0" async-trait = "0.1" diff --git a/plugins/lsps-plugin/src/policy.rs b/plugins/lsps-plugin/src/policy.rs new file mode 100644 index 000000000000..7d631b3b5501 --- /dev/null +++ b/plugins/lsps-plugin/src/policy.rs @@ -0,0 +1,236 @@ +use anyhow::{anyhow, bail}; +use chrono::{Duration, Utc}; +use cln_lsps::proto::lsps0::{Msat, Ppm}; +use cln_lsps::proto::lsps2::{ + Lsps2PolicyGetChannelCapacityRequest, Lsps2PolicyGetChannelCapacityResponse, + Lsps2PolicyGetInfoRequest, Lsps2PolicyGetInfoResponse, PolicyOpeningFeeParams, +}; +use cln_plugin::options::{self, DefaultIntegerConfigOption}; + +const OPTION_MIN_FEE_MSAT: DefaultIntegerConfigOption = options::ConfigOption::new_i64_with_default( + "lsps2-policy-min-fee-msat", + 1_000, + "Minimum opening fee in millisatoshis charged by the LSP", +); + +const OPTION_PROPORTIONAL_PPM: DefaultIntegerConfigOption = + options::ConfigOption::new_i64_with_default( + "lsps2-policy-proportional-ppm", + 0, + "Proportional opening fee in parts-per-million of the payment size", + ); + +const OPTION_VALID_UNTIL_HOURS: DefaultIntegerConfigOption = + options::ConfigOption::new_i64_with_default( + "lsps2-policy-valid-until-hours", + 1, + "Number of hours for which the offered fee parameters are valid", + ); + +const OPTION_MIN_LIFETIME: DefaultIntegerConfigOption = options::ConfigOption::new_i64_with_default( + "lsps2-policy-min-lifetime", + 144, + "Minimum channel lifetime in blocks that the LSP guarantees", +); + +const OPTION_MAX_CLIENT_TO_SELF_DELAY: DefaultIntegerConfigOption = + options::ConfigOption::new_i64_with_default( + "lsps2-policy-max-client-to-self-delay", + 2016, + "Maximum to_self_delay (in blocks) accepted from the client", + ); + +const OPTION_MIN_PAYMENT_SIZE_MSAT: DefaultIntegerConfigOption = + options::ConfigOption::new_i64_with_default( + "lsps2-policy-min-payment-size-msat", + 1_000, + "Minimum payment size in millisatoshis that will trigger a JIT channel opening", + ); + +const OPTION_MAX_PAYMENT_SIZE_MSAT: DefaultIntegerConfigOption = + options::ConfigOption::new_i64_with_default( + "lsps2-policy-max-payment-size-msat", + 10_000_000_000, + "Maximum payment size in millisatoshis that will trigger a JIT channel opening", + ); + +const OPTION_CHANNEL_CAPACITY_MSAT: DefaultIntegerConfigOption = + options::ConfigOption::new_i64_with_default( + "lsps2-policy-channel-capacity-msat", + 10_000_000_000, + "Channel capacity in millisatoshis to open for JIT channel requests", + ); + +#[derive(Clone)] +struct State { + min_fee_msat: Msat, + proportional: Ppm, + valid_until_hours: i64, + min_lifetime: u32, + max_client_to_self_delay: u32, + min_payment_size_msat: Msat, + max_payment_size_msat: Msat, + channel_capacity_msat: u64, +} + +#[tokio::main] +async fn main() -> Result<(), anyhow::Error> { + if let Some(plugin) = cln_plugin::Builder::new(tokio::io::stdin(), tokio::io::stdout()) + .option(OPTION_MIN_FEE_MSAT) + .option(OPTION_PROPORTIONAL_PPM) + .option(OPTION_VALID_UNTIL_HOURS) + .option(OPTION_MIN_LIFETIME) + .option(OPTION_MAX_CLIENT_TO_SELF_DELAY) + .option(OPTION_MIN_PAYMENT_SIZE_MSAT) + .option(OPTION_MAX_PAYMENT_SIZE_MSAT) + .option(OPTION_CHANNEL_CAPACITY_MSAT) + .rpcmethod( + "lsps2-policy-getpolicy", + "Returns the LSP fee policy used for LSPS2 JIT channel requests", + on_getpolicy, + ) + .rpcmethod( + "lsps2-policy-getchannelcapacity", + "Returns the channel capacity the LSP will open for a given JIT channel request", + on_getchannelcapacity, + ) + .configure() + .await? + { + let min_fee_msat = plugin.option(&OPTION_MIN_FEE_MSAT)?; + let proportional = plugin.option(&OPTION_PROPORTIONAL_PPM)?; + let valid_until_hours = plugin.option(&OPTION_VALID_UNTIL_HOURS)?; + let min_lifetime = plugin.option(&OPTION_MIN_LIFETIME)?; + let max_client_to_self_delay = plugin.option(&OPTION_MAX_CLIENT_TO_SELF_DELAY)?; + let min_payment_size_msat = plugin.option(&OPTION_MIN_PAYMENT_SIZE_MSAT)?; + let max_payment_size_msat = plugin.option(&OPTION_MAX_PAYMENT_SIZE_MSAT)?; + let channel_capacity_msat = plugin.option(&OPTION_CHANNEL_CAPACITY_MSAT)?; + + if min_fee_msat < 0 { + bail!( + "`{}` must be non-negative, got {}", + OPTION_MIN_FEE_MSAT.name, + min_fee_msat + ); + } + if proportional < 0 || proportional > 1_000_000 { + bail!( + "`{}` must be between 0 and 1000000, got {}", + OPTION_PROPORTIONAL_PPM.name, + proportional + ); + } + if valid_until_hours <= 0 { + bail!( + "`{}` must be positive, got {}", + OPTION_VALID_UNTIL_HOURS.name, + valid_until_hours + ); + } + if min_lifetime < 0 || min_lifetime > i64::from(u32::MAX) { + bail!( + "`{}` must be between 0 and {}, got {}", + OPTION_MIN_LIFETIME.name, + u32::MAX, + min_lifetime + ); + } + if max_client_to_self_delay < 0 || max_client_to_self_delay > i64::from(u32::MAX) { + bail!( + "`{}` must be between 0 and {}, got {}", + OPTION_MAX_CLIENT_TO_SELF_DELAY.name, + u32::MAX, + max_client_to_self_delay + ); + } + if min_payment_size_msat < 0 { + bail!( + "`{}` must be non-negative, got {}", + OPTION_MIN_PAYMENT_SIZE_MSAT.name, + min_payment_size_msat + ); + } + if max_payment_size_msat <= min_payment_size_msat { + bail!( + "`{}` must be greater than `{}`, got {} <= {}", + OPTION_MAX_PAYMENT_SIZE_MSAT.name, + OPTION_MIN_PAYMENT_SIZE_MSAT.name, + max_payment_size_msat, + min_payment_size_msat + ); + } + if channel_capacity_msat <= 0 { + bail!( + "`{}` must be positive, got {}", + OPTION_CHANNEL_CAPACITY_MSAT.name, + channel_capacity_msat + ); + } + if channel_capacity_msat % 1_000 != 0 { + bail!( + "`{}` must be divisible by 1000 (whole satoshis), got {}", + OPTION_CHANNEL_CAPACITY_MSAT.name, + channel_capacity_msat + ); + } + + let state = State { + min_fee_msat: Msat::from_msat(min_fee_msat as u64), + proportional: Ppm::from_ppm(proportional as u32), + valid_until_hours, + min_lifetime: min_lifetime as u32, + max_client_to_self_delay: max_client_to_self_delay as u32, + min_payment_size_msat: Msat::from_msat(min_payment_size_msat as u64), + max_payment_size_msat: Msat::from_msat(max_payment_size_msat as u64), + channel_capacity_msat: channel_capacity_msat as u64, + }; + + let plugin = plugin.start(state).await?; + plugin.join().await + } else { + Ok(()) + } +} + +async fn on_getpolicy( + p: cln_plugin::Plugin, + v: serde_json::Value, +) -> Result { + let _req: Lsps2PolicyGetInfoRequest = + serde_json::from_value(v).unwrap_or(Lsps2PolicyGetInfoRequest { token: None }); + + let s = p.state(); + let offset = Duration::try_hours(s.valid_until_hours) + .ok_or_else(|| anyhow!("`{}` is out of range", OPTION_VALID_UNTIL_HOURS.name))?; + let valid_until = Utc::now() + .checked_add_signed(offset) + .ok_or_else(|| anyhow!("`{}` is out of range", OPTION_VALID_UNTIL_HOURS.name))?; + + let params = PolicyOpeningFeeParams { + min_fee_msat: s.min_fee_msat, + proportional: s.proportional, + valid_until, + min_lifetime: s.min_lifetime, + max_client_to_self_delay: s.max_client_to_self_delay, + min_payment_size_msat: s.min_payment_size_msat, + max_payment_size_msat: s.max_payment_size_msat, + }; + + let res = Lsps2PolicyGetInfoResponse { + policy_opening_fee_params_menu: vec![params], + client_rejected: false, + }; + Ok(serde_json::to_value(res)?) +} + +async fn on_getchannelcapacity( + p: cln_plugin::Plugin, + v: serde_json::Value, +) -> Result { + let _req: Lsps2PolicyGetChannelCapacityRequest = serde_json::from_value(v)?; + + let res = Lsps2PolicyGetChannelCapacityResponse { + channel_capacity_msat: Some(p.state().channel_capacity_msat), + }; + Ok(serde_json::to_value(res)?) +}