plugins: lsps: remove cln dependencies from core module and clean up service
What changed, and why it matters
This commit is a code cleanup inside Core Lightning's experimental LSPS (Lightning Service Provider Specification) plugin. It removes a direct dependency between the plugin's core logic and the CLN-specific adapter types, replacing them with generic types. The service layer now converts between CLN-specific hook request/response formats and the new generic core types. There is no obvious security vulnerability introduced or fixed; it is primarily an architectural refactor.
No immediate action required. Treat as normal code-review refactor. Reviewers should verify that the new service-layer error-to-continue fallback in `handle_htlc_safe` is intentional and safe for production LSPS operation, because silently continuing on unexpected errors could change failure semantics compared to the prior code.
Security signals we found
Refactor moves request/response serialization boundary from core module to service module
Error propagation changed: internal API errors (capacity query, fund channel, channel-ready check) now surface as `HtlcError` rather than being silently converted to `UNKNOWN_NEXT_PEER` failures
Service-layer wrapper `handle_htlc_safe` catches all errors and falls back to `json_continue()`, which could cause HTLCs to be forwarded instead of failed when unexpected errors occur
MPP (multi-part payment) rejection is currently mapped to `continue` as a temporary workaround, same as before refactor
Evidence from the diff
The diff refactors plugins/lsps-plugin/src/core/lsps2/htlc.rs to stop importing HtlcAcceptedRequest/HtlcAcceptedResponse from cln_adapters::types. Instead it defines its own Htlc, Onion, HtlcDecision, and RejectReason types, and HtlcAcceptedHookHandler::handle now returns Result<HtlcDecision, HtlcError>. The service layer in plugins/lsps-plugin/src/service.rs takes on the responsibility of converting the CLN HtlcAcceptedRequest into the new core types and converting HtlcDecision back into CLN JSON hook responses. Error handling is slightly changed: capacity-query and fund-channel errors now propagate as HtlcError and are logged/continued at the service boundary, whereas previously they were swallowed and returned as generic UNKNOWN_NEXT_PEER failures. A few tests are updated to match the new API. TlvStream and TlvRecord gain standard equality/ordering derives.
Changed components
plugins/lsps-plugin/src/core/lsps2/htlc.rsplugins/lsps-plugin/src/core/tlv.rsplugins/lsps-plugin/src/service.rsInspect captured patch +393 / −248
diff --git a/plugins/lsps-plugin/src/core/lsps2/htlc.rs b/plugins/lsps-plugin/src/core/lsps2/htlc.rs
index c2e0dc5c..6e39cc07 100644
--- a/plugins/lsps-plugin/src/core/lsps2/htlc.rs
+++ b/plugins/lsps-plugin/src/core/lsps2/htlc.rs
@@ -1,11 +1,10 @@
use crate::{
- cln_adapters::types::{HtlcAcceptedRequest, HtlcAcceptedResponse},
core::{
lsps2::provider::{DatastoreProvider, LightningProvider, Lsps2OfferProvider},
- tlv::TLV_FORWARD_AMT,
+ tlv::{TlvStream, TLV_FORWARD_AMT},
},
proto::{
- lsps0::Msat,
+ lsps0::{Msat, ShortChannelId},
lsps2::{
compute_opening_fee,
failure_codes::{TEMPORARY_CHANNEL_FAILURE, UNKNOWN_NEXT_PEER},
@@ -13,11 +12,77 @@ use crate::{
},
},
};
-use anyhow::Result;
-use bitcoin::hashes::Hash as _;
+use bitcoin::hashes::sha256::Hash;
use chrono::Utc;
-use log::{debug, warn};
use std::time::Duration;
+use thiserror::Error;
+
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub enum HtlcDecision {
+ NotOurs,
+ Forward {
+ payload: TlvStream,
+ forward_to: Hash,
+ extra_tlvs: TlvStream,
+ },
+
+ Reject {
+ reason: RejectReason,
+ },
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub enum RejectReason {
+ OfferExpired { valid_until: chrono::DateTime<Utc> },
+ AmountBelowMinimum { minimum: Msat },
+ AmountAboveMaximum { maximum: Msat },
+ InsufficientForFee { fee: Msat },
+ FeeOverflow,
+ PolicyDenied,
+ FundingFailed,
+
+ // temporarily
+ MppNotSupported,
+}
+
+impl RejectReason {
+ pub fn failure_code(&self) -> &'static str {
+ match self {
+ Self::OfferExpired { .. } => TEMPORARY_CHANNEL_FAILURE,
+ _ => UNKNOWN_NEXT_PEER,
+ }
+ }
+}
+
+#[derive(Debug, Error)]
+pub enum HtlcError {
+ #[error("failed to query channel capacity: {0}")]
+ CapacityQuery(#[source] anyhow::Error),
+ #[error("failed to fund channel: {0}")]
+ FundChannel(#[source] anyhow::Error),
+ #[error("channel ready check failed: {0}")]
+ ChannelReadyCheck(#[source] anyhow::Error),
+}
+
+#[derive(Debug, Clone)]
+pub struct Htlc {
+ pub amount_msat: Msat,
+ pub extra_tlvs: TlvStream,
+}
+impl Htlc {
+ pub fn new(amount_msat: Msat, tlvs: TlvStream) -> Self {
+ Self {
+ amount_msat,
+ extra_tlvs: tlvs,
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct Onion {
+ pub short_channel_id: ShortChannelId,
+ pub payload: TlvStream,
+}
pub struct HtlcAcceptedHookHandler<A> {
api: A,
@@ -35,21 +100,11 @@ impl<A> HtlcAcceptedHookHandler<A> {
}
}
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));
- }
- };
-
+ pub async fn handle(&self, htlc: &Htlc, onion: &Onion) -> Result<HtlcDecision, HtlcError> {
// A) Is this SCID one that we care about?
- let ds_rec = match self.api.get_buy_request(&scid).await {
+ let ds_rec = match self.api.get_buy_request(&onion.short_channel_id).await {
Ok(rec) => rec,
- Err(_) => {
- return Ok(HtlcAcceptedResponse::continue_(None, None, None));
- }
+ Err(_) => return Ok(HtlcDecision::NotOurs),
};
// Fixme: Check that we don't have a channel yet with the peer that we await to
@@ -60,85 +115,78 @@ impl<A: DatastoreProvider + Lsps2OfferProvider + LightningProvider> HtlcAccepted
// 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,
- // ));
+ return Ok(HtlcDecision::Reject {
+ reason: RejectReason::MppNotSupported,
+ });
}
// B) Is the fee option menu still valid?
- let now = Utc::now();
- if now >= ds_rec.opening_fee_params.valid_until {
+ if Utc::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,
- ));
+ let _ = self.api.del_buy_request(&onion.short_channel_id).await;
+ return Ok(HtlcDecision::Reject {
+ reason: RejectReason::OfferExpired {
+ valid_until: ds_rec.opening_fee_params.valid_until,
+ },
+ });
}
// 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,
- ));
+ if htlc.amount_msat.msat() < ds_rec.opening_fee_params.min_fee_msat.msat() {
+ return Ok(HtlcDecision::Reject {
+ reason: RejectReason::AmountBelowMinimum {
+ minimum: ds_rec.opening_fee_params.min_fee_msat,
+ },
+ });
+ }
+
+ if htlc.amount_msat.msat() > ds_rec.opening_fee_params.max_payment_size_msat.msat() {
+ return Ok(HtlcDecision::Reject {
+ reason: RejectReason::AmountAboveMaximum {
+ maximum: ds_rec.opening_fee_params.max_payment_size_msat,
+ },
+ });
}
// 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(),
+ let opening_fee = match compute_opening_fee(
+ 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,
- ));
+ Some(fee) if fee + self.htlc_minimum_msat < htlc.amount_msat.msat() => fee,
+ Some(fee) => {
+ return Ok(HtlcDecision::Reject {
+ reason: RejectReason::InsufficientForFee {
+ fee: Msat::from_msat(fee),
+ },
+ })
+ }
+ None => {
+ return Ok(HtlcDecision::Reject {
+ reason: RejectReason::FeeOverflow,
+ })
}
- 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,
- ));
- }
+ init_payment_size: htlc.amount_msat,
+ scid: onion.short_channel_id,
};
+ let ch_cap_res = self
+ .api
+ .get_channel_capacity(&ch_cap_req)
+ .await
+ .map_err(HtlcError::CapacityQuery)?;
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,
- ));
+ return Ok(HtlcDecision::Reject {
+ reason: RejectReason::PolicyDenied,
+ })
}
};
@@ -148,15 +196,11 @@ impl<A: DatastoreProvider + Lsps2OfferProvider + LightningProvider> HtlcAccepted
// (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,
- ));
- }
- };
+ let (channel_id, _) = self
+ .api
+ .fund_jit_channel(&ds_rec.peer_id, &cap)
+ .await
+ .map_err(HtlcError::FundChannel)?;
// F) Wait for the peer to send `channel_ready`.
// Fixme: Use event to check for channel ready,
@@ -169,36 +213,30 @@ impl<A: DatastoreProvider + Lsps2OfferProvider + LightningProvider> HtlcAccepted
.await
{
Ok(true) => break,
- Ok(false) | Err(_) => tokio::time::sleep(self.backoff_listpeerchannels).await,
+ Ok(false) => tokio::time::sleep(self.backoff_listpeerchannels).await,
+ Err(e) => return Err(HtlcError::ChannelReadyCheck(e)),
};
}
// 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();
+ let deducted_amt_msat = htlc.amount_msat.msat() - opening_fee;
+ let mut payload = 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();
+ let mut extra_tlvs = htlc.extra_tlvs.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),
- ))
+ Ok(HtlcDecision::Forward {
+ payload,
+ forward_to: channel_id,
+ extra_tlvs,
+ })
}
}
#[cfg(test)]
mod tests {
use super::*;
- use crate::cln_adapters::types::{Htlc, HtlcAcceptedResult, Onion};
use crate::core::tlv::TlvStream;
use crate::proto::lsps0::{Msat, Ppm, ShortChannelId};
use crate::proto::lsps2::{
@@ -210,10 +248,10 @@ mod tests {
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;
+ use std::u64;
fn test_peer_id() -> PublicKey {
"0279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798"
@@ -257,31 +295,19 @@ mod tests {
}
}
- 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,
+ fn test_onion(scid: ShortChannelId, payload: TlvStream) -> Onion {
+ Onion {
+ short_channel_id: scid,
+ payload,
}
}
+ fn test_htlc(amount_msat: u64, extra_tlvs: TlvStream) -> Htlc {
+ Htlc {
+ amount_msat: Msat::from_msat(amount_msat),
+ extra_tlvs,
+ }
+ }
#[derive(Default, Clone)]
struct MockApi {
@@ -446,29 +472,16 @@ mod 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();
+ let onion = test_onion(test_scid(), TlvStream::default());
+ let htlc = test_htlc(10_000_000, TlvStream::default());
+ let result = h.handle(&htlc, &onion).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Continue);
- assert!(result.payload.is_none());
+ assert_eq!(result, HtlcDecision::NotOurs);
}
#[tokio::test]
@@ -477,10 +490,16 @@ mod tests {
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();
+ let onion = test_onion(test_scid(), TlvStream::default());
+ let htlc = test_htlc(10_000_000, TlvStream::default());
+ let result = h.handle(&htlc, &onion).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Continue);
+ assert_eq!(
+ result,
+ HtlcDecision::Reject {
+ reason: RejectReason::MppNotSupported
+ }
+ );
}
#[tokio::test]
@@ -490,14 +509,16 @@ mod tests {
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();
+ let onion = test_onion(test_scid(), TlvStream::default());
+ let htlc = test_htlc(10_000_000, TlvStream::default());
+ let result = h.handle(&htlc, &onion).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Fail);
- assert_eq!(
- result.failure_message.unwrap(),
- TEMPORARY_CHANNEL_FAILURE.to_string()
- );
+ assert!(matches!(
+ result,
+ HtlcDecision::Reject {
+ reason: RejectReason::OfferExpired { .. }
+ }
+ ));
assert_eq!(api.del_call_count(), 1); // Should delete expired entry
}
@@ -508,14 +529,16 @@ mod tests {
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()
- );
+ let onion = test_onion(test_scid(), TlvStream::default());
+ let htlc = test_htlc(1_000, TlvStream::default());
+ let result = h.handle(&htlc, &onion).await.unwrap();
+
+ assert!(matches!(
+ result,
+ HtlcDecision::Reject {
+ reason: RejectReason::AmountBelowMinimum { .. }
+ }
+ ));
}
#[tokio::test]
@@ -525,14 +548,16 @@ mod tests {
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()
- );
+ let onion = test_onion(test_scid(), TlvStream::default());
+ let htlc = test_htlc(200_000_000, TlvStream::default());
+ let result = h.handle(&htlc, &onion).await.unwrap();
+
+ assert!(matches!(
+ result,
+ HtlcDecision::Reject {
+ reason: RejectReason::AmountAboveMaximum { .. }
+ }
+ ));
}
#[tokio::test]
@@ -545,14 +570,16 @@ mod tests {
// 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()
- );
+ let onion = test_onion(test_scid(), TlvStream::default());
+ let htlc = test_htlc(3_000, TlvStream::default());
+ let result = h.handle(&htlc, &onion).await.unwrap();
+
+ assert!(matches!(
+ result,
+ HtlcDecision::Reject {
+ reason: RejectReason::InsufficientForFee { .. }
+ }
+ ));
}
#[tokio::test]
@@ -566,14 +593,16 @@ mod tests {
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();
+ let onion = test_onion(test_scid(), TlvStream::default());
+ let htlc = test_htlc(u64::MAX / 2, TlvStream::default());
+ let result = h.handle(&htlc, &onion).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Fail);
- assert_eq!(
- result.failure_message.unwrap(),
- UNKNOWN_NEXT_PEER.to_string()
- );
+ assert!(matches!(
+ result,
+ HtlcDecision::Reject {
+ reason: RejectReason::FeeOverflow,
+ }
+ ));
}
#[tokio::test]
@@ -584,14 +613,11 @@ mod tests {
.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();
+ let onion = test_onion(test_scid(), TlvStream::default());
+ let htlc = test_htlc(10_000_000, TlvStream::default());
+ let result = h.handle(&htlc, &onion).await.expect_err("should fail");
- assert_eq!(result.result, HtlcAcceptedResult::Fail);
- assert_eq!(
- result.failure_message.unwrap(),
- UNKNOWN_NEXT_PEER.to_string()
- );
+ assert!(matches!(result, HtlcError::CapacityQuery(_)));
}
#[tokio::test]
@@ -600,14 +626,16 @@ mod tests {
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();
+ let onion = test_onion(test_scid(), TlvStream::default());
+ let htlc = test_htlc(10_000_000, TlvStream::default());
+ let result = h.handle(&htlc, &onion).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Fail);
- assert_eq!(
- result.failure_message.unwrap(),
- UNKNOWN_NEXT_PEER.to_string()
- );
+ assert!(matches!(
+ result,
+ HtlcDecision::Reject {
+ reason: RejectReason::PolicyDenied,
+ }
+ ));
}
#[tokio::test]
@@ -619,14 +647,11 @@ mod tests {
.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();
+ let onion = test_onion(test_scid(), TlvStream::default());
+ let htlc = test_htlc(10_000_000, TlvStream::default());
+ let result = h.handle(&htlc, &onion).await.expect_err("should fail");
- assert_eq!(result.result, HtlcAcceptedResult::Fail);
- assert_eq!(
- result.failure_message.unwrap(),
- UNKNOWN_NEXT_PEER.to_string()
- );
+ assert!(matches!(result, HtlcError::FundChannel(_)));
}
#[tokio::test]
@@ -639,19 +664,22 @@ mod tests {
.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());
+ let onion = test_onion(test_scid(), TlvStream::default());
+ let htlc = test_htlc(10_000_000, TlvStream::default());
+ let result = h.handle(&htlc, &onion).await.unwrap();
+
+ let HtlcDecision::Forward {
+ payload,
+ forward_to,
+ extra_tlvs,
+ } = result
+ else {
+ panic!("expected forward, got {:?}", result)
+ };
- // Channel ID should match
- assert_eq!(
- result.forward_to.unwrap(),
- test_channel_id().as_byte_array().to_vec()
- );
+ assert_eq!(forward_to, test_channel_id());
+ assert!(!payload.0.is_empty());
+ assert!(!extra_tlvs.0.is_empty());
}
#[tokio::test]
@@ -667,8 +695,10 @@ mod tests {
// 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 onion = test_onion(test_scid(), TlvStream::default());
+ let htlc = test_htlc(10_000_000, TlvStream::default());
+ let result = h.handle(&htlc, &onion).await.unwrap();
+ result
});
// Let it poll a few times
@@ -678,8 +708,8 @@ mod tests {
// Now make channel ready
*api.channel_ready.lock().unwrap() = true;
- let result = handle.await.unwrap().unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Continue);
+ let result = handle.await.unwrap();
+ assert!(matches!(result, HtlcDecision::Forward { .. }));
}
#[tokio::test]
@@ -692,18 +722,18 @@ mod tests {
.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();
+ let onion = test_onion(test_scid(), TlvStream::default());
+ let htlc = test_htlc(10_000_000, TlvStream::default());
+ let result = h.handle(&htlc, &onion).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Continue);
+ let HtlcDecision::Forward { payload, .. } = result else {
+ panic!("expected forward, got {:?}", result)
+ };
// 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));
}
@@ -718,11 +748,13 @@ mod tests {
.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 onion = test_onion(test_scid(), TlvStream::default());
+ let htlc = test_htlc(10_000_000, TlvStream::default());
+ let result = h.handle(&htlc, &onion).await.unwrap();
- let extra_tlvs_bytes = result.extra_tlvs.unwrap();
- let extra_tlvs = TlvStream::from_bytes(&extra_tlvs_bytes).unwrap();
+ let HtlcDecision::Forward { extra_tlvs, .. } = result else {
+ panic!("expected forward, got {:?}", result)
+ };
// Opening fee should be in TLV 65537
let opening_fee = extra_tlvs.get_u64(65537).unwrap();
@@ -743,10 +775,11 @@ mod tests {
// 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();
+ let onion = test_onion(test_scid(), TlvStream::default());
+ let htlc = test_htlc(1_000_000, TlvStream::default());
+ let result = h.handle(&htlc, &onion).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Continue);
+ assert!(matches!(result, HtlcDecision::Forward { .. }));
}
#[tokio::test]
@@ -760,9 +793,10 @@ mod tests {
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();
+ let onion = test_onion(test_scid(), TlvStream::default());
+ let htlc = test_htlc(100_000_000, TlvStream::default());
+ let result = h.handle(&htlc, &onion).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Continue);
+ assert!(matches!(result, HtlcDecision::Forward { .. }));
}
}
diff --git a/plugins/lsps-plugin/src/core/tlv.rs b/plugins/lsps-plugin/src/core/tlv.rs
index e66075f0..357ebd28 100644
--- a/plugins/lsps-plugin/src/core/tlv.rs
+++ b/plugins/lsps-plugin/src/core/tlv.rs
@@ -7,13 +7,13 @@ pub const TLV_OUTGOING_CLTV: u64 = 4;
pub const TLV_SHORT_CHANNEL_ID: u64 = 6;
pub const TLV_PAYMENT_SECRET: u64 = 8;
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct TlvRecord {
pub type_: u64,
pub value: Vec<u8>,
}
-#[derive(Clone, Debug, Default)]
+#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub struct TlvStream(pub Vec<TlvRecord>);
#[derive(Debug)]
diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs
index 57b8579b..bc59ba51 100644
--- a/plugins/lsps-plugin/src/service.rs
+++ b/plugins/lsps-plugin/src/service.rs
@@ -1,18 +1,21 @@
use anyhow::bail;
+use bitcoin::hashes::Hash;
use cln_lsps::{
cln_adapters::{
- hooks::service_custommsg_hook,
- rpc::ClnApiRpc,
- sender::ClnSender,
- state::ServiceState,
- types::{HtlcAcceptedRequest, HtlcAcceptedResponse},
+ hooks::service_custommsg_hook, rpc::ClnApiRpc, sender::ClnSender, state::ServiceState,
+ types::HtlcAcceptedRequest,
},
core::{
- lsps2::{htlc::HtlcAcceptedHookHandler, service::Lsps2ServiceHandler},
+ lsps2::{
+ htlc::{Htlc, HtlcAcceptedHookHandler, HtlcDecision, Onion, RejectReason},
+ service::Lsps2ServiceHandler,
+ },
server::LspsService,
},
+ proto::lsps0::Msat,
};
use cln_plugin::{options, Plugin};
+use log::{debug, error, trace};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -131,20 +134,128 @@ async fn main() -> Result<(), anyhow::Error> {
async fn on_htlc_accepted(
p: Plugin<State>,
v: serde_json::Value,
+) -> Result<serde_json::Value, anyhow::Error> {
+ Ok(handle_htlc_safe(&p, v).await)
+}
+
+async fn handle_htlc_safe(p: &Plugin<State>, v: serde_json::Value) -> serde_json::Value {
+ match handle_htlc_inner(p, v).await {
+ Ok(response) => response,
+ Err(e) => {
+ error!("HTLC hook error (continuing): {:#}", e);
+ json_continue()
+ }
+ }
+}
+
+async fn handle_htlc_inner(
+ p: &Plugin<State>,
+ v: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
if !p.state().lsps2_enabled {
- // just continue.
- // Fixme: Add forward and extra tlvs from incoming.
- let res = serde_json::to_value(&HtlcAcceptedResponse::continue_(None, None, None))?;
- return Ok(res);
+ return Ok(json_continue());
}
let req: HtlcAcceptedRequest = serde_json::from_value(v)?;
+
+ let short_channel_id = match req.onion.short_channel_id {
+ Some(scid) => scid,
+ None => {
+ trace!("We are the destination of the HTLC, continue.");
+ return Ok(json_continue());
+ }
+ };
+
let rpc_path = Path::new(&p.configuration().lightning_dir).join(&p.configuration().rpc_file);
let api = ClnApiRpc::new(rpc_path);
// Fixme: Use real htlc_minimum_amount.
let handler = HtlcAcceptedHookHandler::new(api, 1000);
- let res = handler.handle(req).await?;
- let res_val = serde_json::to_value(&res)?;
- Ok(res_val)
+
+ let onion = Onion {
+ short_channel_id,
+ payload: req.onion.payload,
+ };
+
+ let htlc = Htlc {
+ amount_msat: Msat::from_msat(req.htlc.amount_msat.msat()),
+ extra_tlvs: req.htlc.extra_tlvs.unwrap_or_default(),
+ };
+
+ debug!("Handle potential jit-session HTLC.");
+ let response = match handler.handle(&htlc, &onion).await {
+ Ok(dec) => {
+ log_decision(&dec);
+ decision_to_response(dec)?
+ }
+ Err(e) => {
+ // Fixme: Should we log **BROKEN** here?
+ debug!("Htlc handler failed (continuing): {:#}", e);
+ return Ok(json_continue());
+ }
+ };
+
+ Ok(serde_json::to_value(&response)?)
+}
+
+fn decision_to_response(decision: HtlcDecision) -> Result<serde_json::Value, anyhow::Error> {
+ Ok(match decision {
+ HtlcDecision::NotOurs => json_continue(),
+
+ HtlcDecision::Forward {
+ mut payload,
+ forward_to,
+ mut extra_tlvs,
+ } => json_continue_forward(
+ payload.to_bytes()?,
+ forward_to.as_byte_array().to_vec(),
+ extra_tlvs.to_bytes()?,
+ ),
+
+ // Fixme: once we implement MPP-Support we need to remove this.
+ HtlcDecision::Reject {
+ reason: RejectReason::MppNotSupported,
+ } => json_continue(),
+ HtlcDecision::Reject { reason } => json_fail(reason.failure_code()),
+ })
+}
+
+fn json_continue() -> serde_json::Value {
+ serde_json::json!({"result": "continue"})
+}
+
+fn json_continue_forward(
+ payload: Vec<u8>,
+ forward_to: Vec<u8>,
+ extra_tlvs: Vec<u8>,
+) -> serde_json::Value {
+ serde_json::json!({
+ "result": "continue",
+ "payload": hex::encode(payload),
+ "forward_to": hex::encode(forward_to),
+ "extra_tlvs": hex::encode(extra_tlvs)
+ })
+}
+
+fn json_fail(failure_code: &str) -> serde_json::Value {
+ serde_json::json!({
+ "result": "fail",
+ "failure_message": failure_code
+ })
+}
+
+fn log_decision(decision: &HtlcDecision) {
+ match decision {
+ HtlcDecision::NotOurs => {
+ trace!("SCID not ours, continue");
+ }
+ HtlcDecision::Forward { forward_to, .. } => {
+ debug!(
+ "Forwarding via JIT channel {}",
+ hex::encode(forward_to.as_byte_array())
+ );
+ }
+ HtlcDecision::Reject { reason } => {
+ debug!("Rejecting HTLC: {:?}", reason);
+ }
+ }
}
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.