Files
palladum-lightning/plugins/lsps-plugin/src/client.rs
Peter Neuroth 719fb2ce52 plugin: change method name of lsps-jitchannel
The original method name was lsps-lsps2-invoice but I somehow messed it
up and renamed during a rebase.

Changelog-Changed: lsps-jitchannel is now lsps-lsps2-invoice

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

904 lines
30 KiB
Rust

use anyhow::{anyhow, bail, Context};
use bitcoin::hashes::{hex::FromHex, sha256, Hash};
use chrono::{Duration, Utc};
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::cln::tlv::encode_tu64;
use cln_lsps::lsps2::cln::{
HtlcAcceptedRequest, HtlcAcceptedResponse, InvoicePaymentRequest, OpenChannelRequest,
TLV_FORWARD_AMT, TLV_PAYMENT_SECRET,
};
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::{
DatastoreMode, DatastoreRequest, DeldatastoreRequest, DelinvoiceRequest, DelinvoiceStatus,
ListdatastoreRequest, ListinvoicesRequest, ListpeersRequest,
};
use cln_rpc::model::responses::InvoiceResponse;
use cln_rpc::primitives::{Amount, AmountOrAny, PublicKey, ShortChannelId};
use cln_rpc::ClnRpc;
use log::{debug, info, warn};
use rand::{CryptoRng, Rng};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::str::FromStr as _;
/// An option to enable this service.
const OPTION_ENABLED: options::FlagConfigOption = options::ConfigOption::new_flag(
"experimental-lsps-client",
"Enables an LSPS client on the node.",
);
#[derive(Clone)]
struct State {
hook_manager: CustomMessageHookManager,
}
impl WithCustomMessageHookManager for State {
fn get_custommsg_hook_manager(&self) -> &CustomMessageHookManager {
&self.hook_manager
}
}
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
let hook_manager = CustomMessageHookManager::new();
let state = State { hook_manager };
if let Some(plugin) = cln_plugin::Builder::new(tokio::io::stdin(), tokio::io::stdout())
.hook("custommsg", CustomMessageHookManager::on_custommsg::<State>)
.option(OPTION_ENABLED)
.rpcmethod(
"lsps-listprotocols",
"list protocols supported by lsp",
on_lsps_listprotocols,
)
.rpcmethod(
"lsps-lsps2-getinfo",
"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,
)
.rpcmethod(
"lsps-lsps2-approve",
"Low-level command to approve a jit channel opening for the given scid",
on_lsps_lsps2_approve,
)
.rpcmethod(
"lsps-lsps2-invoice",
"Requests a new jit channel from LSP and returns the matching invoice",
on_lsps_lsps2_invoice,
)
.hook("invoice_payment", on_invoice_payment)
.hook("htlc_accepted", on_htlc_accepted)
.hook("openchannel", on_openchannel)
.configure()
.await?
{
if !plugin.option(&OPTION_ENABLED)? {
return plugin
.disable(&format!("`{}` not enabled", OPTION_ENABLED.name))
.await;
}
let plugin = plugin.start(state).await?;
plugin.join().await
} else {
Ok(())
}
}
/// Rpc Method handler for `lsps-lsps2-getinfo`.
async fn on_lsps_lsps2_getinfo(
p: cln_plugin::Plugin<State>,
v: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
let req: ClnRpcLsps2GetinfoRequest =
serde_json::from_value(v).context("Failed to parse request JSON")?;
debug!(
"Requesting opening fee menu from lsp {} with token {:?}",
req.lsp_id, req.token
);
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?;
let lsp_status = check_peer_lsp_status(&mut cln_client, &req.lsp_id).await?;
// Fail early: Check that we are connected to the peer.
if !lsp_status.connected {
bail!("Not connected to peer {}", &req.lsp_id);
};
// From Blip52: LSPs MAY set the features bit numbered 729
// (option_supports_lsps)...
// We only log that it is not set but don't fail.
if !lsp_status.has_lsp_feature {
debug!("Peer {} doesn't have the LSP feature bit set.", &req.lsp_id);
}
// 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);
// 1. Call lsps2.get_info.
let info_req = Lsps2GetInfoRequest { token: req.token };
let info_res: Lsps2GetInfoResponse = client
.call_typed(info_req)
.await
.context("lsps2.get_info call failed")?;
debug!("received lsps2.get_info response: {:?}", info_res);
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?;
let lsp_status = check_peer_lsp_status(&mut cln_client, &req.lsp_id).await?;
// Fail early: Check that we are connected to the peer.
if !lsp_status.connected {
bail!("Not connected to peer {}", &req.lsp_id);
};
// From Blip52: LSPs MAY set the features bit numbered 729
// (option_supports_lsps)...
// We only log that it is not set but don't fail.
if !lsp_status.has_lsp_feature {
debug!("Peer {} doesn't have the LSP feature bit set.", &req.lsp_id);
}
// 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);
let selected_params = req.opening_fee_params;
if let Some(payment_size) = req.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: req.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_lsps2_approve(
p: cln_plugin::Plugin<State>,
v: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
let req: ClnRpcLsps2Approve = serde_json::from_value(v)?;
let ds_rec = DatastoreRecord {
jit_channel_scid: req.jit_channel_scid.clone(),
client_trusts_lsp: req.client_trusts_lsp.unwrap_or_default(),
};
let ds_rec_json = serde_json::to_string(&ds_rec)?;
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?;
let ds_req = DatastoreRequest {
generation: None,
hex: None,
mode: Some(DatastoreMode::CREATE_OR_REPLACE),
string: Some(ds_rec_json),
key: vec!["lsps".to_string(), "client".to_string(), req.lsp_id.clone()],
};
let _ds_res = cln_client.call_typed(&ds_req).await?;
let ds_req = DatastoreRequest {
generation: None,
hex: None,
mode: Some(DatastoreMode::CREATE_OR_REPLACE),
string: Some(req.lsp_id),
key: vec!["lsps".to_string(), "invoice".to_string(), req.payment_hash],
};
let _ds_res = cln_client.call_typed(&ds_req).await?;
Ok(serde_json::Value::default())
}
/// RPC Method handler for `lsps-jitchannel`.
/// Calls lsps2.get_info, selects parameters, calculates fee, calls lsps2.buy,
/// creates invoice.
async fn on_lsps_lsps2_invoice(
p: cln_plugin::Plugin<State>,
v: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
#[derive(Deserialize)]
struct Request {
lsp_id: String,
// Optional: for discounts/API keys
token: Option<String>,
// Pass-through of cln invoice rpc params
pub amount_msat: cln_rpc::primitives::AmountOrAny,
pub description: String,
pub label: String,
}
let req: Request = serde_json::from_value(v).context("Failed to parse request JSON")?;
debug!(
"Handling lsps-buy-jit-channel request for peer {} with payment_size {:?} and token {:?}",
req.lsp_id, req.amount_msat, req.token
);
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?;
// 1. Get LSP's opening fee menu.
let info_res: Lsps2GetInfoResponse = cln_client
.call_raw(
"lsps-lsps2-getinfo",
&ClnRpcLsps2GetinfoRequest {
lsp_id: req.lsp_id.clone(),
token: req.token,
},
)
.await?;
// 2. Select Fee Parameters.
// Simple strategy for now: choose the first valid option as LSPS2 requires
// this to be the cheapest. Could be more sophisticated (e.g., user choice).
let selected_params = info_res
.opening_fee_params_menu
.iter()
.find(|params| {
// Basic validation on client side: check expiry and promise length
let fut_now = Utc::now() + Duration::minutes(1); // Add some extra time for network delay
let expiry_valid = params.valid_until > fut_now;
if !expiry_valid {
warn!("Ignoring expired fee params from LSP {:?}", params);
}
expiry_valid
})
.cloned() // Clone the selected params
.ok_or_else(|| {
anyhow!(
"No valid/unexpired fee parameters offered by LSP {}",
req.lsp_id
)
})?;
info!("Selected fee parameters: {:?}", selected_params);
let payment_size_msat = match req.amount_msat {
AmountOrAny::Amount(amount) => Some(Msat::from_msat(amount.msat())),
AmountOrAny::Any => None,
};
// Check that the amount is big enough to cover the fee and a single HTLC.
let reduced_amount_msat = if let Some(payment_msat) = payment_size_msat {
match compute_opening_fee(
payment_msat.msat(),
selected_params.min_fee_msat.msat(),
selected_params.proportional.ppm() as u64,
) {
Some(fee_msat) => {
if payment_msat.msat() - fee_msat < 1000 {
bail!(
"amount_msat {}msat is too small, needs to be at least {}msat: opening fee is {}msat",
payment_msat,
1000 + fee_msat,
fee_msat
);
}
Some(payment_msat.msat() - fee_msat)
}
None => bail!("failed to compute opening fee"),
}
} else {
None
};
// 3. Request channel from LSP.
let buy_res: Lsps2BuyResponse = cln_client
.call_raw(
"lsps-lsps2-buy",
&ClnRpcLsps2BuyRequest {
lsp_id: req.lsp_id.clone(),
payment_size_msat,
opening_fee_params: selected_params.clone(),
},
)
.await?;
debug!("Received lsps2.buy response: {:?}", buy_res);
// We define the invoice expiry here to avoid cloning `selected_params`
// as they are about to be moved to the `Lsps2BuyRequest`.
let expiry = (selected_params.valid_until - Utc::now()).num_seconds();
if expiry <= 10 {
return Err(anyhow!(
"Invoice lifetime is too short, options are valid until: {}",
selected_params.valid_until,
));
}
// 4. Create and invoice with a route hint pointing to the LSP, using
// the scid we got from the LSP.
let hint = RoutehintHopDev {
id: req.lsp_id.clone(),
short_channel_id: buy_res.jit_channel_scid.to_string(),
fee_base_msat: Some(0),
fee_proportional_millionths: 0,
cltv_expiry_delta: u16::try_from(buy_res.lsp_cltv_expiry_delta)?,
};
// Generate a preimage if we have an amount specified.
let preimage = if payment_size_msat.is_some() {
Some(gen_rand_preimage_hex(&mut rand::rng()))
} else {
None
};
let public_inv: cln_rpc::model::responses::InvoiceResponse = cln_client
.call_raw(
"invoice",
&InvoiceRequest {
amount_msat: req.amount_msat,
dev_routes: Some(vec![vec![hint.clone()]]),
description: req.description.clone(),
label: req.label.clone(),
expiry: Some(expiry as u64),
cltv: None,
deschashonly: None,
preimage: preimage.clone(),
exposeprivatechannels: None,
fallbacks: None,
},
)
.await?;
// We need to reduce the expected amount if the invoice has an amount set
if let Some(amount_msat) = reduced_amount_msat {
debug!(
"amount_msat is specified: create new invoice with reduced amount {}msat",
amount_msat,
);
let _ = cln_client
.call_typed(&DelinvoiceRequest {
desconly: None,
status: DelinvoiceStatus::UNPAID,
label: req.label.clone(),
})
.await?;
let _: cln_rpc::model::responses::InvoiceResponse = cln_client
.call_raw(
"invoice",
&InvoiceRequest {
amount_msat: AmountOrAny::Amount(Amount::from_msat(amount_msat)),
dev_routes: Some(vec![vec![hint]]),
description: req.description,
label: req.label,
expiry: Some(expiry as u64),
cltv: None,
deschashonly: None,
preimage,
exposeprivatechannels: None,
fallbacks: None,
},
)
.await?;
}
// 5. Approve jit_channel_scid for a jit channel opening.
let appr_req = ClnRpcLsps2Approve {
lsp_id: req.lsp_id,
jit_channel_scid: buy_res.jit_channel_scid,
payment_hash: public_inv.payment_hash.to_string(),
client_trusts_lsp: Some(buy_res.client_trusts_lsp),
};
let _: serde_json::Value = cln_client.call_raw("lsps-lsps2-approve", &appr_req).await?;
// 6. Return invoice.
let out = InvoiceResponse {
bolt11: public_inv.bolt11,
created_index: public_inv.created_index,
warning_capacity: public_inv.warning_capacity,
warning_deadends: public_inv.warning_deadends,
warning_mpp: public_inv.warning_mpp,
warning_offline: public_inv.warning_offline,
warning_private_unused: public_inv.warning_private_unused,
expires_at: public_inv.expires_at,
payment_hash: public_inv.payment_hash,
payment_secret: public_inv.payment_secret,
};
Ok(serde_json::to_value(out)?)
}
async fn on_invoice_payment(
p: cln_plugin::Plugin<State>,
v: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
let req: InvoicePaymentRequest = ok_or_continue!(
serde_json::from_value(v).context("failed to deserialize htlc_accepted request JSON")
);
let preimage = ok_or_continue!(
<[u8; 32]>::from_hex(&req.payment.preimage).context("invalid preimage hex")
);
let hash = payment_hash(&preimage);
// Delete DS-entries.
let dir = p.configuration().lightning_dir;
let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file);
let mut cln_client = ok_or_continue!(cln_rpc::ClnRpc::new(rpc_path.clone())
.await
.context("failed to connect to core-lightning"));
ok_or_continue!(cln_client
.call_typed(&DeldatastoreRequest {
key: vec!["lsps".to_string(), "invoice".to_string(), hash.to_string()],
generation: None,
})
.await
.context("failed to delete datastore record"));
Ok(serde_json::json!({"result": "continue"}))
}
async fn on_htlc_accepted(
p: cln_plugin::Plugin<State>,
v: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
let req: HtlcAcceptedRequest = ok_or_continue!(
serde_json::from_value(v).context("failed to deserialize htlc_accepted request JSON")
);
let htlc_amt = req.htlc.amount_msat;
let onion_amt = some_or_continue!(
req.onion.forward_msat,
"missing forward_msat in onion, continue"
);
let payment_data = some_or_continue!(
req.onion.payload.get(TLV_PAYMENT_SECRET),
"htlc is a forward, continue"
);
// Check that the htlc belongs to a jit-channel request.
let dir = p.configuration().lightning_dir;
let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file);
let mut cln_client = ok_or_continue!(cln_rpc::ClnRpc::new(rpc_path.clone())
.await
.context("failed to connect to core-lightning"));
let lsp_data = ok_or_continue!(cln_client
.call_typed(&ListdatastoreRequest {
key: Some(vec![
"lsps".to_string(),
"invoice".to_string(),
hex::encode(&req.htlc.payment_hash),
]),
})
.await
.context("failed to fetch datastore record"));
// If we don't know about this payment it's not an LSP payment, continue.
some_or_continue!(lsp_data.datastore.first());
let extra_fee_msat = req
.htlc
.extra_tlvs
.as_ref()
.map(|tlvs| tlvs.get_u64(65537))
.transpose()?
.flatten()
.unwrap_or_default();
debug!(
"incoming jit-channel htlc with htlc_amt={}, onion_amt={} and extra_fee={}",
htlc_amt.msat(),
onion_amt.msat(),
extra_fee_msat
);
if htlc_amt.msat() + extra_fee_msat != onion_amt.msat() {
warn!(
"amounts don't match (htlc_amt + extra_fee) = {} != onion_amt = {}",
(htlc_amt.msat() + extra_fee_msat),
onion_amt.msat()
);
// FIXME: If we are strict, we should reject the htlc here.
}
let inv_res = ok_or_continue!(cln_client
.call_typed(&ListinvoicesRequest {
index: None,
invstring: None,
label: None,
limit: None,
offer_id: None,
payment_hash: Some(hex::encode(&req.htlc.payment_hash)),
start: None,
})
.await
.context("failed to get invoice"));
let invoice = some_or_continue!(
inv_res.invoices.first(),
"no invoice found for jit-channel-opening with payment_hash={}",
hex::encode(&req.htlc.payment_hash)
);
let total_amt = invoice.amount_msat.unwrap_or(htlc_amt).msat();
let mut payload = req.onion.payload.clone();
payload.set_tu64(TLV_FORWARD_AMT, htlc_amt.msat());
let mut ps = Vec::new();
ps.extend_from_slice(&payment_data[0..32]);
ps.extend(encode_tu64(total_amt));
payload.insert(TLV_PAYMENT_SECRET, ps);
let payload_bytes = ok_or_continue!(payload
.to_bytes()
.context("failed to encode payload as bytes"));
info!(
"Amended onion payload with forward_amt={} and total_msat={}",
htlc_amt.msat(),
total_amt
);
let value = serde_json::to_value(HtlcAcceptedResponse::continue_(
Some(payload_bytes),
None,
None,
))?;
Ok(value)
}
/// Allows `zero_conf` channels to the client if the LSP is on the allowlist.
async fn on_openchannel(
p: cln_plugin::Plugin<State>,
v: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
let req: OpenChannelRequest = ok_or_continue!(
serde_json::from_value(v).context("failed to deserialize open_channel request JSON")
);
// Fixme: Check that channel parameters are as negotiated.
let dir = p.configuration().lightning_dir;
let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file);
let mut cln_client = ok_or_continue!(cln_rpc::ClnRpc::new(rpc_path.clone())
.await
.context("failed to connect to core-lightning"));
let ds_req = ListdatastoreRequest {
key: Some(vec![
"lsps".to_string(),
"client".to_string(),
req.openchannel.id.clone(),
]),
};
let ds_res = ok_or_continue!(cln_client
.call_typed(&ds_req)
.await
.context("failed to get datastore record"));
if let Some(_rec) = ds_res.datastore.iter().next() {
info!(
"Allowing zero-conf channel from LSP {}",
&req.openchannel.id
);
let ds_req = DeldatastoreRequest {
generation: None,
key: vec![
"lsps".to_string(),
"client".to_string(),
req.openchannel.id.clone(),
],
};
if let Some(err) = cln_client.call_typed(&ds_req).await.err() {
// We can do nothing but report that there was an issue deleting the
// datastore record.
warn!("Failed to delete LSP record from datastore: {}", err);
}
// Fixme: Check that we actually use client-trusts-LSP mode - can be
// found in the ds record.
return Ok(serde_json::json!({
"result": "continue",
"mindepth": 0,
}));
} else {
// Not a requested JIT-channel opening, continue.
Ok(serde_json::json!({"result": "continue"}))
}
}
async fn on_lsps_listprotocols(
p: cln_plugin::Plugin<State>,
v: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
#[derive(Deserialize)]
struct Request {
lsp_id: String,
}
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?;
let req: Request = serde_json::from_value(v).context("Failed to parse request JSON")?;
let lsp_status = check_peer_lsp_status(&mut cln_client, &req.lsp_id).await?;
// Fail early: Check that we are connected to the peer.
if !lsp_status.connected {
bail!("Not connected to peer {}", &req.lsp_id);
};
// From Blip52: LSPs MAY set the features bit numbered 729
// (option_supports_lsps)...
// We only log that it is not set but don't fail.
if !lsp_status.has_lsp_feature {
debug!("Peer {} doesn't have the LSP feature bit set.", &req.lsp_id);
}
// Create the transport first and handle potential errors
let transport = Bolt8Transport::new(
&req.lsp_id,
rpc_path,
p.state().hook_manager.clone(),
None, // Use default timeout
)
.context("Failed to create Bolt8Transport")?;
// Now create the client using the transport
let client = JsonRpcClient::new(transport);
let request = lsps0::model::Lsps0listProtocolsRequest {};
let res: lsps0::model::Lsps0listProtocolsResponse = client
.call_typed(request)
.await
.map_err(|e| anyhow!("lsps0.list_protocols call failed: {}", e))?;
debug!("Received lsps0.list_protocols response: {:?}", res);
Ok(serde_json::to_value(res)?)
}
struct PeerLspStatus {
connected: bool,
has_lsp_feature: bool,
}
/// Returns the `PeerLspStatus`, containing information about the connectivity
/// and the LSP feature bit.
async fn check_peer_lsp_status(
cln_client: &mut ClnRpc,
peer_id: &str,
) -> Result<PeerLspStatus, anyhow::Error> {
let res = cln_client
.call_typed(&ListpeersRequest {
id: Some(PublicKey::from_str(peer_id)?),
level: None,
})
.await?;
let peer = match res.peers.first() {
None => {
return Ok(PeerLspStatus {
connected: false,
has_lsp_feature: false,
})
}
Some(p) => p,
};
let connected = peer.connected;
let has_lsp_feature = if let Some(f_str) = &peer.features {
let feature_bits = hex::decode(f_str)
.map_err(|e| anyhow!("Invalid feature bits hex for peer {peer_id}, {f_str}: {e}"))?;
util::is_feature_bit_set_reversed(&feature_bits, LSP_FEATURE_BIT)
} else {
false
};
Ok(PeerLspStatus {
connected,
has_lsp_feature,
})
}
pub fn gen_rand_preimage_hex<R: Rng + CryptoRng>(rng: &mut R) -> String {
let mut pre = [0u8; 32];
rng.fill_bytes(&mut pre);
hex::encode(&pre)
}
pub fn payment_hash(preimage: &[u8]) -> sha256::Hash {
sha256::Hash::hash(preimage)
}
fn continue_ok() -> Result<serde_json::Value, anyhow::Error> {
Ok(serde_json::json!({"result": "continue"}))
}
#[macro_export]
macro_rules! some_or_continue {
($expr:expr) => {
match $expr {
Some(v) => v,
None => return continue_ok(),
}
};
($expr:expr, $($log:tt)+) => {
match $expr {
Some(v) => v,
None => {
debug!($($log)+);
return continue_ok();
},
}
};
}
#[macro_export]
macro_rules! ok_or_continue {
($expr:expr) => {
match $expr {
Ok(v) => v,
Err(e) => {
debug!("{:#}", e);
return continue_ok();
}
}
};
($expr:expr, $($log:tt)+) => {
match $expr {
Ok(v) => v,
Err(e) => {
debug!("{}: {:#}",format_args!($($log)+), e);
return continue_ok();
}
}
};
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct LspsBuyJitChannelResponse {
bolt11: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InvoiceRequest {
pub amount_msat: cln_rpc::primitives::AmountOrAny,
pub description: String,
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expiry: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fallbacks: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preimage: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cltv: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deschashonly: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exposeprivatechannels: Option<Vec<String>>,
#[serde(rename = "dev-routes", skip_serializing_if = "Option::is_none")]
pub dev_routes: Option<Vec<Vec<RoutehintHopDev>>>,
}
// This variant is used by dev-routes, using slightly different key names.
// TODO Remove once we have consolidated the routehint format.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RoutehintHopDev {
pub id: String,
pub short_channel_id: String,
pub fee_base_msat: Option<u64>,
pub fee_proportional_millionths: u32,
pub cltv_expiry_delta: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClnRpcLsps2BuyRequest {
lsp_id: String,
payment_size_msat: Option<Msat>,
opening_fee_params: OpeningFeeParams,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClnRpcLsps2GetinfoRequest {
lsp_id: String,
token: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClnRpcLsps2Approve {
lsp_id: String,
jit_channel_scid: ShortChannelId,
payment_hash: String,
#[serde(default)]
client_trusts_lsp: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DatastoreRecord {
jit_channel_scid: ShortChannelId,
client_trusts_lsp: bool,
}