plugins: lsps: split up handlers
What changed, and why it matters
This commit is a routine code reorganization (refactor) for the LSPS (Lightning Service Provider Specification) plugin in Core Lightning. It splits business logic from Core-Lightning-specific RPC calls by moving RPC implementations into a new 'cln_adapters::rpc' module and separating HTLC handling, service handling, and provider traits. There is no indication of a security fix or vulnerability being patched.
No security action required. Treat as normal code maintenance; review for functional correctness during standard code review.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit refactors the lsps-plugin Rust code: introduces provider traits (BlockheightProvider, DatastoreProvider, LightningProvider, Lsps2OfferProvider), moves ClnApiRpc implementation into cln_adapters/rpc.rs, splits the former handler module into core/lsps2/htlc.rs, core/lsps2/provider.rs, and core/lsps2/service.rs, and adds unit tests. It also adds a ShortChannelIdJITExt helper for generating JIT SCIDs. No security-relevant behavioral changes are visible in the diff.
Changed components
plugins/lsps-plugin/src/cln_adapters/mod.rsplugins/lsps-plugin/src/cln_adapters/rpc.rsplugins/lsps-plugin/src/core/lsps2/htlc.rsplugins/lsps-plugin/src/core/lsps2/mod.rsplugins/lsps-plugin/src/core/lsps2/provider.rsplugins/lsps-plugin/src/core/lsps2/service.rsplugins/lsps-plugin/src/proto/lsps2.rsplugins/lsps-plugin/src/service.rsInspect captured patch +1731 / −7
diff --git a/plugins/lsps-plugin/src/cln_adapters/mod.rs b/plugins/lsps-plugin/src/cln_adapters/mod.rs
index 1ff4d2f3..240b47ae 100644
--- a/plugins/lsps-plugin/src/cln_adapters/mod.rs
+++ b/plugins/lsps-plugin/src/cln_adapters/mod.rs
@@ -1,3 +1,4 @@
pub mod hooks;
+pub mod rpc;
pub mod sender;
pub mod state;
diff --git a/plugins/lsps-plugin/src/cln_adapters/rpc.rs b/plugins/lsps-plugin/src/cln_adapters/rpc.rs
new file mode 100644
index 00000000..07dd1b00
--- /dev/null
+++ b/plugins/lsps-plugin/src/cln_adapters/rpc.rs
@@ -0,0 +1,304 @@
+use crate::{
+ core::lsps2::provider::{
+ Blockheight, BlockheightProvider, DatastoreProvider, LightningProvider, Lsps2OfferProvider,
+ },
+ proto::{
+ lsps0::Msat,
+ lsps2::{
+ DatastoreEntry, Lsps2PolicyGetChannelCapacityRequest,
+ Lsps2PolicyGetChannelCapacityResponse, Lsps2PolicyGetInfoRequest,
+ Lsps2PolicyGetInfoResponse, OpeningFeeParams,
+ },
+ },
+};
+use anyhow::{Context, Result};
+use async_trait::async_trait;
+use bitcoin::secp256k1::PublicKey;
+use cln_rpc::{
+ model::{
+ requests::{
+ DatastoreMode, DatastoreRequest, DeldatastoreRequest, FundchannelRequest,
+ GetinfoRequest, ListdatastoreRequest, ListpeerchannelsRequest,
+ },
+ responses::ListdatastoreResponse,
+ },
+ primitives::{Amount, AmountOrAll, ChannelState, Sha256, ShortChannelId},
+ ClnRpc,
+};
+use core::fmt;
+use serde::Serialize;
+use std::path::PathBuf;
+
+pub const DS_MAIN_KEY: &'static str = "lsps";
+pub const DS_SUB_KEY: &'static str = "lsps2";
+
+#[derive(Clone)]
+pub struct ClnApiRpc {
+ rpc_path: PathBuf,
+}
+
+impl ClnApiRpc {
+ pub fn new(rpc_path: PathBuf) -> Self {
+ Self { rpc_path }
+ }
+
+ async fn create_rpc(&self) -> Result<ClnRpc> {
+ ClnRpc::new(&self.rpc_path).await
+ }
+}
+
+#[async_trait]
+impl LightningProvider for ClnApiRpc {
+ async fn fund_jit_channel(
+ &self,
+ peer_id: &PublicKey,
+ amount: &Msat,
+ ) -> Result<(Sha256, String)> {
+ let mut rpc = self.create_rpc().await?;
+ let res = rpc
+ .call_typed(&FundchannelRequest {
+ announce: Some(false),
+ close_to: None,
+ compact_lease: None,
+ feerate: None,
+ minconf: None,
+ mindepth: Some(0),
+ push_msat: None,
+ request_amt: None,
+ reserve: None,
+ channel_type: Some(vec![12, 46, 50]),
+ utxos: None,
+ amount: AmountOrAll::Amount(Amount::from_msat(amount.msat())),
+ id: peer_id.to_owned(),
+ })
+ .await
+ .with_context(|| "calling fundchannel")?;
+ Ok((res.channel_id, res.txid))
+ }
+
+ async fn is_channel_ready(&self, peer_id: &PublicKey, channel_id: &Sha256) -> Result<bool> {
+ let mut rpc = self.create_rpc().await?;
+ let r = rpc
+ .call_typed(&ListpeerchannelsRequest {
+ id: Some(peer_id.to_owned()),
+ short_channel_id: None,
+ })
+ .await
+ .with_context(|| "calling listpeerchannels")?;
+
+ let chs = r
+ .channels
+ .iter()
+ .find(|&ch| ch.channel_id.is_some_and(|id| id == *channel_id));
+ if let Some(ch) = chs {
+ if ch.state == ChannelState::CHANNELD_NORMAL {
+ return Ok(true);
+ }
+ }
+
+ return Ok(false);
+ }
+}
+
+#[async_trait]
+impl DatastoreProvider for ClnApiRpc {
+ async fn store_buy_request(
+ &self,
+ scid: &ShortChannelId,
+ peer_id: &PublicKey,
+ opening_fee_params: &OpeningFeeParams,
+ expected_payment_size: &Option<Msat>,
+ ) -> Result<bool> {
+ let mut rpc = self.create_rpc().await?;
+ #[derive(Serialize)]
+ struct BorrowedDatastoreEntry<'a> {
+ peer_id: &'a PublicKey,
+ opening_fee_params: &'a OpeningFeeParams,
+ #[serde(borrow)]
+ expected_payment_size: &'a Option<Msat>,
+ }
+
+ let ds = BorrowedDatastoreEntry {
+ peer_id,
+ opening_fee_params,
+ expected_payment_size,
+ };
+ let json_str = serde_json::to_string(&ds)?;
+
+ let ds = DatastoreRequest {
+ generation: None,
+ hex: None,
+ mode: Some(DatastoreMode::MUST_CREATE),
+ string: Some(json_str),
+ key: vec![
+ DS_MAIN_KEY.to_string(),
+ DS_SUB_KEY.to_string(),
+ scid.to_string(),
+ ],
+ };
+
+ let _ = rpc
+ .call_typed(&ds)
+ .await
+ .map_err(anyhow::Error::new)
+ .with_context(|| "calling datastore")?;
+
+ Ok(true)
+ }
+
+ async fn get_buy_request(&self, scid: &ShortChannelId) -> Result<DatastoreEntry> {
+ let mut rpc = self.create_rpc().await?;
+ let key = vec![
+ DS_MAIN_KEY.to_string(),
+ DS_SUB_KEY.to_string(),
+ scid.to_string(),
+ ];
+ let res = rpc
+ .call_typed(&ListdatastoreRequest {
+ key: Some(key.clone()),
+ })
+ .await
+ .with_context(|| "calling listdatastore")?;
+
+ let (rec, _) = deserialize_by_key(&res, key)?;
+ Ok(rec)
+ }
+
+ async fn del_buy_request(&self, scid: &ShortChannelId) -> Result<()> {
+ let mut rpc = self.create_rpc().await?;
+ let key = vec![
+ DS_MAIN_KEY.to_string(),
+ DS_SUB_KEY.to_string(),
+ scid.to_string(),
+ ];
+
+ let _ = rpc
+ .call_typed(&DeldatastoreRequest {
+ generation: None,
+ key,
+ })
+ .await;
+
+ Ok(())
+ }
+}
+
+#[async_trait]
+impl Lsps2OfferProvider for ClnApiRpc {
+ async fn get_offer(
+ &self,
+ request: &Lsps2PolicyGetInfoRequest,
+ ) -> Result<Lsps2PolicyGetInfoResponse> {
+ let mut rpc = self.create_rpc().await?;
+ rpc.call_raw("lsps2-policy-getpolicy", request)
+ .await
+ .context("failed to call lsps2-policy-getpolicy")
+ }
+
+ async fn get_channel_capacity(
+ &self,
+ params: &Lsps2PolicyGetChannelCapacityRequest,
+ ) -> Result<Lsps2PolicyGetChannelCapacityResponse> {
+ let mut rpc = self.create_rpc().await?;
+ rpc.call_raw("lsps2-policy-getchannelcapacity", params)
+ .await
+ .map_err(anyhow::Error::new)
+ .with_context(|| "calling lsps2-policy-getchannelcapacity")
+ }
+}
+
+#[async_trait]
+impl BlockheightProvider for ClnApiRpc {
+ async fn get_blockheight(&self) -> Result<Blockheight> {
+ let mut rpc = self.create_rpc().await?;
+ let info = rpc
+ .call_typed(&GetinfoRequest {})
+ .await
+ .map_err(anyhow::Error::new)
+ .with_context(|| "calling getinfo")?;
+ Ok(info.blockheight)
+ }
+}
+
+#[derive(Debug)]
+pub enum DsError {
+ /// No datastore entry with this exact key.
+ NotFound { key: Vec<String> },
+ /// Entry existed but had neither `string` nor `hex`.
+ MissingValue { key: Vec<String> },
+ /// JSON parse failed (from `string` or decoded `hex`).
+ JsonParse {
+ key: Vec<String>,
+ source: serde_json::Error,
+ },
+ /// Hex decode failed.
+ HexDecode {
+ key: Vec<String>,
+ source: hex::FromHexError,
+ },
+}
+
+impl fmt::Display for DsError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ DsError::NotFound { key } => write!(f, "no datastore entry for key {:?}", key),
+ DsError::MissingValue { key } => write!(
+ f,
+ "datastore entry had neither `string` nor `hex` for key {:?}",
+ key
+ ),
+ DsError::JsonParse { key, source } => {
+ write!(f, "failed to parse JSON at key {:?}: {}", key, source)
+ }
+ DsError::HexDecode { key, source } => {
+ write!(f, "failed to decode hex at key {:?}: {}", key, source)
+ }
+ }
+ }
+}
+
+impl std::error::Error for DsError {}
+
+pub fn deserialize_by_key<K>(
+ resp: &ListdatastoreResponse,
+ key: K,
+) -> std::result::Result<(DatastoreEntry, Option<u64>), DsError>
+where
+ K: AsRef<[String]>,
+{
+ let wanted: &[String] = key.as_ref();
+
+ let ds = resp
+ .datastore
+ .iter()
+ .find(|d| d.key.as_slice() == wanted)
+ .ok_or_else(|| DsError::NotFound {
+ key: wanted.to_vec(),
+ })?;
+
+ // Prefer `string`, fall back to `hex`
+ if let Some(s) = &ds.string {
+ let value = serde_json::from_str::<DatastoreEntry>(s).map_err(|e| DsError::JsonParse {
+ key: ds.key.clone(),
+ source: e,
+ })?;
+ return Ok((value, ds.generation));
+ }
+
+ if let Some(hx) = &ds.hex {
+ let bytes = hex::decode(hx).map_err(|e| DsError::HexDecode {
+ key: ds.key.clone(),
+ source: e,
+ })?;
+ let value =
+ serde_json::from_slice::<DatastoreEntry>(&bytes).map_err(|e| DsError::JsonParse {
+ key: ds.key.clone(),
+ source: e,
+ })?;
+ return Ok((value, ds.generation));
+ }
+
+ Err(DsError::MissingValue {
+ key: ds.key.clone(),
+ })
+}
diff --git a/plugins/lsps-plugin/src/core/lsps2/htlc.rs b/plugins/lsps-plugin/src/core/lsps2/htlc.rs
new file mode 100644
index 00000000..360bfb05
--- /dev/null
+++ b/plugins/lsps-plugin/src/core/lsps2/htlc.rs
@@ -0,0 +1,767 @@
+use crate::{
+ core::lsps2::provider::{DatastoreProvider, LightningProvider, Lsps2OfferProvider},
+ lsps2::cln::{HtlcAcceptedRequest, HtlcAcceptedResponse, TLV_FORWARD_AMT},
+ proto::{
+ lsps0::Msat,
+ lsps2::{
+ compute_opening_fee,
+ failure_codes::{TEMPORARY_CHANNEL_FAILURE, UNKNOWN_NEXT_PEER},
+ Lsps2PolicyGetChannelCapacityRequest,
+ },
+ },
+};
+use anyhow::Result;
+use bitcoin::hashes::Hash as _;
+use chrono::Utc;
+use log::{debug, warn};
+use std::time::Duration;
+
+pub struct HtlcAcceptedHookHandler<A> {
+ api: A,
+ htlc_minimum_msat: u64,
+ backoff_listpeerchannels: Duration,
+}
+
+impl<A> HtlcAcceptedHookHandler<A> {
+ pub fn new(api: A, htlc_minimum_msat: u64) -> Self {
+ Self {
+ api,
+ htlc_minimum_msat,
+ backoff_listpeerchannels: Duration::from_secs(10),
+ }
+ }
+}
+impl<A: DatastoreProvider + Lsps2OfferProvider + LightningProvider> HtlcAcceptedHookHandler<A> {
+ pub async fn handle(&self, req: HtlcAcceptedRequest) -> Result<HtlcAcceptedResponse> {
+ let scid = match req.onion.short_channel_id {
+ Some(scid) => scid,
+ None => {
+ // We are the final destination of this htlc.
+ return Ok(HtlcAcceptedResponse::continue_(None, None, None));
+ }
+ };
+
+ // A) Is this SCID one that we care about?
+ let ds_rec = match self.api.get_buy_request(&scid).await {
+ Ok(rec) => rec,
+ Err(_) => {
+ return Ok(HtlcAcceptedResponse::continue_(None, None, None));
+ }
+ };
+
+ // Fixme: Check that we don't have a channel yet with the peer that we await to
+ // become READY to use.
+ // ---
+
+ // Fixme: We only accept no-mpp for now, mpp and other flows will be added later on
+ // Fixme: We continue mpp for now to let the test mock handle the htlc, as we need
+ // to test the client implementation for mpp payments.
+ if ds_rec.expected_payment_size.is_some() {
+ warn!("mpp payments are not implemented yet");
+ return Ok(HtlcAcceptedResponse::continue_(None, None, None));
+ // return Ok(HtlcAcceptedResponse::fail(
+ // Some(UNKNOWN_NEXT_PEER.to_string()),
+ // None,
+ // ));
+ }
+
+ // B) Is the fee option menu still valid?
+ let now = Utc::now();
+ if now >= ds_rec.opening_fee_params.valid_until {
+ // Not valid anymore, remove from DS and fail HTLC.
+ let _ = self.api.del_buy_request(&scid).await;
+ return Ok(HtlcAcceptedResponse::fail(
+ Some(TEMPORARY_CHANNEL_FAILURE.to_string()),
+ None,
+ ));
+ }
+
+ // C) Is the amount in the boundaries of the fee menu?
+ if req.htlc.amount_msat.msat() < ds_rec.opening_fee_params.min_fee_msat.msat()
+ || req.htlc.amount_msat.msat() > ds_rec.opening_fee_params.max_payment_size_msat.msat()
+ {
+ // No! reject the HTLC.
+ debug!("amount_msat for scid: {}, was too low or to high", scid);
+ return Ok(HtlcAcceptedResponse::fail(
+ Some(UNKNOWN_NEXT_PEER.to_string()),
+ None,
+ ));
+ }
+
+ // D) Check that the amount_msat covers the opening fee (only for non-mpp right now)
+ let opening_fee = if let Some(opening_fee) = compute_opening_fee(
+ req.htlc.amount_msat.msat(),
+ ds_rec.opening_fee_params.min_fee_msat.msat(),
+ ds_rec.opening_fee_params.proportional.ppm() as u64,
+ ) {
+ if opening_fee + self.htlc_minimum_msat >= req.htlc.amount_msat.msat() {
+ debug!("amount_msat for scid: {}, does not cover opening fee", scid);
+ return Ok(HtlcAcceptedResponse::fail(
+ Some(UNKNOWN_NEXT_PEER.to_string()),
+ None,
+ ));
+ }
+ opening_fee
+ } else {
+ // The computation overflowed.
+ debug!("amount_msat for scid: {}, was too low or to high", scid);
+ return Ok(HtlcAcceptedResponse::fail(
+ Some(UNKNOWN_NEXT_PEER.to_string()),
+ None,
+ ));
+ };
+
+ // E) We made it, open a channel to the peer.
+ let ch_cap_req = Lsps2PolicyGetChannelCapacityRequest {
+ opening_fee_params: ds_rec.opening_fee_params,
+ init_payment_size: Msat::from_msat(req.htlc.amount_msat.msat()),
+ scid,
+ };
+ let ch_cap_res = match self.api.get_channel_capacity(&ch_cap_req).await {
+ Ok(r) => r,
+ Err(e) => {
+ warn!("failed to get channel capacity for scid {}: {}", scid, e);
+ return Ok(HtlcAcceptedResponse::fail(
+ Some(UNKNOWN_NEXT_PEER.to_string()),
+ None,
+ ));
+ }
+ };
+
+ let cap = match ch_cap_res.channel_capacity_msat {
+ Some(c) => Msat::from_msat(c),
+ None => {
+ debug!("policy giver does not allow channel for scid {}", scid);
+ return Ok(HtlcAcceptedResponse::fail(
+ Some(UNKNOWN_NEXT_PEER.to_string()),
+ None,
+ ));
+ }
+ };
+
+ // We take the policy-giver seriously, if the capacity is too low, we
+ // still try to open the channel.
+ // Fixme: We may check that the capacity is ge than the
+ // (amount_msat - opening fee) in the future.
+ // Fixme: Make this configurable, maybe return the whole request from
+ // the policy giver?
+ let channel_id = match self.api.fund_jit_channel(&ds_rec.peer_id, &cap).await {
+ Ok((channel_id, _)) => channel_id,
+ Err(_) => {
+ return Ok(HtlcAcceptedResponse::fail(
+ Some(UNKNOWN_NEXT_PEER.to_string()),
+ None,
+ ));
+ }
+ };
+
+ // F) Wait for the peer to send `channel_ready`.
+ // Fixme: Use event to check for channel ready,
+ // Fixme: Check for htlc timeout if peer refuses to send "ready".
+ // Fixme: handle unexpected channel states.
+ loop {
+ match self
+ .api
+ .is_channel_ready(&ds_rec.peer_id, &channel_id)
+ .await
+ {
+ Ok(true) => break,
+ Ok(false) | Err(_) => tokio::time::sleep(self.backoff_listpeerchannels).await,
+ };
+ }
+
+ // G) We got a working channel, deduct fee and forward htlc.
+ let deducted_amt_msat = req.htlc.amount_msat.msat() - opening_fee;
+ let mut payload = req.onion.payload.clone();
+ payload.set_tu64(TLV_FORWARD_AMT, deducted_amt_msat);
+
+ // It is okay to unwrap the next line as we do not have duplicate entries.
+ let payload_bytes = payload.to_bytes().unwrap();
+ debug!("about to send payload: {:02x?}", &payload_bytes);
+
+ let mut extra_tlvs = req.htlc.extra_tlvs.unwrap_or_default().clone();
+ extra_tlvs.set_u64(65537, opening_fee);
+ let extra_tlvs_bytes = extra_tlvs.to_bytes().unwrap();
+ debug!("extra_tlv: {:02x?}", extra_tlvs_bytes);
+
+ Ok(HtlcAcceptedResponse::continue_(
+ Some(payload_bytes),
+ Some(channel_id.as_byte_array().to_vec()),
+ Some(extra_tlvs_bytes),
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::lsps2::cln::tlv::TlvStream;
+ use crate::lsps2::cln::Htlc;
+ use crate::lsps2::cln::HtlcAcceptedResult;
+ use crate::lsps2::cln::Onion;
+ use crate::proto::lsps0::{Msat, Ppm, ShortChannelId};
+ use crate::proto::lsps2::{
+ DatastoreEntry, Lsps2PolicyGetChannelCapacityResponse, Lsps2PolicyGetInfoRequest,
+ Lsps2PolicyGetInfoResponse, OpeningFeeParams, Promise,
+ };
+ use anyhow::{anyhow, Result as AnyResult};
+ use async_trait::async_trait;
+ use bitcoin::hashes::{sha256::Hash as Sha256, Hash};
+ use bitcoin::secp256k1::PublicKey;
+ use chrono::{TimeZone, Utc};
+ use cln_rpc::primitives::Amount;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use std::sync::{Arc, Mutex};
+ use std::time::Duration;
+
+ fn test_peer_id() -> PublicKey {
+ "0279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798"
+ .parse()
+ .unwrap()
+ }
+
+ fn test_scid() -> ShortChannelId {
+ ShortChannelId::from(123456789u64)
+ }
+
+ fn test_channel_id() -> Sha256 {
+ Sha256::from_byte_array([1u8; 32])
+ }
+
+ fn valid_opening_fee_params() -> OpeningFeeParams {
+ OpeningFeeParams {
+ min_fee_msat: Msat(2_000),
+ proportional: Ppm(10_000), // 1%
+ valid_until: Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap(),
+ min_lifetime: 1000,
+ max_client_to_self_delay: 2016,
+ min_payment_size_msat: Msat(1_000_000),
+ max_payment_size_msat: Msat(100_000_000),
+ promise: Promise::try_from("test").unwrap(),
+ }
+ }
+
+ fn expired_opening_fee_params() -> OpeningFeeParams {
+ OpeningFeeParams {
+ valid_until: Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(),
+ ..valid_opening_fee_params()
+ }
+ }
+
+ fn test_datastore_entry(expected_payment_size: Option<Msat>) -> DatastoreEntry {
+ DatastoreEntry {
+ peer_id: test_peer_id(),
+ opening_fee_params: valid_opening_fee_params(),
+ expected_payment_size,
+ }
+ }
+
+ fn test_htlc_request(scid: Option<ShortChannelId>, amount_msat: u64) -> HtlcAcceptedRequest {
+ HtlcAcceptedRequest {
+ onion: Onion {
+ short_channel_id: scid,
+ payload: TlvStream::default(),
+ next_onion: vec![],
+ forward_msat: None,
+ outgoing_cltv_value: None,
+ shared_secret: vec![],
+ total_msat: None,
+ type_: None,
+ },
+ htlc: Htlc {
+ amount_msat: Amount::from_msat(amount_msat),
+ cltv_expiry: 800_100,
+ cltv_expiry_relative: 40,
+ payment_hash: vec![0u8; 32],
+ extra_tlvs: None,
+ short_channel_id: test_scid(),
+ id: 0,
+ },
+ forward_to: None,
+ }
+ }
+
+
+ #[derive(Default, Clone)]
+ struct MockApi {
+ // Datastore
+ buy_request: Arc<Mutex<Option<DatastoreEntry>>>,
+ buy_request_error: Arc<Mutex<bool>>,
+ del_called: Arc<AtomicUsize>,
+
+ // Policy
+ channel_capacity: Arc<Mutex<Option<Option<u64>>>>, // Some(Some(cap)), Some(None) = denied, None = error
+ channel_capacity_error: Arc<Mutex<bool>>,
+
+ // Lightning
+ fund_result: Arc<Mutex<Option<(Sha256, String)>>>,
+ fund_error: Arc<Mutex<bool>>,
+ channel_ready: Arc<Mutex<bool>>,
+ channel_ready_checks: Arc<AtomicUsize>,
+ }
+
+ impl MockApi {
+ fn new() -> Self {
+ Self::default()
+ }
+
+ fn with_buy_request(self, entry: DatastoreEntry) -> Self {
+ *self.buy_request.lock().unwrap() = Some(entry);
+ self
+ }
+
+ fn with_no_buy_request(self) -> Self {
+ *self.buy_request_error.lock().unwrap() = true;
+ self
+ }
+
+ fn with_channel_capacity(self, capacity_msat: u64) -> Self {
+ *self.channel_capacity.lock().unwrap() = Some(Some(capacity_msat));
+ self
+ }
+
+ fn with_channel_denied(self) -> Self {
+ *self.channel_capacity.lock().unwrap() = Some(None);
+ self
+ }
+
+ fn with_channel_capacity_error(self) -> Self {
+ *self.channel_capacity_error.lock().unwrap() = true;
+ self
+ }
+
+ fn with_fund_result(self, channel_id: Sha256, txid: &str) -> Self {
+ *self.fund_result.lock().unwrap() = Some((channel_id, txid.to_string()));
+ self
+ }
+
+ fn with_fund_error(self) -> Self {
+ *self.fund_error.lock().unwrap() = true;
+ self
+ }
+
+ fn with_channel_ready(self, ready: bool) -> Self {
+ *self.channel_ready.lock().unwrap() = ready;
+ self
+ }
+
+ fn del_call_count(&self) -> usize {
+ self.del_called.load(Ordering::SeqCst)
+ }
+
+ fn channel_ready_check_count(&self) -> usize {
+ self.channel_ready_checks.load(Ordering::SeqCst)
+ }
+ }
+
+ #[async_trait]
+ impl DatastoreProvider for MockApi {
+ async fn store_buy_request(
+ &self,
+ _scid: &ShortChannelId,
+ _peer_id: &PublicKey,
+ _fee_params: &OpeningFeeParams,
+ _payment_size: &Option<Msat>,
+ ) -> AnyResult<bool> {
+ unimplemented!("not needed for HTLC tests")
+ }
+
+ async fn get_buy_request(&self, _scid: &ShortChannelId) -> AnyResult<DatastoreEntry> {
+ if *self.buy_request_error.lock().unwrap() {
+ return Err(anyhow!("not found"));
+ }
+ self.buy_request
+ .lock()
+ .unwrap()
+ .clone()
+ .ok_or_else(|| anyhow!("not found"))
+ }
+
+ async fn del_buy_request(&self, _scid: &ShortChannelId) -> AnyResult<()> {
+ self.del_called.fetch_add(1, Ordering::SeqCst);
+ Ok(())
+ }
+ }
+
+ #[async_trait]
+ impl Lsps2OfferProvider for MockApi {
+ async fn get_offer(
+ &self,
+ _request: &Lsps2PolicyGetInfoRequest,
+ ) -> AnyResult<Lsps2PolicyGetInfoResponse> {
+ unimplemented!("not needed for HTLC tests")
+ }
+
+ async fn get_channel_capacity(
+ &self,
+ _params: &Lsps2PolicyGetChannelCapacityRequest,
+ ) -> AnyResult<Lsps2PolicyGetChannelCapacityResponse> {
+ if *self.channel_capacity_error.lock().unwrap() {
+ return Err(anyhow!("capacity error"));
+ }
+ let cap = self
+ .channel_capacity
+ .lock()
+ .unwrap()
+ .ok_or_else(|| anyhow!("no capacity set"))?;
+ Ok(Lsps2PolicyGetChannelCapacityResponse {
+ channel_capacity_msat: cap,
+ })
+ }
+ }
+
+ #[async_trait]
+ impl LightningProvider for MockApi {
+ async fn fund_jit_channel(
+ &self,
+ _peer_id: &PublicKey,
+ _amount: &Msat,
+ ) -> AnyResult<(Sha256, String)> {
+ if *self.fund_error.lock().unwrap() {
+ return Err(anyhow!("fund error"));
+ }
+ self.fund_result
+ .lock()
+ .unwrap()
+ .clone()
+ .ok_or_else(|| anyhow!("no fund result set"))
+ }
+
+ async fn is_channel_ready(
+ &self,
+ _peer_id: &PublicKey,
+ _channel_id: &Sha256,
+ ) -> AnyResult<bool> {
+ self.channel_ready_checks.fetch_add(1, Ordering::SeqCst);
+ Ok(*self.channel_ready.lock().unwrap())
+ }
+ }
+
+ fn handler(api: MockApi) -> HtlcAcceptedHookHandler<MockApi> {
+ HtlcAcceptedHookHandler {
+ api,
+ htlc_minimum_msat: 1_000,
+ backoff_listpeerchannels: Duration::from_millis(1), // Fast for tests
+ }
+ }
+
+ #[tokio::test]
+ async fn continues_when_no_scid() {
+ let api = MockApi::new();
+ let h = handler(api);
+
+ let req = test_htlc_request(None, 10_000_000);
+ let result = h.handle(req).await.unwrap();
+
+ assert_eq!(result.result, HtlcAcceptedResult::Continue);
+ assert!(result.payload.is_none());
+ assert!(result.forward_to.is_none());
+ }
+
+ #[tokio::test]
+ async fn continues_when_scid_not_found() {
+ let api = MockApi::new().with_no_buy_request();
+ let h = handler(api);
+
+ let req = test_htlc_request(Some(test_scid()), 10_000_000);
+ let result = h.handle(req).await.unwrap();
+
+ assert_eq!(result.result, HtlcAcceptedResult::Continue);
+ assert!(result.payload.is_none());
+ }
+
+ #[tokio::test]
+ async fn continues_when_mpp_payment() {
+ let entry = test_datastore_entry(Some(Msat(50_000_000))); // MPP = has expected size
+ let api = MockApi::new().with_buy_request(entry);
+ let h = handler(api);
+
+ let req = test_htlc_request(Some(test_scid()), 10_000_000);
+ let result = h.handle(req).await.unwrap();
+
+ assert_eq!(result.result, HtlcAcceptedResult::Continue);
+ }
+
+ #[tokio::test]
+ async fn fails_when_offer_expired() {
+ let mut entry = test_datastore_entry(None);
+ entry.opening_fee_params = expired_opening_fee_params();
+ let api = MockApi::new().with_buy_request(entry);
+ let h = handler(api.clone());
+
+ let req = test_htlc_request(Some(test_scid()), 10_000_000);
+ let result = h.handle(req).await.unwrap();
+
+ assert_eq!(result.result, HtlcAcceptedResult::Fail);
+ assert_eq!(
+ result.failure_message.unwrap(),
+ TEMPORARY_CHANNEL_FAILURE.to_string()
+ );
+ assert_eq!(api.del_call_count(), 1); // Should delete expired entry
+ }
+
+ #[tokio::test]
+ async fn fails_when_amount_below_min_fee() {
+ let entry = test_datastore_entry(None);
+ let api = MockApi::new().with_buy_request(entry);
+ let h = handler(api);
+
+ // min_fee_msat is 2_000
+ let req = test_htlc_request(Some(test_scid()), 1_000);
+ let result = h.handle(req).await.unwrap();
+
+ assert_eq!(result.result, HtlcAcceptedResult::Fail);
+ assert_eq!(
+ result.failure_message.unwrap(),
+ UNKNOWN_NEXT_PEER.to_string()
+ );
+ }
+
+ #[tokio::test]
+ async fn fails_when_amount_above_max() {
+ let entry = test_datastore_entry(None);
+ let api = MockApi::new().with_buy_request(entry);
+ let h = handler(api);
+
+ // max_payment_size_msat is 100_000_000
+ let req = test_htlc_request(Some(test_scid()), 200_000_000);
+ let result = h.handle(req).await.unwrap();
+
+ assert_eq!(result.result, HtlcAcceptedResult::Fail);
+ assert_eq!(
+ result.failure_message.unwrap(),
+ UNKNOWN_NEXT_PEER.to_string()
+ );
+ }
+
+ #[tokio::test]
+ async fn fails_when_amount_doesnt_cover_fee_plus_minimum() {
+ let entry = test_datastore_entry(None);
+ let api = MockApi::new().with_buy_request(entry);
+ let h = handler(api);
+
+ // min_fee = 2_000, htlc_minimum = 1_000
+ // Amount must be > fee + htlc_minimum
+ // At 3_000: fee ~= 2_000 + (3_000 * 10_000 / 1_000_000) = 2_030
+ // 2_030 + 1_000 = 3_030 > 3_000, so should fail
+ let req = test_htlc_request(Some(test_scid()), 3_000);
+ let result = h.handle(req).await.unwrap();
+
+ assert_eq!(result.result, HtlcAcceptedResult::Fail);
+ assert_eq!(
+ result.failure_message.unwrap(),
+ UNKNOWN_NEXT_PEER.to_string()
+ );
+ }
+
+ #[tokio::test]
+ async fn fails_when_fee_computation_overflows() {
+ let mut entry = test_datastore_entry(None);
+ entry.opening_fee_params.min_fee_msat = Msat(u64::MAX / 2);
+ entry.opening_fee_params.proportional = Ppm(u32::MAX);
+ entry.opening_fee_params.min_payment_size_msat = Msat(1);
+ entry.opening_fee_params.max_payment_size_msat = Msat(u64::MAX);
+
+ let api = MockApi::new().with_buy_request(entry);
+ let h = handler(api);
+
+ let req = test_htlc_request(Some(test_scid()), u64::MAX / 2);
+ let result = h.handle(req).await.unwrap();
+
+ assert_eq!(result.result, HtlcAcceptedResult::Fail);
+ assert_eq!(
+ result.failure_message.unwrap(),
+ UNKNOWN_NEXT_PEER.to_string()
+ );
+ }
+
+ #[tokio::test]
+ async fn fails_when_channel_capacity_errors() {
+ let entry = test_datastore_entry(None);
+ let api = MockApi::new()
+ .with_buy_request(entry)
+ .with_channel_capacity_error();
+ let h = handler(api);
+
+ let req = test_htlc_request(Some(test_scid()), 10_000_000);
+ let result = h.handle(req).await.unwrap();
+
+ assert_eq!(result.result, HtlcAcceptedResult::Fail);
+ assert_eq!(
+ result.failure_message.unwrap(),
+ UNKNOWN_NEXT_PEER.to_string()
+ );
+ }
+
+ #[tokio::test]
+ async fn fails_when_policy_denies_channel() {
+ let entry = test_datastore_entry(None);
+ let api = MockApi::new().with_buy_request(entry).with_channel_denied();
+ let h = handler(api);
+
+ let req = test_htlc_request(Some(test_scid()), 10_000_000);
+ let result = h.handle(req).await.unwrap();
+
+ assert_eq!(result.result, HtlcAcceptedResult::Fail);
+ assert_eq!(
+ result.failure_message.unwrap(),
+ UNKNOWN_NEXT_PEER.to_string()
+ );
+ }
+
+ #[tokio::test]
+ async fn fails_when_fund_channel_errors() {
+ let entry = test_datastore_entry(None);
+ let api = MockApi::new()
+ .with_buy_request(entry)
+ .with_channel_capacity(50_000_000)
+ .with_fund_error();
+ let h = handler(api);
+
+ let req = test_htlc_request(Some(test_scid()), 10_000_000);
+ let result = h.handle(req).await.unwrap();
+
+ assert_eq!(result.result, HtlcAcceptedResult::Fail);
+ assert_eq!(
+ result.failure_message.unwrap(),
+ UNKNOWN_NEXT_PEER.to_string()
+ );
+ }
+
+ #[tokio::test]
+ async fn success_flow_continues_with_modified_payload() {
+ let entry = test_datastore_entry(None);
+ let api = MockApi::new()
+ .with_buy_request(entry)
+ .with_channel_capacity(50_000_000)
+ .with_fund_result(test_channel_id(), "txid123")
+ .with_channel_ready(true);
+ let h = handler(api.clone());
+
+ let req = test_htlc_request(Some(test_scid()), 10_000_000);
+ let result = h.handle(req).await.unwrap();
+
+ assert_eq!(result.result, HtlcAcceptedResult::Continue);
+ assert!(result.payload.is_some());
+ assert!(result.forward_to.is_some());
+ assert!(result.extra_tlvs.is_some());
+
+ // Channel ID should match
+ assert_eq!(
+ result.forward_to.unwrap(),
+ test_channel_id().as_byte_array().to_vec()
+ );
+ }
+
+ #[tokio::test]
+ async fn polls_until_channel_ready() {
+ let entry = test_datastore_entry(None);
+ let api = MockApi::new()
+ .with_buy_request(entry)
+ .with_channel_capacity(50_000_000)
+ .with_fund_result(test_channel_id(), "txid123")
+ .with_channel_ready(false);
+
+ let h = handler(api.clone());
+
+ // Spawn handler, will block on channel ready
+ let handle = tokio::spawn(async move {
+ let req = test_htlc_request(Some(test_scid()), 10_000_000);
+ h.handle(req).await
+ });
+
+ // Let it poll a few times
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ assert!(api.channel_ready_check_count() > 1);
+
+ // Now make channel ready
+ *api.channel_ready.lock().unwrap() = true;
+
+ let result = handle.await.unwrap().unwrap();
+ assert_eq!(result.result, HtlcAcceptedResult::Continue);
+ }
+
+ #[tokio::test]
+ async fn deducts_fee_from_forward_amount() {
+ let entry = test_datastore_entry(None);
+ let api = MockApi::new()
+ .with_buy_request(entry)
+ .with_channel_capacity(50_000_000)
+ .with_fund_result(test_channel_id(), "txid123")
+ .with_channel_ready(true);
+ let h = handler(api);
+
+ let amount_msat = 10_000_000u64;
+ let req = test_htlc_request(Some(test_scid()), amount_msat);
+ let result = h.handle(req).await.unwrap();
+
+ assert_eq!(result.result, HtlcAcceptedResult::Continue);
+
+ // Verify payload contains deducted amount
+ // fee = max(min_fee, amount * proportional / 1_000_000)
+ // fee = max(2_000, 10_000_000 * 10_000 / 1_000_000) = max(2_000, 100_000) = 100_000
+ // deducted = 10_000_000 - 100_000 = 9_900_000
+ let payload_bytes = result.payload.unwrap();
+ let payload = TlvStream::from_bytes(&payload_bytes).unwrap();
+ let forward_amt = payload.get_tu64(TLV_FORWARD_AMT).unwrap();
+ assert_eq!(forward_amt, Some(9_900_000));
+ }
+
+ #[tokio::test]
+ async fn extra_tlvs_contain_opening_fee() {
+ let entry = test_datastore_entry(None);
+ let api = MockApi::new()
+ .with_buy_request(entry)
+ .with_channel_capacity(50_000_000)
+ .with_fund_result(test_channel_id(), "txid123")
+ .with_channel_ready(true);
+ let h = handler(api);
+
+ let req = test_htlc_request(Some(test_scid()), 10_000_000);
+ let result = h.handle(req).await.unwrap();
+
+ let extra_tlvs_bytes = result.extra_tlvs.unwrap();
+ let extra_tlvs = TlvStream::from_bytes(&extra_tlvs_bytes).unwrap();
+
+ // Opening fee should be in TLV 65537
+ let opening_fee = extra_tlvs.get_u64(65537).unwrap();
+ assert_eq!(opening_fee, Some(100_000)); // Same fee calculation as above
+ }
+
+ #[tokio::test]
+ async fn handles_minimum_valid_amount() {
+ let entry = test_datastore_entry(None);
+ let api = MockApi::new()
+ .with_buy_request(entry)
+ .with_channel_capacity(50_000_000)
+ .with_fund_result(test_channel_id(), "txid123")
+ .with_channel_ready(true);
+ let h = handler(api);
+
+ // Just enough to cover fee + htlc_minimum
+ // fee at 1_000_000 = max(2_000, 1_000_000 * 10_000 / 1_000_000) = max(2_000, 10_000) = 10_000
+ // Need: fee + htlc_minimum < amount
+ // 10_000 + 1_000 = 11_000 < 1_000_000 ✓
+ let req = test_htlc_request(Some(test_scid()), 1_000_000);
+ let result = h.handle(req).await.unwrap();
+
+ assert_eq!(result.result, HtlcAcceptedResult::Continue);
+ }
+
+ #[tokio::test]
+ async fn handles_maximum_valid_amount() {
+ let entry = test_datastore_entry(None);
+ let api = MockApi::new()
+ .with_buy_request(entry)
+ .with_channel_capacity(200_000_000)
+ .with_fund_result(test_channel_id(), "txid123")
+ .with_channel_ready(true);
+ let h = handler(api);
+
+ // max_payment_size_msat is 100_000_000
+ let req = test_htlc_request(Some(test_scid()), 100_000_000);
+ let result = h.handle(req).await.unwrap();
+
+ assert_eq!(result.result, HtlcAcceptedResult::Continue);
+ }
+}
diff --git a/plugins/lsps-plugin/src/core/lsps2/mod.rs b/plugins/lsps-plugin/src/core/lsps2/mod.rs
index 035e4bd1..18bf1cb5 100644
--- a/plugins/lsps-plugin/src/core/lsps2/mod.rs
+++ b/plugins/lsps-plugin/src/core/lsps2/mod.rs
@@ -1,2 +1,3 @@
-pub mod handler;
+pub mod htlc;
+pub mod provider;
pub mod service;
diff --git a/plugins/lsps-plugin/src/core/lsps2/provider.rs b/plugins/lsps-plugin/src/core/lsps2/provider.rs
new file mode 100644
index 00000000..6466630a
--- /dev/null
+++ b/plugins/lsps-plugin/src/core/lsps2/provider.rs
@@ -0,0 +1,53 @@
+use anyhow::Result;
+use async_trait::async_trait;
+use bitcoin::hashes::sha256::Hash;
+use bitcoin::secp256k1::PublicKey;
+
+use crate::proto::{
+ lsps0::{Msat, ShortChannelId},
+ lsps2::{
+ DatastoreEntry, Lsps2PolicyGetChannelCapacityRequest,
+ Lsps2PolicyGetChannelCapacityResponse, Lsps2PolicyGetInfoRequest,
+ Lsps2PolicyGetInfoResponse, OpeningFeeParams,
+ },
+};
+
+pub type Blockheight = u32;
+
+#[async_trait]
+pub trait BlockheightProvider: Send + Sync {
+ async fn get_blockheight(&self) -> Result<Blockheight>;
+}
+
+#[async_trait]
+pub trait DatastoreProvider: Send + Sync {
+ async fn store_buy_request(
+ &self,
+ scid: &ShortChannelId,
+ peer_id: &PublicKey,
+ offer: &OpeningFeeParams,
+ expected_payment_size: &Option<Msat>,
+ ) -> Result<bool>;
+
+ async fn get_buy_request(&self, scid: &ShortChannelId) -> Result<DatastoreEntry>;
+ async fn del_buy_request(&self, scid: &ShortChannelId) -> Result<()>;
+}
+
+#[async_trait]
+pub trait LightningProvider: Send + Sync {
+ async fn fund_jit_channel(&self, peer_id: &PublicKey, amount: &Msat) -> Result<(Hash, String)>;
+ async fn is_channel_ready(&self, peer_id: &PublicKey, channel_id: &Hash) -> Result<bool>;
+}
+
+#[async_trait]
+pub trait Lsps2OfferProvider: Send + Sync {
+ async fn get_offer(
+ &self,
+ request: &Lsps2PolicyGetInfoRequest,
+ ) -> Result<Lsps2PolicyGetInfoResponse>;
+
+ async fn get_channel_capacity(
+ &self,
+ params: &Lsps2PolicyGetChannelCapacityRequest,
+ ) -> Result<Lsps2PolicyGetChannelCapacityResponse>;
+}
diff --git a/plugins/lsps-plugin/src/core/lsps2/service.rs b/plugins/lsps-plugin/src/core/lsps2/service.rs
index 5c704bc0..a3ab3240 100644
--- a/plugins/lsps-plugin/src/core/lsps2/service.rs
+++ b/plugins/lsps-plugin/src/core/lsps2/service.rs
@@ -1,8 +1,16 @@
use crate::{
- core::{router::JsonRpcRouterBuilder, server::LspsProtocol},
+ core::{
+ lsps2::provider::{BlockheightProvider, DatastoreProvider, Lsps2OfferProvider},
+ router::JsonRpcRouterBuilder,
+ server::LspsProtocol,
+ },
proto::{
- jsonrpc::RpcError,
- lsps2::{Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest, Lsps2GetInfoResponse},
+ jsonrpc::{RpcError, RpcErrorExt as _},
+ lsps0::{LSPS0RpcErrorExt as _, ShortChannelId},
+ lsps2::{
+ Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest, Lsps2GetInfoResponse,
+ Lsps2PolicyGetInfoRequest, OpeningFeeParams, ShortChannelIdJITExt,
+ },
},
register_handler,
};
@@ -10,6 +18,8 @@ use async_trait::async_trait;
use bitcoin::secp256k1::PublicKey;
use std::sync::Arc;
+const DEFAULT_CLTV_EXPIRY_DELTA: u32 = 144;
+
#[async_trait]
pub trait Lsps2Handler: Send + Sync + 'static {
async fn handle_get_info(
@@ -37,3 +47,559 @@ where
2
}
}
+
+pub struct Lsps2ServiceHandler<A> {
+ pub api: Arc<A>,
+ pub promise_secret: [u8; 32],
+}
+
+impl<A> Lsps2ServiceHandler<A> {
+ pub fn new(api: Arc<A>, promise_seret: &[u8; 32]) -> Self {
+ Lsps2ServiceHandler {
+ api,
+ promise_secret: promise_seret.to_owned(),
+ }
+ }
+}
+
+#[async_trait]
+impl<A: DatastoreProvider + BlockheightProvider + Lsps2OfferProvider + 'static> Lsps2Handler
+ for Lsps2ServiceHandler<A>
+{
+ async fn handle_get_info(
+ &self,
+ request: Lsps2GetInfoRequest,
+ ) -> std::result::Result<Lsps2GetInfoResponse, RpcError> {
+ let res_data = self
+ .api
+ .get_offer(&Lsps2PolicyGetInfoRequest {
+ token: request.token.clone(),
+ })
+ .await
+ .map_err(|_| RpcError::internal_error("internal error"))?;
+
+ if res_data.client_rejected {
+ return Err(RpcError::client_rejected("client was rejected"));
+ };
+
+ let opening_fee_params_menu = res_data
+ .policy_opening_fee_params_menu
+ .iter()
+ .map(|v| v.with_promise(&self.promise_secret))
+ .collect::<Vec<OpeningFeeParams>>();
+
+ Ok(Lsps2GetInfoResponse {
+ opening_fee_params_menu,
+ })
+ }
+
+ async fn handle_buy(
+ &self,
+ peer_id: PublicKey,
+ request: Lsps2BuyRequest,
+ ) -> core::result::Result<Lsps2BuyResponse, RpcError> {
+ let fee_params = request.opening_fee_params;
+
+ // FIXME: In the future we should replace the \`None\` with a meaningful
+ // value that reflects the inbound capacity for this node from the
+ // public network for a better pre-condition check on the payment_size.
+ fee_params.validate(&self.promise_secret, request.payment_size_msat, None)?;
+
+ // Generate a tmp scid to identify jit channel request in htlc.
+ let blockheight = self
+ .api
+ .get_blockheight()
+ .await
+ .map_err(|_| RpcError::internal_error("internal error"))?;
+
+ // FIXME: Future task: Check that we don't conflict with any jit scid we
+ // already handed out -> Check datastore entries.
+ let jit_scid = ShortChannelId::generate_jit(blockheight, 12); // Approximately 2 hours in the future.
+
+ let ok = self
+ .api
+ .store_buy_request(&jit_scid, &peer_id, &fee_params, &request.payment_size_msat)
+ .await
+ .map_err(|_| RpcError::internal_error("internal error"))?;
+
+ if !ok {
+ return Err(RpcError::internal_error("internal error"))?;
+ }
+
+ Ok(Lsps2BuyResponse {
+ jit_channel_scid: jit_scid,
+ // We can make this configurable if necessary.
+ lsp_cltv_expiry_delta: DEFAULT_CLTV_EXPIRY_DELTA,
+ // We can implement the other mode later on as we might have to do
+ // some additional work on core-lightning to enable this.
+ client_trusts_lsp: false,
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::proto::lsps0::{Msat, Ppm};
+ use crate::proto::lsps2::{
+ DatastoreEntry, Lsps2PolicyGetChannelCapacityRequest,
+ Lsps2PolicyGetChannelCapacityResponse, Lsps2PolicyGetInfoResponse, OpeningFeeParams,
+ PolicyOpeningFeeParams, Promise,
+ };
+ use anyhow::{anyhow, Result as AnyResult};
+ use chrono::{TimeZone, Utc};
+ use std::sync::{Arc, Mutex};
+
+ fn test_peer_id() -> PublicKey {
+ "0279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798"
+ .parse()
+ .unwrap()
+ }
+
+ fn test_secret() -> [u8; 32] {
+ [0x42; 32]
+ }
+
+ fn test_policy_params() -> PolicyOpeningFeeParams {
+ PolicyOpeningFeeParams {
+ min_fee_msat: Msat(2_000),
+ proportional: Ppm(10_000),
+ valid_until: Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap(),
+ min_lifetime: 1000,
+ max_client_to_self_delay: 2016,
+ min_payment_size_msat: Msat(1_000_000),
+ max_payment_size_msat: Msat(100_000_000),
+ }
+ }
+
+ fn test_opening_fee_params(secret: &[u8; 32]) -> OpeningFeeParams {
+ test_policy_params().with_promise(secret)
+ }
+
+ fn expired_opening_fee_params(secret: &[u8; 32]) -> OpeningFeeParams {
+ let mut policy = test_policy_params();
+ policy.valid_until = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap();
+ policy.with_promise(secret)
+ }
+
+ #[derive(Default, Clone)]
+ struct MockApi {
+ // Responses
+ offer_response: Arc<Mutex<Option<Lsps2PolicyGetInfoResponse>>>,
+ blockheight: Arc<Mutex<Option<u32>>>,
+ store_result: Arc<Mutex<Option<bool>>>,
+
+ // Errors
+ offer_error: Arc<Mutex<bool>>,
+ blockheight_error: Arc<Mutex<bool>>,
+ store_error: Arc<Mutex<bool>>,
+
+ // Capture calls
+ stored_requests: Arc<Mutex<Vec<StoredBuyRequest>>>,
+ }
+
+ #[derive(Clone, Debug)]
+ struct StoredBuyRequest {
+ peer_id: PublicKey,
+ payment_size: Option<Msat>,
+ }
+
+ impl MockApi {
+ fn new() -> Self {
+ Self::default()
+ }
+
+ fn with_offer(self, response: Lsps2PolicyGetInfoResponse) -> Self {
+ *self.offer_response.lock().unwrap() = Some(response);
+ self
+ }
+
+ fn with_offer_menu(self, menu: Vec<PolicyOpeningFeeParams>) -> Self {
+ self.with_offer(Lsps2PolicyGetInfoResponse {
+ policy_opening_fee_params_menu: menu,
+ client_rejected: false,
+ })
+ }
+
+ fn with_client_rejected(self) -> Self {
+ *self.offer_response.lock().unwrap() = Some(Lsps2PolicyGetInfoResponse {
+ policy_opening_fee_params_menu: vec![],
+ client_rejected: true,
+ });
+ self
+ }
+
+ fn with_blockheight(self, height: u32) -> Self {
+ *self.blockheight.lock().unwrap() = Some(height);
+ self
+ }
+
+ fn with_store_result(self, ok: bool) -> Self {
+ *self.store_result.lock().unwrap() = Some(ok);
+ self
+ }
+
+ fn with_offer_error(self) -> Self {
+ *self.offer_error.lock().unwrap() = true;
+ self
+ }
+
+ fn with_blockheight_error(self) -> Self {
+ *self.blockheight_error.lock().unwrap() = true;
+ self
+ }
+
+ fn with_store_error(self) -> Self {
+ *self.store_error.lock().unwrap() = true;
+ self
+ }
+
+ fn stored_requests(&self) -> Vec<StoredBuyRequest> {
+ self.stored_requests.lock().unwrap().clone()
+ }
+ }
+
+ #[async_trait]
+ impl Lsps2OfferProvider for MockApi {
+ async fn get_offer(
+ &self,
+ _request: &Lsps2PolicyGetInfoRequest,
+ ) -> AnyResult<Lsps2PolicyGetInfoResponse> {
+ if *self.offer_error.lock().unwrap() {
+ return Err(anyhow!("offer error"));
+ }
+ self.offer_response
+ .lock()
+ .unwrap()
+ .clone()
+ .ok_or_else(|| anyhow!("no offer response set"))
+ }
+
+ async fn get_channel_capacity(
+ &self,
+ _params: &Lsps2PolicyGetChannelCapacityRequest,
+ ) -> AnyResult<Lsps2PolicyGetChannelCapacityResponse> {
+ unimplemented!("not needed for service tests")
+ }
+ }
+
+ #[async_trait]
+ impl BlockheightProvider for MockApi {
+ async fn get_blockheight(&self) -> AnyResult<u32> {
+ if *self.blockheight_error.lock().unwrap() {
+ return Err(anyhow!("blockheight error"));
+ }
+ self.blockheight
+ .lock()
+ .unwrap()
+ .ok_or_else(|| anyhow!("no blockheight set"))
+ }
+ }
+
+ #[async_trait]
+ impl DatastoreProvider for MockApi {
+ async fn store_buy_request(
+ &self,
+ _scid: &ShortChannelId,
+ peer_id: &PublicKey,
+ _fee_params: &OpeningFeeParams,
+ payment_size: &Option<Msat>,
+ ) -> AnyResult<bool> {
+ if *self.store_error.lock().unwrap() {
+ return Err(anyhow!("store error"));
+ }
+
+ self.stored_requests.lock().unwrap().push(StoredBuyRequest {
+ peer_id: *peer_id,
+ payment_size: *payment_size,
+ });
+
+ Ok(self.store_result.lock().unwrap().unwrap_or(true))
+ }
+
+ async fn get_buy_request(&self, _scid: &ShortChannelId) -> AnyResult<DatastoreEntry> {
+ unimplemented!("not needed for service tests")
+ }
+
+ async fn del_buy_request(&self, _scid: &ShortChannelId) -> AnyResult<()> {
+ unimplemented!("not needed for service tests")
+ }
+ }
+
+ fn handler(api: MockApi) -> Lsps2ServiceHandler<MockApi> {
+ Lsps2ServiceHandler::new(Arc::new(api), &test_secret())
+ }
+
+ #[tokio::test]
+ async fn get_info_returns_fee_params_with_promise() {
+ let api = MockApi::new().with_offer_menu(vec![test_policy_params()]);
+ let h = handler(api);
+
+ let result = h.handle_get_info(Lsps2GetInfoRequest { token: None }).await;
+
+ let response = result.unwrap();
+ assert_eq!(response.opening_fee_params_menu.len(), 1);
+
+ let params = &response.opening_fee_params_menu[0];
+ assert_eq!(params.min_fee_msat, Msat(2_000));
+ assert_eq!(params.proportional, Ppm(10_000));
+ assert!(!params.promise.0.is_empty());
+ }
+
+ #[tokio::test]
+ async fn get_info_returns_multiple_fee_params() {
+ let mut params1 = test_policy_params();
+ params1.min_fee_msat = Msat(1_000);
+
+ let mut params2 = test_policy_params();
+ params2.min_fee_msat = Msat(2_000);
+
+ let api = MockApi::new().with_offer_menu(vec![params1, params2]);
+ let h = handler(api);
+
+ let result = h.handle_get_info(Lsps2GetInfoRequest { token: None }).await;
+
+ let response = result.unwrap();
+ assert_eq!(response.opening_fee_params_menu.len(), 2);
+ assert_eq!(
+ response.opening_fee_params_menu[0].min_fee_msat,
+ Msat(1_000)
+ );
+ assert_eq!(
+ response.opening_fee_params_menu[1].min_fee_msat,
+ Msat(2_000)
+ );
+ }
+
+ #[tokio::test]
+ async fn get_info_returns_empty_menu() {
+ let api = MockApi::new().with_offer_menu(vec![]);
+ let h = handler(api);
+
+ let result = h.handle_get_info(Lsps2GetInfoRequest { token: None }).await;
+
+ let response = result.unwrap();
+ assert!(response.opening_fee_params_menu.is_empty());
+ }
+
+ #[tokio::test]
+ async fn get_info_rejects_client() {
+ let api = MockApi::new().with_client_rejected();
+ let h = handler(api);
+
+ let result = h.handle_get_info(Lsps2GetInfoRequest { token: None }).await;
+
+ let err = result.unwrap_err();
+ assert_eq!(err.code, 001); // client_rejected code
+ }
+
+ #[tokio::test]
+ async fn get_info_handles_api_error() {
+ let api = MockApi::new().with_offer_error();
+ let h = handler(api);
+
+ let result = h.handle_get_info(Lsps2GetInfoRequest { token: None }).await;
+
+ let err = result.unwrap_err();
+ assert_eq!(err.code, -32603); // internal error
+ }
+
+ #[tokio::test]
+ async fn buy_success_with_payment_size() {
+ let api = MockApi::new()
+ .with_blockheight(800_000)
+ .with_store_result(true);
+ let h = handler(api.clone());
+
+ let request = Lsps2BuyRequest {
+ opening_fee_params: test_opening_fee_params(&test_secret()),
+ payment_size_msat: Some(Msat(50_000_000)),
+ };
+
+ let result = h.handle_buy(test_peer_id(), request).await;
+
+ let response = result.unwrap();
+ assert!(response.jit_channel_scid.to_u64() > 0);
+ assert_eq!(response.lsp_cltv_expiry_delta, DEFAULT_CLTV_EXPIRY_DELTA);
+ assert!(!response.client_trusts_lsp);
+
+ // Verify stored
+ let stored = api.stored_requests();
+ assert_eq!(stored.len(), 1);
+ assert_eq!(stored[0].peer_id, test_peer_id());
+ assert_eq!(stored[0].payment_size, Some(Msat(50_000_000)));
+ }
+
+ #[tokio::test]
+ async fn buy_success_without_payment_size() {
+ let api = MockApi::new()
+ .with_blockheight(800_000)
+ .with_store_result(true);
+ let h = handler(api.clone());
+
+ let request = Lsps2BuyRequest {
+ opening_fee_params: test_opening_fee_params(&test_secret()),
+ payment_size_msat: None,
+ };
+
+ let result = h.handle_buy(test_peer_id(), request).await;
+
+ assert!(result.is_ok());
+ assert_eq!(api.stored_requests()[0].payment_size, None);
+ }
+
+ #[tokio::test]
+ async fn buy_rejects_invalid_promise() {
+ let api = MockApi::new();
+ let h = handler(api);
+
+ let mut fee_params = test_opening_fee_params(&test_secret());
+ fee_params.promise = Promise::try_from("invalid").unwrap();
+
+ let request = Lsps2BuyRequest {
+ opening_fee_params: fee_params,
+ payment_size_msat: Some(Msat(50_000_000)),
+ };
+
+ let result = h.handle_buy(test_peer_id(), request).await;
+
+ let err = result.unwrap_err();
+ assert_eq!(err.code, 201); // invalid/unrecognized params
+ }
+
+ #[tokio::test]
+ async fn buy_rejects_expired_offer() {
+ let api = MockApi::new();
+ let h = handler(api);
+
+ let request = Lsps2BuyRequest {
+ opening_fee_params: expired_opening_fee_params(&test_secret()),
+ payment_size_msat: Some(Msat(50_000_000)),
+ };
+
+ let result = h.handle_buy(test_peer_id(), request).await;
+
+ let err = result.unwrap_err();
+ assert_eq!(err.code, 201);
+ }
+
+ #[tokio::test]
+ async fn buy_rejects_payment_below_min() {
+ let api = MockApi::new();
+ let h = handler(api);
+
+ let request = Lsps2BuyRequest {
+ opening_fee_params: test_opening_fee_params(&test_secret()),
+ payment_size_msat: Some(Msat(100)), // Below min_payment_size_msat
+ };
+
+ let result = h.handle_buy(test_peer_id(), request).await;
+
+ assert!(result.is_err());
+ }
+
+ #[tokio::test]
+ async fn buy_rejects_payment_above_max() {
+ let api = MockApi::new();
+ let h = handler(api);
+
+ let request = Lsps2BuyRequest {
+ opening_fee_params: test_opening_fee_params(&test_secret()),
+ payment_size_msat: Some(Msat(999_999_999_999)), // Above max
+ };
+
+ let result = h.handle_buy(test_peer_id(), request).await;
+
+ assert!(result.is_err());
+ }
+
+ #[tokio::test]
+ async fn buy_rejects_when_fee_exceeds_payment() {
+ let api = MockApi::new();
+ let h = handler(api);
+
+ // Payment size barely above min_fee, but fee calculation might exceed it
+ let mut fee_params = test_policy_params();
+ fee_params.min_fee_msat = Msat(10_000);
+ fee_params.min_payment_size_msat = Msat(1);
+ let fee_params = fee_params.with_promise(&test_secret());
+
+ let request = Lsps2BuyRequest {
+ opening_fee_params: fee_params,
+ payment_size_msat: Some(Msat(5_000)), // Less than min_fee
+ };
+
+ let result = h.handle_buy(test_peer_id(), request).await;
+
+ let err = result.unwrap_err();
+ assert_eq!(err.code, 202); // fee exceeds payment
+ }
+
+ #[tokio::test]
+ async fn buy_handles_blockheight_error() {
+ let api = MockApi::new().with_blockheight_error();
+ let h = handler(api);
+
+ let request = Lsps2BuyRequest {
+ opening_fee_params: test_opening_fee_params(&test_secret()),
+ payment_size_msat: Some(Msat(50_000_000)),
+ };
+
+ let result = h.handle_buy(test_peer_id(), request).await;
+
+ let err = result.unwrap_err();
+ assert_eq!(err.code, -32603);
+ }
+
+ #[tokio::test]
+ async fn buy_handles_store_error() {
+ let api = MockApi::new().with_blockheight(800_000).with_store_error();
+ let h = handler(api);
+
+ let request = Lsps2BuyRequest {
+ opening_fee_params: test_opening_fee_params(&test_secret()),
+ payment_size_msat: Some(Msat(50_000_000)),
+ };
+
+ let result = h.handle_buy(test_peer_id(), request).await;
+
+ let err = result.unwrap_err();
+ assert_eq!(err.code, -32603);
+ }
+
+ #[tokio::test]
+ async fn buy_handles_store_returns_false() {
+ let api = MockApi::new()
+ .with_blockheight(800_000)
+ .with_store_result(false);
+ let h = handler(api);
+
+ let request = Lsps2BuyRequest {
+ opening_fee_params: test_opening_fee_params(&test_secret()),
+ payment_size_msat: Some(Msat(50_000_000)),
+ };
+
+ let result = h.handle_buy(test_peer_id(), request).await;
+
+ let err = result.unwrap_err();
+ assert_eq!(err.code, -32603);
+ }
+
+ #[tokio::test]
+ async fn buy_generates_unique_scids() {
+ let api = MockApi::new()
+ .with_blockheight(800_000)
+ .with_store_result(true);
+ let h = handler(api);
+
+ let request = Lsps2BuyRequest {
+ opening_fee_params: test_opening_fee_params(&test_secret()),
+ payment_size_msat: None,
+ };
+
+ let r1 = h.handle_buy(test_peer_id(), request.clone()).await.unwrap();
+ let r2 = h.handle_buy(test_peer_id(), request).await.unwrap();
+
+ assert_ne!(r1.jit_channel_scid, r2.jit_channel_scid);
+ }
+}
diff --git a/plugins/lsps-plugin/src/proto/lsps2.rs b/plugins/lsps-plugin/src/proto/lsps2.rs
index 51d3d41d..82767a27 100644
--- a/plugins/lsps-plugin/src/proto/lsps2.rs
+++ b/plugins/lsps-plugin/src/proto/lsps2.rs
@@ -60,6 +60,23 @@ pub trait LSPS2RpcErrorExt {
impl LSPS2RpcErrorExt for RpcError {}
+pub trait ShortChannelIdJITExt {
+ fn generate_jit(blockheight: u32, distance: u32) -> Self;
+}
+
+impl ShortChannelIdJITExt for ShortChannelId {
+ fn generate_jit(blockheight: u32, distance: u32) -> Self {
+ use rand::{rng, Rng as _};
+
+ let mut rng = rng();
+ let block = blockheight + distance;
+ let tx_idx: u32 = rng.random_range(0..5000);
+ let output_idx: u16 = rng.random_range(0..10);
+
+ (((block as u64) << 40) | ((tx_idx as u64) << 16) | (output_idx as u64)).into()
+ }
+}
+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Lsps2GetInfoRequest {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -98,7 +115,7 @@ impl core::error::Error for PromiseError {}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "String")]
-pub struct Promise(String);
+pub struct Promise(pub String);
impl Promise {
pub const MAX_BYTES: usize = 512;
@@ -304,6 +321,19 @@ impl PolicyOpeningFeeParams {
.collect();
promise
}
+
+ pub fn with_promise(&self, secret: &[u8; 32]) -> OpeningFeeParams {
+ OpeningFeeParams {
+ min_fee_msat: self.min_fee_msat,
+ proportional: self.proportional,
+ valid_until: self.valid_until,
+ min_lifetime: self.min_lifetime,
+ max_client_to_self_delay: self.max_client_to_self_delay,
+ min_payment_size_msat: self.min_payment_size_msat,
+ max_payment_size_msat: self.max_payment_size_msat,
+ promise: Promise(self.get_hmac_hex(secret)),
+ }
+ }
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs
index 384e4451..53a08282 100644
--- a/plugins/lsps-plugin/src/service.rs
+++ b/plugins/lsps-plugin/src/service.rs
@@ -1,8 +1,10 @@
use anyhow::bail;
use cln_lsps::{
- cln_adapters::{hooks::service_custommsg_hook, sender::ClnSender, state::ServiceState},
+ cln_adapters::{
+ hooks::service_custommsg_hook, rpc::ClnApiRpc, sender::ClnSender, state::ServiceState,
+ },
core::{
- lsps2::handler::{ClnApiRpc, HtlcAcceptedHookHandler, Lsps2ServiceHandler},
+ lsps2::{htlc::HtlcAcceptedHookHandler, service::Lsps2ServiceHandler},
server::LspsService,
},
lsps2::{
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.