Files
palladum-lightning/plugins/lsps-plugin/src/lsps2/handler.rs
Peter Neuroth be015898ae lsp_plugin: add basic lsps2 mpp support to client
This includes a mocked lsps2 service plugin, tests and some changes on
the client side. The client now can accept mpp payments for a
jit-channel opening from a connected LSP.

Changelog-Added: Lsps2 `fixed-invoice-mpp` mode for the lsps2 client

Signed-off-by: Peter Neuroth <pet.v.ne@gmail.com>
2025-11-13 10:58:49 +10:30

1596 lines
58 KiB
Rust

use crate::{
jsonrpc::{server::RequestHandler, JsonRpcResponse as _, RequestObject, RpcError},
lsps0::primitives::{Msat, ShortChannelId},
lsps2::{
cln::{HtlcAcceptedRequest, HtlcAcceptedResponse, TLV_FORWARD_AMT},
model::{
compute_opening_fee,
failure_codes::{TEMPORARY_CHANNEL_FAILURE, UNKNOWN_NEXT_PEER},
DatastoreEntry, Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest,
Lsps2GetInfoResponse, Lsps2PolicyGetChannelCapacityRequest,
Lsps2PolicyGetChannelCapacityResponse, Lsps2PolicyGetInfoRequest,
Lsps2PolicyGetInfoResponse, OpeningFeeParams, Promise,
},
DS_MAIN_KEY, DS_SUB_KEY,
},
util::unwrap_payload_with_peer_id,
};
use anyhow::{Context, Result as AnyResult};
use async_trait::async_trait;
use bitcoin::hashes::Hash as _;
use chrono::Utc;
use cln_rpc::{
model::{
requests::{
DatastoreMode, DatastoreRequest, DeldatastoreRequest, FundchannelRequest,
GetinfoRequest, ListdatastoreRequest, ListpeerchannelsRequest,
},
responses::{
DatastoreResponse, DeldatastoreResponse, FundchannelResponse, GetinfoResponse,
ListdatastoreResponse, ListpeerchannelsResponse,
},
},
primitives::{Amount, AmountOrAll, ChannelState},
ClnRpc,
};
use log::{debug, warn};
use rand::{rng, Rng as _};
use std::{fmt, path::PathBuf, time::Duration};
#[async_trait]
pub trait ClnApi: Send + Sync {
async fn lsps2_getpolicy(
&self,
params: &Lsps2PolicyGetInfoRequest,
) -> AnyResult<Lsps2PolicyGetInfoResponse>;
async fn lsps2_getchannelcapacity(
&self,
params: &Lsps2PolicyGetChannelCapacityRequest,
) -> AnyResult<Lsps2PolicyGetChannelCapacityResponse>;
async fn cln_getinfo(&self, params: &GetinfoRequest) -> AnyResult<GetinfoResponse>;
async fn cln_datastore(&self, params: &DatastoreRequest) -> AnyResult<DatastoreResponse>;
async fn cln_listdatastore(
&self,
params: &ListdatastoreRequest,
) -> AnyResult<ListdatastoreResponse>;
async fn cln_deldatastore(
&self,
params: &DeldatastoreRequest,
) -> AnyResult<DeldatastoreResponse>;
async fn cln_fundchannel(&self, params: &FundchannelRequest) -> AnyResult<FundchannelResponse>;
async fn cln_listpeerchannels(
&self,
params: &ListpeerchannelsRequest,
) -> AnyResult<ListpeerchannelsResponse>;
}
const DEFAULT_CLTV_EXPIRY_DELTA: u32 = 144;
#[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) -> AnyResult<ClnRpc> {
ClnRpc::new(&self.rpc_path).await
}
}
#[async_trait]
impl ClnApi for ClnApiRpc {
async fn lsps2_getpolicy(
&self,
params: &Lsps2PolicyGetInfoRequest,
) -> AnyResult<Lsps2PolicyGetInfoResponse> {
let mut rpc = self.create_rpc().await?;
rpc.call_raw("lsps2-policy-getpolicy", params)
.await
.map_err(anyhow::Error::new)
.with_context(|| "calling lsps2-policy-getpolicy")
}
async fn lsps2_getchannelcapacity(
&self,
params: &Lsps2PolicyGetChannelCapacityRequest,
) -> AnyResult<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 fn cln_getinfo(&self, params: &GetinfoRequest) -> AnyResult<GetinfoResponse> {
let mut rpc = self.create_rpc().await?;
rpc.call_typed(params)
.await
.map_err(anyhow::Error::new)
.with_context(|| "calling getinfo")
}
async fn cln_datastore(&self, params: &DatastoreRequest) -> AnyResult<DatastoreResponse> {
let mut rpc = self.create_rpc().await?;
rpc.call_typed(params)
.await
.map_err(anyhow::Error::new)
.with_context(|| "calling datastore")
}
async fn cln_listdatastore(
&self,
params: &ListdatastoreRequest,
) -> AnyResult<ListdatastoreResponse> {
let mut rpc = self.create_rpc().await?;
rpc.call_typed(params)
.await
.map_err(anyhow::Error::new)
.with_context(|| "calling listdatastore")
}
async fn cln_deldatastore(
&self,
params: &DeldatastoreRequest,
) -> AnyResult<DeldatastoreResponse> {
let mut rpc = self.create_rpc().await?;
rpc.call_typed(params)
.await
.map_err(anyhow::Error::new)
.with_context(|| "calling deldatastore")
}
async fn cln_fundchannel(&self, params: &FundchannelRequest) -> AnyResult<FundchannelResponse> {
let mut rpc = self.create_rpc().await?;
rpc.call_typed(params)
.await
.map_err(anyhow::Error::new)
.with_context(|| "calling fundchannel")
}
async fn cln_listpeerchannels(
&self,
params: &ListpeerchannelsRequest,
) -> AnyResult<ListpeerchannelsResponse> {
let mut rpc = self.create_rpc().await?;
rpc.call_typed(params)
.await
.map_err(anyhow::Error::new)
.with_context(|| "calling listpeerchannels")
}
}
/// Handler for the `lsps2.get_info` method.
pub struct Lsps2GetInfoHandler<A: ClnApi> {
pub api: A,
pub promise_secret: [u8; 32],
}
impl<A: ClnApi> Lsps2GetInfoHandler<A> {
pub fn new(api: A, promise_secret: [u8; 32]) -> Self {
Self {
api,
promise_secret,
}
}
}
/// The RequestHandler calls the internal rpc command `lsps2-policy-getinfo`. It
/// expects a plugin has registered this command and manages policies for the
/// LSPS2 service.
#[async_trait]
impl<T: ClnApi + 'static> RequestHandler for Lsps2GetInfoHandler<T> {
async fn handle(&self, payload: &[u8]) -> core::result::Result<Vec<u8>, RpcError> {
let (payload, _) = unwrap_payload_with_peer_id(payload);
let req: RequestObject<Lsps2GetInfoRequest> = serde_json::from_slice(&payload)
.map_err(|e| RpcError::parse_error(format!("failed to parse request: {e}")))?;
if req.id.is_none() {
// Is a notification we can not reply so we just return
return Ok(vec![]);
}
let params = req
.params
.ok_or(RpcError::invalid_params("expected params but was missing"))?;
let policy_params: Lsps2PolicyGetInfoRequest = params.into();
let res_data: Lsps2PolicyGetInfoResponse = self
.api
.lsps2_getpolicy(&policy_params)
.await
.map_err(|e| RpcError {
code: 200,
message: format!("failed to fetch policy {e:#}"),
data: None,
})?;
let opening_fee_params_menu = res_data
.policy_opening_fee_params_menu
.iter()
.map(|v| {
let promise: Promise = v
.get_hmac_hex(&self.promise_secret)
.try_into()
.map_err(|e| RpcError::internal_error(format!("invalid promise: {e}")))?;
Ok(OpeningFeeParams {
min_fee_msat: v.min_fee_msat,
proportional: v.proportional,
valid_until: v.valid_until,
min_lifetime: v.min_lifetime,
max_client_to_self_delay: v.max_client_to_self_delay,
min_payment_size_msat: v.min_payment_size_msat,
max_payment_size_msat: v.max_payment_size_msat,
promise,
})
})
.collect::<Result<Vec<_>, RpcError>>()?;
let res = Lsps2GetInfoResponse {
opening_fee_params_menu,
}
.into_response(req.id.unwrap()); // We checked that we got an id before.
serde_json::to_vec(&res)
.map_err(|e| RpcError::internal_error(format!("Failed to serialize response: {}", e)))
}
}
pub struct Lsps2BuyHandler<A: ClnApi> {
pub api: A,
pub promise_secret: [u8; 32],
}
impl<A: ClnApi> Lsps2BuyHandler<A> {
pub fn new(api: A, promise_secret: [u8; 32]) -> Self {
Self {
api,
promise_secret,
}
}
}
#[async_trait]
impl<A: ClnApi + 'static> RequestHandler for Lsps2BuyHandler<A> {
async fn handle(&self, payload: &[u8]) -> core::result::Result<Vec<u8>, RpcError> {
let (payload, peer_id) = unwrap_payload_with_peer_id(payload);
let req: RequestObject<Lsps2BuyRequest> = serde_json::from_slice(&payload)
.map_err(|e| RpcError::parse_error(format!("Failed to parse request: {}", e)))?;
if req.id.is_none() {
// Is a notification we can not reply so we just return
return Ok(vec![]);
}
let req_params = req
.params
.ok_or_else(|| RpcError::invalid_request("Missing params field"))?;
let fee_params = req_params.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, req_params.payment_size_msat, None)?;
// Generate a tmp scid to identify jit channel request in htlc.
let get_info_req = GetinfoRequest {};
let info = self.api.cln_getinfo(&get_info_req).await.map_err(|e| {
warn!("Failed to call getinfo via rpc {}", e);
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_u64 = generate_jit_scid(info.blockheight);
let jit_scid = ShortChannelId::from(jit_scid_u64);
let ds_data = DatastoreEntry {
peer_id,
opening_fee_params: fee_params,
expected_payment_size: req_params.payment_size_msat,
};
let ds_json = serde_json::to_string(&ds_data).map_err(|e| {
warn!("Failed to serialize opening fee params to string {}", e);
RpcError::internal_error("Internal error")
})?;
let ds_req = DatastoreRequest {
generation: None,
hex: None,
mode: Some(DatastoreMode::MUST_CREATE),
string: Some(ds_json),
key: vec![
DS_MAIN_KEY.to_string(),
DS_SUB_KEY.to_string(),
jit_scid.to_string(),
],
};
let _ds_res = self.api.cln_datastore(&ds_req).await.map_err(|e| {
warn!("Failed to store jit request in ds via rpc {}", e);
RpcError::internal_error("Internal error")
})?;
let res = 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,
}
.into_response(req.id.unwrap()); // We checked that we got an id before.
serde_json::to_vec(&res)
.map_err(|e| RpcError::internal_error(format!("Failed to serialize response: {}", e)))
}
}
fn generate_jit_scid(best_blockheigt: u32) -> u64 {
let mut rng = rng();
let block = best_blockheigt + 6; // Approx 1 hour in the future and should avoid collision with confirmed channels
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)
}
pub struct HtlcAcceptedHookHandler<A: ClnApi> {
api: A,
htlc_minimum_msat: u64,
backoff_listpeerchannels: Duration,
}
impl<A: ClnApi> HtlcAcceptedHookHandler<A> {
pub fn new(api: A, htlc_minimum_msat: u64) -> Self {
Self {
api,
htlc_minimum_msat,
backoff_listpeerchannels: Duration::from_secs(10),
}
}
pub async fn handle(&self, req: HtlcAcceptedRequest) -> AnyResult<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_req = ListdatastoreRequest {
key: Some(scid_ds_key(scid)),
};
let ds_res = self.api.cln_listdatastore(&ds_req).await.map_err(|e| {
warn!("Failed to listpeerchannels via rpc {}", e);
RpcError::internal_error("Internal error")
})?;
let (ds_rec, ds_gen) = match deserialize_by_key(&ds_res, scid_ds_key(scid)) {
Ok(r) => r,
Err(DsError::NotFound { .. }) => {
// We don't know the scid, continue.
return Ok(HtlcAcceptedResponse::continue_(None, None, None));
}
Err(e @ DsError::MissingValue { .. })
| Err(e @ DsError::HexDecode { .. })
| Err(e @ DsError::JsonParse { .. }) => {
// We have a data issue, log and continue.
// Note: We may want to actually reject the htlc here or throw
// an error alltogether but we will try to fulfill this htlc for
// now.
warn!("datastore issue: {}", e);
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 ds_req = DeldatastoreRequest {
generation: ds_gen,
key: scid_ds_key(scid),
};
match self.api.cln_deldatastore(&ds_req).await {
Ok(_) => debug!("removed datastore for scid: {}, wasn't valid anymore", scid),
Err(e) => warn!("could not remove datastore for scid: {}: {}", scid, e),
};
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.lsps2_getchannelcapacity(&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) => 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 fund_ch_req = 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(cap)),
id: ds_rec.peer_id,
};
let fund_ch_res = match self.api.cln_fundchannel(&fund_ch_req).await {
Ok(r) => r,
Err(e) => {
// Fixme: Retry to fund the channel.
warn!("could not fund jit channel for scid {}: {}", scid, e);
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.
let mut is_active = false;
while !is_active {
let ls_ch_req = ListpeerchannelsRequest {
id: Some(ds_rec.peer_id),
short_channel_id: None,
};
let ls_ch_res = match self.api.cln_listpeerchannels(&ls_ch_req).await {
Ok(r) => r,
Err(e) => {
warn!("failed to fetch peer channels for scid {}: {}", scid, e);
tokio::time::sleep(self.backoff_listpeerchannels).await;
continue;
}
};
let chs = ls_ch_res
.channels
.iter()
.find(|&ch| ch.channel_id.is_some_and(|id| id == fund_ch_res.channel_id));
if let Some(ch) = chs {
debug!("jit channel for scid {} has state {:?}", scid, ch.state);
if ch.state == ChannelState::CHANNELD_NORMAL {
is_active = true;
}
}
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(fund_ch_res.channel_id.as_byte_array().to_vec()),
Some(extra_tlvs_bytes),
))
}
}
#[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 {}
fn scid_ds_key(scid: ShortChannelId) -> Vec<String> {
vec![
DS_MAIN_KEY.to_string(),
DS_SUB_KEY.to_string(),
scid.to_string(),
]
}
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(),
})
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use super::*;
use crate::{
jsonrpc::{JsonRpcRequest, ResponseObject},
lsps0::primitives::{Msat, Ppm},
lsps2::{
cln::{tlv::TlvStream, HtlcAcceptedResult},
model::PolicyOpeningFeeParams,
},
util::wrap_payload_with_peer_id,
};
use chrono::{TimeZone, Utc};
use cln_rpc::{model::responses::ListdatastoreDatastore, RpcError as ClnRpcError};
use cln_rpc::{
model::responses::ListpeerchannelsChannels,
primitives::{Amount, PublicKey, Sha256},
};
use serde::Serialize;
const PUBKEY: [u8; 33] = [
0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87,
0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16,
0xf8, 0x17, 0x98,
];
fn create_peer_id() -> PublicKey {
PublicKey::from_slice(&PUBKEY).expect("Valid pubkey")
}
fn create_wrapped_request<T: Serialize>(request: &RequestObject<T>) -> Vec<u8> {
let payload = serde_json::to_vec(request).expect("Failed to serialize request");
wrap_payload_with_peer_id(&payload, create_peer_id())
}
/// Build a pair: policy params + buy params with a Promise derived from `secret`
fn params_with_promise(secret: &[u8; 32]) -> (PolicyOpeningFeeParams, OpeningFeeParams) {
let policy = 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: 42,
min_payment_size_msat: Msat(1_000_000),
max_payment_size_msat: Msat(100_000_000),
};
let hex = policy.get_hmac_hex(secret);
let promise: Promise = hex.try_into().expect("hex->Promise");
let buy = OpeningFeeParams {
min_fee_msat: policy.min_fee_msat,
proportional: policy.proportional,
valid_until: policy.valid_until,
min_lifetime: policy.min_lifetime,
max_client_to_self_delay: policy.max_client_to_self_delay,
min_payment_size_msat: policy.min_payment_size_msat,
max_payment_size_msat: policy.max_payment_size_msat,
promise,
};
(policy, buy)
}
#[derive(Clone, Default)]
struct FakeCln {
lsps2_getpolicy_response: Arc<Mutex<Option<Lsps2PolicyGetInfoResponse>>>,
lsps2_getpolicy_error: Arc<Mutex<Option<ClnRpcError>>>,
cln_getinfo_response: Arc<Mutex<Option<GetinfoResponse>>>,
cln_getinfo_error: Arc<Mutex<Option<ClnRpcError>>>,
cln_datastore_response: Arc<Mutex<Option<DatastoreResponse>>>,
cln_datastore_error: Arc<Mutex<Option<ClnRpcError>>>,
cln_listdatastore_response: Arc<Mutex<Option<ListdatastoreResponse>>>,
cln_listdatastore_error: Arc<Mutex<Option<ClnRpcError>>>,
cln_deldatastore_response: Arc<Mutex<Option<DeldatastoreResponse>>>,
cln_deldatastore_error: Arc<Mutex<Option<ClnRpcError>>>,
cln_fundchannel_response: Arc<Mutex<Option<FundchannelResponse>>>,
cln_fundchannel_error: Arc<Mutex<Option<ClnRpcError>>>,
cln_listpeerchannels_response: Arc<Mutex<Option<ListpeerchannelsResponse>>>,
cln_listpeerchannels_error: Arc<Mutex<Option<ClnRpcError>>>,
lsps2_getchannelcapacity_response:
Arc<Mutex<Option<Lsps2PolicyGetChannelCapacityResponse>>>,
lsps2_getchannelcapacity_error: Arc<Mutex<Option<ClnRpcError>>>,
}
#[async_trait]
impl ClnApi for FakeCln {
async fn lsps2_getpolicy(
&self,
_params: &Lsps2PolicyGetInfoRequest,
) -> Result<Lsps2PolicyGetInfoResponse, anyhow::Error> {
if let Some(err) = self.lsps2_getpolicy_error.lock().unwrap().take() {
return Err(anyhow::Error::new(err).context("from fake api"));
};
if let Some(res) = self.lsps2_getpolicy_response.lock().unwrap().take() {
return Ok(res);
};
panic!("No lsps2 response defined");
}
async fn lsps2_getchannelcapacity(
&self,
_params: &Lsps2PolicyGetChannelCapacityRequest,
) -> AnyResult<Lsps2PolicyGetChannelCapacityResponse> {
if let Some(err) = self.lsps2_getchannelcapacity_error.lock().unwrap().take() {
return Err(anyhow::Error::new(err).context("from fake api"));
}
if let Some(res) = self
.lsps2_getchannelcapacity_response
.lock()
.unwrap()
.take()
{
return Ok(res);
}
panic!("No lsps2 getchannelcapacity response defined");
}
async fn cln_getinfo(
&self,
_params: &GetinfoRequest,
) -> Result<GetinfoResponse, anyhow::Error> {
if let Some(err) = self.cln_getinfo_error.lock().unwrap().take() {
return Err(anyhow::Error::new(err).context("from fake api"));
};
if let Some(res) = self.cln_getinfo_response.lock().unwrap().take() {
return Ok(res);
};
panic!("No cln getinfo response defined");
}
async fn cln_datastore(
&self,
_params: &DatastoreRequest,
) -> Result<DatastoreResponse, anyhow::Error> {
if let Some(err) = self.cln_datastore_error.lock().unwrap().take() {
return Err(anyhow::Error::new(err).context("from fake api"));
};
if let Some(res) = self.cln_datastore_response.lock().unwrap().take() {
return Ok(res);
};
panic!("No cln datastore response defined");
}
async fn cln_listdatastore(
&self,
_params: &ListdatastoreRequest,
) -> AnyResult<ListdatastoreResponse> {
if let Some(err) = self.cln_listdatastore_error.lock().unwrap().take() {
return Err(anyhow::Error::new(err).context("from fake api"));
}
if let Some(res) = self.cln_listdatastore_response.lock().unwrap().take() {
return Ok(res);
}
panic!("No cln listdatastore response defined");
}
async fn cln_deldatastore(
&self,
_params: &DeldatastoreRequest,
) -> AnyResult<DeldatastoreResponse> {
if let Some(err) = self.cln_deldatastore_error.lock().unwrap().take() {
return Err(anyhow::Error::new(err).context("from fake api"));
}
if let Some(res) = self.cln_deldatastore_response.lock().unwrap().take() {
return Ok(res);
}
panic!("No cln deldatastore response defined");
}
async fn cln_fundchannel(
&self,
_params: &FundchannelRequest,
) -> AnyResult<FundchannelResponse> {
if let Some(err) = self.cln_fundchannel_error.lock().unwrap().take() {
return Err(anyhow::Error::new(err).context("from fake api"));
}
if let Some(res) = self.cln_fundchannel_response.lock().unwrap().take() {
return Ok(res);
}
panic!("No cln fundchannel response defined");
}
async fn cln_listpeerchannels(
&self,
_params: &ListpeerchannelsRequest,
) -> AnyResult<ListpeerchannelsResponse> {
if let Some(err) = self.cln_listpeerchannels_error.lock().unwrap().take() {
return Err(anyhow::Error::new(err).context("from fake api"));
}
if let Some(res) = self.cln_listpeerchannels_response.lock().unwrap().take() {
return Ok(res);
}
// Default: return a ready channel
let channel = ListpeerchannelsChannels {
channel_id: Some(*Sha256::from_bytes_ref(&[1u8; 32])),
state: ChannelState::CHANNELD_NORMAL,
peer_id: create_peer_id(),
peer_connected: true,
alias: None,
closer: None,
funding: None,
funding_outnum: None,
funding_txid: None,
htlcs: None,
in_offered_msat: None,
initial_feerate: None,
last_feerate: None,
last_stable_connection: None,
last_tx_fee_msat: None,
lost_state: None,
max_accepted_htlcs: None,
minimum_htlc_in_msat: None,
next_feerate: None,
next_fee_step: None,
out_fulfilled_msat: None,
out_offered_msat: None,
owner: None,
private: None,
receivable_msat: None,
reestablished: None,
scratch_txid: None,
short_channel_id: None,
spendable_msat: None,
status: None,
their_reserve_msat: None,
to_us_msat: None,
total_msat: None,
close_to: None,
close_to_addr: None,
direction: None,
dust_limit_msat: None,
fee_base_msat: None,
fee_proportional_millionths: None,
feerate: None,
ignore_fee_limits: None,
in_fulfilled_msat: None,
in_payments_fulfilled: None,
in_payments_offered: None,
max_to_us_msat: None,
maximum_htlc_out_msat: None,
min_to_us_msat: None,
minimum_htlc_out_msat: None,
our_max_htlc_value_in_flight_msat: None,
our_reserve_msat: None,
our_to_self_delay: None,
out_payments_fulfilled: None,
out_payments_offered: None,
their_max_htlc_value_in_flight_msat: None,
their_to_self_delay: None,
updates: None,
inflight: None,
#[allow(deprecated)]
max_total_htlc_in_msat: None,
opener: cln_rpc::primitives::ChannelSide::LOCAL,
};
Ok(ListpeerchannelsResponse {
channels: vec![channel],
})
}
}
fn create_test_htlc_request(
scid: Option<ShortChannelId>,
amount_msat: u64,
) -> HtlcAcceptedRequest {
let payload = TlvStream::default();
HtlcAcceptedRequest {
onion: crate::lsps2::cln::Onion {
short_channel_id: scid,
payload,
next_onion: vec![],
forward_msat: None,
outgoing_cltv_value: None,
shared_secret: vec![],
total_msat: None,
type_: None,
},
htlc: crate::lsps2::cln::Htlc {
amount_msat: Amount::from_msat(amount_msat),
cltv_expiry: 100,
cltv_expiry_relative: 10,
payment_hash: vec![],
extra_tlvs: None,
short_channel_id: ShortChannelId::from(123456789u64),
id: 0,
},
forward_to: None,
}
}
fn create_test_datastore_entry(
peer_id: PublicKey,
expected_payment_size: Option<Msat>,
) -> DatastoreEntry {
let (_, policy) = params_with_promise(&[0u8; 32]);
DatastoreEntry {
peer_id,
opening_fee_params: policy,
expected_payment_size,
}
}
fn minimal_getinfo(height: u32) -> GetinfoResponse {
GetinfoResponse {
lightning_dir: String::default(),
alias: None,
our_features: None,
warning_bitcoind_sync: None,
warning_lightningd_sync: None,
address: None,
binding: None,
blockheight: height,
color: String::default(),
fees_collected_msat: Amount::from_msat(0),
id: PublicKey::from_slice(&PUBKEY).expect("pubkey from slice"),
network: String::default(),
num_active_channels: u32::default(),
num_inactive_channels: u32::default(),
num_peers: u32::default(),
num_pending_channels: u32::default(),
version: String::default(),
}
}
#[tokio::test]
async fn test_successful_get_info() {
let promise_secret = [0u8; 32];
let params = Lsps2PolicyGetInfoResponse {
policy_opening_fee_params_menu: vec![PolicyOpeningFeeParams {
min_fee_msat: Msat(2000),
proportional: Ppm(10000),
valid_until: Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(),
min_lifetime: 1000,
max_client_to_self_delay: 42,
min_payment_size_msat: Msat(1000000),
max_payment_size_msat: Msat(100000000),
}],
};
let promise = params.policy_opening_fee_params_menu[0].get_hmac_hex(&promise_secret);
let fake = FakeCln::default();
*fake.lsps2_getpolicy_response.lock().unwrap() = Some(params);
let handler = Lsps2GetInfoHandler::new(fake, promise_secret);
let request = Lsps2GetInfoRequest { token: None }.into_request(Some("test-id".to_string()));
let payload = create_wrapped_request(&request);
let result = handler.handle(&payload).await.unwrap();
let response: ResponseObject<Lsps2GetInfoResponse> =
serde_json::from_slice(&result).unwrap();
let response = response.into_inner().unwrap();
assert_eq!(
response.opening_fee_params_menu[0].min_payment_size_msat,
Msat(1000000)
);
assert_eq!(
response.opening_fee_params_menu[0].max_payment_size_msat,
Msat(100000000)
);
assert_eq!(
response.opening_fee_params_menu[0].promise,
promise.try_into().unwrap()
);
}
#[tokio::test]
async fn test_get_info_rpc_error_handling() {
let fake = FakeCln::default();
*fake.lsps2_getpolicy_error.lock().unwrap() = Some(ClnRpcError {
code: Some(-1),
message: "not found".to_string(),
data: None,
});
let handler = Lsps2GetInfoHandler::new(fake, [0; 32]);
let request = Lsps2GetInfoRequest { token: None }.into_request(Some("test-id".to_string()));
let payload = create_wrapped_request(&request);
let result = handler.handle(&payload).await;
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.code, 200);
assert!(error.message.contains("failed to fetch policy"));
}
#[tokio::test]
async fn buy_ok_fixed_amount() {
let secret = [0u8; 32];
let fake = FakeCln::default();
*fake.cln_getinfo_response.lock().unwrap() = Some(minimal_getinfo(900_000));
*fake.cln_datastore_response.lock().unwrap() = Some(DatastoreResponse {
generation: Some(0),
hex: None,
string: None,
key: vec![],
});
let handler = Lsps2BuyHandler::new(fake, secret);
let (_policy, buy) = params_with_promise(&secret);
// Set payment_size_msat => "MPP+fixed-invoice" mode.
let req = Lsps2BuyRequest {
opening_fee_params: buy,
payment_size_msat: Some(Msat(2_000_000)),
}
.into_request(Some("ok-fixed".into()));
let payload = create_wrapped_request(&req);
let out = handler.handle(&payload).await.unwrap();
let resp: ResponseObject<Lsps2BuyResponse> = serde_json::from_slice(&out).unwrap();
let resp = resp.into_inner().unwrap();
assert_eq!(resp.lsp_cltv_expiry_delta, DEFAULT_CLTV_EXPIRY_DELTA);
assert!(!resp.client_trusts_lsp);
assert!(resp.jit_channel_scid.to_u64() > 0);
}
#[tokio::test]
async fn buy_ok_variable_amount_no_payment_size() {
let secret = [2u8; 32];
let fake = FakeCln::default();
*fake.cln_getinfo_response.lock().unwrap() = Some(minimal_getinfo(900_100));
*fake.cln_datastore_response.lock().unwrap() = Some(DatastoreResponse {
generation: Some(0),
hex: None,
string: None,
key: vec![],
});
let handler = Lsps2BuyHandler::new(fake, secret);
let (_policy, buy) = params_with_promise(&secret);
// No payment_size_msat => "no-MPP+var-invoice" mode.
let req = Lsps2BuyRequest {
opening_fee_params: buy,
payment_size_msat: None,
}
.into_request(Some("ok-var".into()));
let payload = create_wrapped_request(&req);
let out = handler.handle(&payload).await.unwrap();
let resp: ResponseObject<Lsps2BuyResponse> = serde_json::from_slice(&out).unwrap();
assert!(resp.into_inner().is_ok());
}
#[tokio::test]
async fn buy_rejects_invalid_promise_or_past_valid_until_with_201() {
let secret = [3u8; 32];
let handler = Lsps2BuyHandler::new(FakeCln::default(), secret);
// Case A: wrong promise (derive with different secret)
let (_policy_wrong, mut buy_wrong) = params_with_promise(&[9u8; 32]);
buy_wrong.valid_until = Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap(); // future, so only promise is wrong
let req_wrong = Lsps2BuyRequest {
opening_fee_params: buy_wrong,
payment_size_msat: Some(Msat(2_000_000)),
}
.into_request(Some("bad-promise".into()));
let err1 = handler
.handle(&create_wrapped_request(&req_wrong))
.await
.unwrap_err();
assert_eq!(err1.code, 201);
// Case B: past valid_until
let (_policy, mut buy_past) = params_with_promise(&secret);
buy_past.valid_until = Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(); // past
let req_past = Lsps2BuyRequest {
opening_fee_params: buy_past,
payment_size_msat: Some(Msat(2_000_000)),
}
.into_request(Some("past-valid".into()));
let err2 = handler
.handle(&create_wrapped_request(&req_past))
.await
.unwrap_err();
assert_eq!(err2.code, 201);
}
#[tokio::test]
async fn buy_rejects_when_opening_fee_ge_payment_size_with_202() {
let secret = [4u8; 32];
let handler = Lsps2BuyHandler::new(FakeCln::default(), secret);
// Make min_fee already >= payment_size to trigger 202
let policy = PolicyOpeningFeeParams {
min_fee_msat: Msat(10_000),
proportional: Ppm(0), // no extra percentage
valid_until: Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap(),
min_lifetime: 1000,
max_client_to_self_delay: 42,
min_payment_size_msat: Msat(1),
max_payment_size_msat: Msat(u64::MAX / 2),
};
let hex = policy.get_hmac_hex(&secret);
let promise: Promise = hex.try_into().unwrap();
let buy = OpeningFeeParams {
min_fee_msat: policy.min_fee_msat,
proportional: policy.proportional,
valid_until: policy.valid_until,
min_lifetime: policy.min_lifetime,
max_client_to_self_delay: policy.max_client_to_self_delay,
min_payment_size_msat: policy.min_payment_size_msat,
max_payment_size_msat: policy.max_payment_size_msat,
promise,
};
let req = Lsps2BuyRequest {
opening_fee_params: buy,
payment_size_msat: Some(Msat(9_999)), // strictly less than min_fee => opening_fee >= payment_size
}
.into_request(Some("too-small".into()));
let err = handler
.handle(&create_wrapped_request(&req))
.await
.unwrap_err();
assert_eq!(err.code, 202);
}
#[tokio::test]
async fn buy_rejects_on_fee_overflow_with_203() {
let secret = [5u8; 32];
let handler = Lsps2BuyHandler::new(FakeCln::default(), secret);
// Choose values likely to overflow if multiplication isn't checked:
// opening_fee = min_fee + payment_size * proportional / 1_000_000
let policy = PolicyOpeningFeeParams {
min_fee_msat: Msat(u64::MAX / 2),
proportional: Ppm(u32::MAX), // 4_294_967_295 ppm
valid_until: Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap(),
min_lifetime: 1000,
max_client_to_self_delay: 42,
min_payment_size_msat: Msat(1),
max_payment_size_msat: Msat(u64::MAX),
};
let hex = policy.get_hmac_hex(&secret);
let promise: Promise = hex.try_into().unwrap();
let buy = OpeningFeeParams {
min_fee_msat: policy.min_fee_msat,
proportional: policy.proportional,
valid_until: policy.valid_until,
min_lifetime: policy.min_lifetime,
max_client_to_self_delay: policy.max_client_to_self_delay,
min_payment_size_msat: policy.min_payment_size_msat,
max_payment_size_msat: policy.max_payment_size_msat,
promise,
};
let req = Lsps2BuyRequest {
opening_fee_params: buy,
payment_size_msat: Some(Msat(u64::MAX / 2)),
}
.into_request(Some("overflow".into()));
let err = handler
.handle(&create_wrapped_request(&req))
.await
.unwrap_err();
assert_eq!(err.code, 203);
}
#[tokio::test]
async fn test_htlc_no_scid_continues() {
let fake = FakeCln::default();
let handler = HtlcAcceptedHookHandler::new(fake, 1000);
// HTLC with no short_channel_id (final destination)
let req = create_test_htlc_request(None, 1000000);
let result = handler.handle(req).await.unwrap();
assert_eq!(result.result, HtlcAcceptedResult::Continue);
}
#[tokio::test]
async fn test_htlc_unknown_scid_continues() {
let fake = FakeCln::default();
let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
let scid = ShortChannelId::from(123456789u64);
// Return empty datastore response (SCID not found)
*fake.cln_listdatastore_response.lock().unwrap() =
Some(ListdatastoreResponse { datastore: vec![] });
let req = create_test_htlc_request(Some(scid), 1000000);
let result = handler.handle(req).await.unwrap();
assert_eq!(result.result, HtlcAcceptedResult::Continue);
}
#[tokio::test]
async fn test_htlc_expired_fee_menu_fails() {
let fake = FakeCln::default();
let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
let peer_id = create_peer_id();
let scid = ShortChannelId::from(123456789u64);
// Create datastore entry with expired fee menu
let mut ds_entry = create_test_datastore_entry(peer_id, None);
ds_entry.opening_fee_params.valid_until =
Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(); // expired
let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
*fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
datastore: vec![ListdatastoreDatastore {
key: scid_ds_key(scid),
generation: Some(1),
hex: None,
string: Some(ds_entry_json),
}],
});
// Mock successful deletion
*fake.cln_deldatastore_response.lock().unwrap() = Some(DeldatastoreResponse {
generation: Some(1),
hex: None,
string: None,
key: scid_ds_key(scid),
});
let req = create_test_htlc_request(Some(scid), 1000000);
let result = handler.handle(req).await.unwrap();
assert_eq!(result.result, HtlcAcceptedResult::Fail);
assert_eq!(
result.failure_message.unwrap(),
TEMPORARY_CHANNEL_FAILURE.to_string()
);
}
#[tokio::test]
async fn test_htlc_amount_too_low_fails() {
let fake = FakeCln::default();
let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
let peer_id = create_peer_id();
let scid = ShortChannelId::from(123456789u64);
let ds_entry = create_test_datastore_entry(peer_id, None);
let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
*fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
datastore: vec![ListdatastoreDatastore {
key: scid_ds_key(scid),
generation: Some(1),
hex: None,
string: Some(ds_entry_json),
}],
});
// HTLC amount below minimum
let req = create_test_htlc_request(Some(scid), 100);
let result = handler.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 test_htlc_amount_too_high_fails() {
let fake = FakeCln::default();
let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
let peer_id = create_peer_id();
let scid = ShortChannelId::from(123456789u64);
let ds_entry = create_test_datastore_entry(peer_id, None);
let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
*fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
datastore: vec![ListdatastoreDatastore {
key: scid_ds_key(scid),
generation: Some(1),
hex: None,
string: Some(ds_entry_json),
}],
});
// HTLC amount above maximum
let req = create_test_htlc_request(Some(scid), 200_000_000);
let result = handler.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 test_htlc_amount_doesnt_cover_fee_fails() {
let fake = FakeCln::default();
let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
let peer_id = create_peer_id();
let scid = ShortChannelId::from(123456789u64);
let ds_entry = create_test_datastore_entry(peer_id, None);
let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
*fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
datastore: vec![ListdatastoreDatastore {
key: scid_ds_key(scid),
generation: Some(1),
hex: None,
string: Some(ds_entry_json),
}],
});
// HTLC amount just barely covers minimum fee but not minimum HTLC
let req = create_test_htlc_request(Some(scid), 2500); // min_fee is 2000, htlc_minimum is 1000
let result = handler.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 test_htlc_channel_capacity_request_fails() {
let fake = FakeCln::default();
let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
let peer_id = create_peer_id();
let scid = ShortChannelId::from(123456789u64);
let ds_entry = create_test_datastore_entry(peer_id, None);
let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
*fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
datastore: vec![ListdatastoreDatastore {
key: scid_ds_key(scid),
generation: Some(1),
hex: None,
string: Some(ds_entry_json),
}],
});
*fake.lsps2_getchannelcapacity_error.lock().unwrap() = Some(ClnRpcError {
code: Some(-1),
message: "capacity check failed".to_string(),
data: None,
});
let req = create_test_htlc_request(Some(scid), 10_000_000);
let result = handler.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 test_htlc_policy_denies_channel() {
let fake = FakeCln::default();
let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
let peer_id = create_peer_id();
let scid = ShortChannelId::from(123456789u64);
let ds_entry = create_test_datastore_entry(peer_id, None);
let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
*fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
datastore: vec![ListdatastoreDatastore {
key: scid_ds_key(scid),
generation: Some(1),
hex: None,
string: Some(ds_entry_json),
}],
});
// Policy response with no channel capacity (denied)
*fake.lsps2_getchannelcapacity_response.lock().unwrap() =
Some(Lsps2PolicyGetChannelCapacityResponse {
channel_capacity_msat: None,
});
let req = create_test_htlc_request(Some(scid), 10_000_000);
let result = handler.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 test_htlc_fund_channel_fails() {
let fake = FakeCln::default();
let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
let peer_id = create_peer_id();
let scid = ShortChannelId::from(123456789u64);
let ds_entry = create_test_datastore_entry(peer_id, None);
let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
*fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
datastore: vec![ListdatastoreDatastore {
key: scid_ds_key(scid),
generation: Some(1),
hex: None,
string: Some(ds_entry_json),
}],
});
*fake.lsps2_getchannelcapacity_response.lock().unwrap() =
Some(Lsps2PolicyGetChannelCapacityResponse {
channel_capacity_msat: Some(50_000_000),
});
*fake.cln_fundchannel_error.lock().unwrap() = Some(ClnRpcError {
code: Some(-1),
message: "insufficient funds".to_string(),
data: None,
});
let req = create_test_htlc_request(Some(scid), 10_000_000);
let result = handler.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 test_htlc_successful_flow() {
let fake = FakeCln::default();
let handler = HtlcAcceptedHookHandler {
api: fake.clone(),
htlc_minimum_msat: 1000,
backoff_listpeerchannels: Duration::from_millis(10),
};
let peer_id = create_peer_id();
let scid = ShortChannelId::from(123456789u64);
let ds_entry = create_test_datastore_entry(peer_id, None);
let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
*fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
datastore: vec![ListdatastoreDatastore {
key: scid_ds_key(scid),
generation: Some(1),
hex: None,
string: Some(ds_entry_json),
}],
});
*fake.lsps2_getchannelcapacity_response.lock().unwrap() =
Some(Lsps2PolicyGetChannelCapacityResponse {
channel_capacity_msat: Some(50_000_000),
});
*fake.cln_fundchannel_response.lock().unwrap() = Some(FundchannelResponse {
channel_id: *Sha256::from_bytes_ref(&[1u8; 32]),
outnum: 0,
txid: String::default(),
channel_type: None,
close_to: None,
mindepth: None,
tx: String::default(),
});
let req = create_test_htlc_request(Some(scid), 10_000_000);
let result = handler.handle(req).await.unwrap();
assert_eq!(result.result, HtlcAcceptedResult::Continue);
assert!(result.payload.is_some());
assert!(result.extra_tlvs.is_some());
assert!(result.forward_to.is_some());
// The payload should have the deducted amount
let payload_bytes = result.payload.unwrap();
let payload_tlv = TlvStream::from_bytes(&payload_bytes).unwrap();
// Should contain forward amount.
assert!(payload_tlv.get(TLV_FORWARD_AMT).is_some());
}
#[tokio::test]
#[ignore] // We deactivate the mpp check on the experimental server for
// client side checks.
async fn test_htlc_mpp_not_implemented() {
let fake = FakeCln::default();
let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
let peer_id = create_peer_id();
let scid = ShortChannelId::from(123456789u64);
// Create entry with expected_payment_size (MPP mode)
let mut ds_entry = create_test_datastore_entry(peer_id, None);
ds_entry.expected_payment_size = Some(Msat::from_msat(1000000));
let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
*fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
datastore: vec![ListdatastoreDatastore {
key: scid_ds_key(scid),
generation: Some(1),
hex: None,
string: Some(ds_entry_json),
}],
});
let req = create_test_htlc_request(Some(scid), 10_000_000);
let result = handler.handle(req).await.unwrap();
assert_eq!(result.result, HtlcAcceptedResult::Fail);
assert_eq!(
result.failure_message.unwrap(),
UNKNOWN_NEXT_PEER.to_string()
);
}
}