lsp_plugin: add lsps2_buy request and handler
Adds the lsps2.buy request to the client and the lsps2.buy handler to the LSP service. Signed-off-by: Peter Neuroth <pet.v.ne@gmail.com>
This commit is contained in:
committed by
Rusty Russell
parent
581eb3076f
commit
ecb1f4c7e7
@@ -1,17 +1,21 @@
|
||||
use anyhow::{anyhow, Context};
|
||||
use cln_lsps::jsonrpc::client::JsonRpcClient;
|
||||
use cln_lsps::lsps0::primitives::Msat;
|
||||
use cln_lsps::lsps0::{
|
||||
self,
|
||||
transport::{Bolt8Transport, CustomMessageHookManager, WithCustomMessageHookManager},
|
||||
};
|
||||
use cln_lsps::lsps2::model::{Lsps2GetInfoRequest, Lsps2GetInfoResponse};
|
||||
use cln_lsps::lsps2::model::{
|
||||
compute_opening_fee, Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest,
|
||||
Lsps2GetInfoResponse, OpeningFeeParams,
|
||||
};
|
||||
use cln_lsps::util;
|
||||
use cln_lsps::LSP_FEATURE_BIT;
|
||||
use cln_plugin::options;
|
||||
use cln_rpc::model::requests::ListpeersRequest;
|
||||
use cln_rpc::primitives::PublicKey;
|
||||
use cln_rpc::primitives::{AmountOrAny, PublicKey};
|
||||
use cln_rpc::ClnRpc;
|
||||
use log::debug;
|
||||
use log::{debug, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::str::FromStr as _;
|
||||
@@ -51,6 +55,11 @@ async fn main() -> Result<(), anyhow::Error> {
|
||||
"Low-level command to request the opening fee menu of an LSP",
|
||||
on_lsps_lsps2_getinfo,
|
||||
)
|
||||
.rpcmethod(
|
||||
"lsps-lsps2-buy",
|
||||
"Low-level command to return the lsps2.buy result from an ",
|
||||
on_lsps_lsps2_buy,
|
||||
)
|
||||
.configure()
|
||||
.await?
|
||||
{
|
||||
@@ -108,6 +117,103 @@ async fn on_lsps_lsps2_getinfo(
|
||||
Ok(serde_json::to_value(info_res)?)
|
||||
}
|
||||
|
||||
/// Rpc Method handler for `lsps-lsps2-buy`.
|
||||
async fn on_lsps_lsps2_buy(
|
||||
p: cln_plugin::Plugin<State>,
|
||||
v: serde_json::Value,
|
||||
) -> Result<serde_json::Value, anyhow::Error> {
|
||||
let req: ClnRpcLsps2BuyRequest =
|
||||
serde_json::from_value(v).context("Failed to parse request JSON")?;
|
||||
debug!(
|
||||
"Asking for a channel from lsp {} with opening fee params {:?} and payment size {:?}",
|
||||
req.lsp_id, req.opening_fee_params, req.payment_size_msat
|
||||
);
|
||||
|
||||
let dir = p.configuration().lightning_dir;
|
||||
let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file);
|
||||
let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?;
|
||||
|
||||
// Fail early: Check that we are connected to the peer and that it has the
|
||||
// LSP feature bit set.
|
||||
ensure_lsp_connected(&mut cln_client, &req.lsp_id).await?;
|
||||
|
||||
// Create Transport and Client
|
||||
let transport = Bolt8Transport::new(
|
||||
&req.lsp_id,
|
||||
rpc_path.clone(), // Clone path for potential reuse
|
||||
p.state().hook_manager.clone(),
|
||||
None, // Use default timeout
|
||||
)
|
||||
.context("Failed to create Bolt8Transport")?;
|
||||
let client = JsonRpcClient::new(transport);
|
||||
|
||||
// Convert from AmountOrAny to Msat.
|
||||
let payment_size_msat = if let Some(payment_size) = req.payment_size_msat {
|
||||
match payment_size {
|
||||
AmountOrAny::Amount(amount) => Some(Msat::from_msat(amount.msat())),
|
||||
AmountOrAny::Any => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let selected_params = req.opening_fee_params;
|
||||
|
||||
if let Some(payment_size) = payment_size_msat {
|
||||
if payment_size < selected_params.min_payment_size_msat {
|
||||
return Err(anyhow!(
|
||||
"Requested payment size {}msat is below minimum {}msat required by LSP",
|
||||
payment_size,
|
||||
selected_params.min_payment_size_msat
|
||||
));
|
||||
}
|
||||
if payment_size > selected_params.max_payment_size_msat {
|
||||
return Err(anyhow!(
|
||||
"Requested payment size {}msat is above maximum {}msat allowed by LSP",
|
||||
payment_size,
|
||||
selected_params.max_payment_size_msat
|
||||
));
|
||||
}
|
||||
|
||||
let opening_fee = compute_opening_fee(
|
||||
payment_size.msat(),
|
||||
selected_params.min_fee_msat.msat(),
|
||||
selected_params.proportional.ppm() as u64,
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
warn!(
|
||||
"Opening fee calculation overflowed for payment size {}",
|
||||
payment_size
|
||||
);
|
||||
anyhow!("failed to calculate opening fee")
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"Calculated opening fee: {}msat for payment size {}msat",
|
||||
opening_fee, payment_size
|
||||
);
|
||||
} else {
|
||||
info!("No payment size specified, requesting JIT channel for a variable-amount invoice.");
|
||||
// Check if the selected params allow for variable amount (implicitly they do if max > min)
|
||||
if selected_params.min_payment_size_msat >= selected_params.max_payment_size_msat {
|
||||
// This shouldn't happen if LSP follows spec, but good to check.
|
||||
warn!("Selected fee params seem unsuitable for variable amount: min >= max");
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Calling lsps2.buy for peer {}", req.lsp_id);
|
||||
let buy_req = Lsps2BuyRequest {
|
||||
opening_fee_params: selected_params, // Pass the chosen params back
|
||||
payment_size_msat,
|
||||
};
|
||||
let buy_res: Lsps2BuyResponse = client
|
||||
.call_typed(buy_req)
|
||||
.await
|
||||
.context("lsps2.buy call failed")?;
|
||||
|
||||
Ok(serde_json::to_value(buy_res)?)
|
||||
}
|
||||
|
||||
async fn on_lsps_listprotocols(
|
||||
p: cln_plugin::Plugin<State>,
|
||||
v: serde_json::Value,
|
||||
@@ -188,6 +294,14 @@ async fn ensure_lsp_connected(cln_client: &mut ClnRpc, lsp_id: &str) -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ClnRpcLsps2BuyRequest {
|
||||
lsp_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
payment_size_msat: Option<AmountOrAny>,
|
||||
opening_fee_params: OpeningFeeParams,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ClnRpcLsps2GetinfoRequest {
|
||||
lsp_id: String,
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
use crate::{
|
||||
jsonrpc::{server::RequestHandler, JsonRpcResponse as _, RequestObject, RpcError},
|
||||
lsps2::model::{
|
||||
Lsps2GetInfoRequest, Lsps2GetInfoResponse, Lsps2PolicyGetInfoRequest,
|
||||
Lsps2PolicyGetInfoResponse, OpeningFeeParams, Promise,
|
||||
lsps0::primitives::ShortChannelId,
|
||||
lsps2::{
|
||||
model::{
|
||||
DatastoreEntry, Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest,
|
||||
Lsps2GetInfoResponse, 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 cln_rpc::ClnRpc;
|
||||
use cln_rpc::{
|
||||
model::{
|
||||
requests::{DatastoreMode, DatastoreRequest, GetinfoRequest},
|
||||
responses::{DatastoreResponse, GetinfoResponse},
|
||||
},
|
||||
ClnRpc,
|
||||
};
|
||||
use log::warn;
|
||||
use rand::{rng, Rng as _};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[async_trait]
|
||||
@@ -17,8 +30,14 @@ pub trait ClnApi: Send + Sync {
|
||||
&self,
|
||||
params: &Lsps2PolicyGetInfoRequest,
|
||||
) -> AnyResult<Lsps2PolicyGetInfoResponse>;
|
||||
|
||||
async fn cln_getinfo(&self, params: &GetinfoRequest) -> AnyResult<GetinfoResponse>;
|
||||
|
||||
async fn cln_datastore(&self, params: &DatastoreRequest) -> AnyResult<DatastoreResponse>;
|
||||
}
|
||||
|
||||
const DEFAULT_CLTV_EXPIRY_DELTA: u32 = 144;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClnApiRpc {
|
||||
rpc_path: PathBuf,
|
||||
@@ -46,6 +65,22 @@ impl ClnApi for ClnApiRpc {
|
||||
.map_err(anyhow::Error::new)
|
||||
.with_context(|| "calling dev-lsps2-getpolicy")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for the `lsps2.get_info` method.
|
||||
@@ -124,6 +159,106 @@ impl<T: ClnApi + 'static> RequestHandler for Lsps2GetInfoHandler<T> {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -136,8 +271,9 @@ mod tests {
|
||||
util::wrap_payload_with_peer_id,
|
||||
};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use cln_rpc::primitives::PublicKey;
|
||||
use cln_rpc::primitives::{Amount, PublicKey};
|
||||
use cln_rpc::RpcError as ClnRpcError;
|
||||
use serde::Serialize;
|
||||
|
||||
const PUBKEY: [u8; 33] = [
|
||||
0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87,
|
||||
@@ -149,15 +285,45 @@ mod tests {
|
||||
PublicKey::from_slice(&PUBKEY).expect("Valid pubkey")
|
||||
}
|
||||
|
||||
fn create_wrapped_request(request: &RequestObject<Lsps2GetInfoRequest>) -> Vec<u8> {
|
||||
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>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -174,6 +340,54 @@ mod tests {
|
||||
};
|
||||
panic!("No lsps2 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");
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
@@ -236,4 +450,181 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,3 +13,6 @@ pub const OPTION_PROMISE_SECRET: options::StringConfigOption =
|
||||
"dev-lsps2-promise-secret",
|
||||
"A 64-character hex string that is the secret for promises",
|
||||
);
|
||||
|
||||
pub const DS_MAIN_KEY: &'static str = "lsps";
|
||||
pub const DS_SUB_KEY: &'static str = "lsps2";
|
||||
|
||||
@@ -6,7 +6,7 @@ use cln_lsps::jsonrpc::{server::JsonRpcServer, JsonRpcRequest};
|
||||
use cln_lsps::lsps0::handler::Lsps0ListProtocolsHandler;
|
||||
use cln_lsps::lsps0::model::Lsps0listProtocolsRequest;
|
||||
use cln_lsps::lsps0::transport::{self, CustomMsg};
|
||||
use cln_lsps::lsps2::model::Lsps2GetInfoRequest;
|
||||
use cln_lsps::lsps2::model::{Lsps2BuyRequest, Lsps2GetInfoRequest};
|
||||
use cln_lsps::util::wrap_payload_with_peer_id;
|
||||
use cln_lsps::{lsps0, lsps2, util, LSP_FEATURE_BIT};
|
||||
use cln_plugin::options::ConfigOption;
|
||||
@@ -94,11 +94,15 @@ async fn main() -> Result<(), anyhow::Error> {
|
||||
};
|
||||
|
||||
let cln_api_rpc = lsps2::handler::ClnApiRpc::new(rpc_path);
|
||||
let getinfo_handler = lsps2::handler::Lsps2GetInfoHandler::new(cln_api_rpc, secret);
|
||||
lsps_builder = lsps_builder.with_handler(
|
||||
Lsps2GetInfoRequest::METHOD.to_string(),
|
||||
Arc::new(getinfo_handler),
|
||||
);
|
||||
let getinfo_handler =
|
||||
lsps2::handler::Lsps2GetInfoHandler::new(cln_api_rpc.clone(), secret);
|
||||
let buy_handler = lsps2::handler::Lsps2BuyHandler::new(cln_api_rpc, secret);
|
||||
lsps_builder = lsps_builder
|
||||
.with_handler(
|
||||
Lsps2GetInfoRequest::METHOD.to_string(),
|
||||
Arc::new(getinfo_handler),
|
||||
)
|
||||
.with_handler(Lsps2BuyRequest::METHOD.to_string(), Arc::new(buy_handler));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,3 +64,27 @@ def test_lsps2_getinfo(node_factory):
|
||||
|
||||
res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id'])
|
||||
assert res["opening_fee_params_menu"]
|
||||
|
||||
|
||||
def test_lsps2_buy(node_factory):
|
||||
# We need a policy service to fetch from.
|
||||
plugin = os.path.join(os.path.dirname(__file__), 'plugins/lsps2_policy.py')
|
||||
|
||||
l1, l2 = node_factory.get_nodes(2, opts=[
|
||||
{"dev-lsps-client-enabled": None},
|
||||
{
|
||||
"dev-lsps-service-enabled": None,
|
||||
"dev-lsps2-service-enabled": None,
|
||||
"dev-lsps2-promise-secret": "0" * 64,
|
||||
"plugin": plugin
|
||||
}
|
||||
])
|
||||
|
||||
# We don't need a channel to query for lsps services
|
||||
node_factory.join_nodes([l1, l2], fundchannel=False)
|
||||
|
||||
res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id'])
|
||||
params = res["opening_fee_params_menu"][0]
|
||||
|
||||
res = l1.rpc.lsps_lsps2_buy(lsp_id=l2.info['id'], payment_size_msat=None, opening_fee_params=params)
|
||||
assert res
|
||||
|
||||
Reference in New Issue
Block a user