Skip to content

Commit c298a7b

Browse files
committed
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>
1 parent 20dabaf commit c298a7b

File tree

4 files changed

+483
-51
lines changed

4 files changed

+483
-51
lines changed

plugins/lsps-plugin/src/client.rs

Lines changed: 169 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ use cln_lsps::util;
1818
use cln_lsps::LSP_FEATURE_BIT;
1919
use cln_plugin::options;
2020
use cln_rpc::model::requests::{
21-
DatastoreMode, DatastoreRequest, DeldatastoreRequest, ListdatastoreRequest, ListpeersRequest,
21+
DatastoreMode, DatastoreRequest, DeldatastoreRequest, DelinvoiceRequest, DelinvoiceStatus,
22+
ListdatastoreRequest, ListinvoicesRequest, ListpeersRequest,
2223
};
2324
use cln_rpc::model::responses::InvoiceResponse;
24-
use cln_rpc::primitives::{AmountOrAny, PublicKey, ShortChannelId};
25+
use cln_rpc::primitives::{Amount, AmountOrAny, PublicKey, ShortChannelId};
2526
use cln_rpc::ClnRpc;
2627
use log::{debug, info, warn};
28+
use rand::{CryptoRng, Rng};
2729
use serde::{Deserialize, Serialize};
2830
use std::path::Path;
2931
use std::str::FromStr as _;
@@ -263,7 +265,15 @@ async fn on_lsps_lsps2_approve(
263265
hex: None,
264266
mode: Some(DatastoreMode::CREATE_OR_REPLACE),
265267
string: Some(ds_rec_json),
266-
key: vec!["lsps".to_string(), "client".to_string(), req.lsp_id],
268+
key: vec!["lsps".to_string(), "client".to_string(), req.lsp_id.clone()],
269+
};
270+
let _ds_res = cln_client.call_typed(&ds_req).await?;
271+
let ds_req = DatastoreRequest {
272+
generation: None,
273+
hex: None,
274+
mode: Some(DatastoreMode::CREATE_OR_REPLACE),
275+
string: Some(req.lsp_id),
276+
key: vec!["lsps".to_string(), "invoice".to_string(), req.payment_hash],
267277
};
268278
let _ds_res = cln_client.call_typed(&ds_req).await?;
269279
Ok(serde_json::Value::default())
@@ -338,6 +348,30 @@ async fn on_lsps_jitchannel(
338348
AmountOrAny::Any => None,
339349
};
340350

351+
// Check that the amount is big enough to cover the fee and a single HTLC.
352+
let reduced_amount_msat = if let Some(payment_msat) = payment_size_msat {
353+
match compute_opening_fee(
354+
payment_msat.msat(),
355+
selected_params.min_fee_msat.msat(),
356+
selected_params.proportional.ppm() as u64,
357+
) {
358+
Some(fee_msat) => {
359+
if payment_msat.msat() - fee_msat < 1000 {
360+
bail!(
361+
"amount_msat {}msat is too small, needs to be at least {}msat: opening fee is {}msat",
362+
payment_msat,
363+
1000 + fee_msat,
364+
fee_msat
365+
);
366+
}
367+
Some(payment_msat.msat() - fee_msat)
368+
}
369+
None => bail!("failed to compute opening fee"),
370+
}
371+
} else {
372+
None
373+
};
374+
341375
// 3. Request channel from LSP.
342376
let buy_res: Lsps2BuyResponse = cln_client
343377
.call_raw(
@@ -372,50 +406,91 @@ async fn on_lsps_jitchannel(
372406
cltv_expiry_delta: u16::try_from(buy_res.lsp_cltv_expiry_delta)?,
373407
};
374408

375-
let inv: cln_rpc::model::responses::InvoiceResponse = cln_client
409+
// Generate a preimage if we have an amount specified.
410+
let preimage = if payment_size_msat.is_some() {
411+
Some(gen_rand_preimage_hex(&mut rand::rng()))
412+
} else {
413+
None
414+
};
415+
416+
let public_inv: cln_rpc::model::responses::InvoiceResponse = cln_client
376417
.call_raw(
377418
"invoice",
378419
&InvoiceRequest {
379420
amount_msat: req.amount_msat,
380-
dev_routes: Some(vec![vec![hint]]),
381-
description: req.description,
382-
label: req.label,
421+
dev_routes: Some(vec![vec![hint.clone()]]),
422+
description: req.description.clone(),
423+
label: req.label.clone(),
383424
expiry: Some(expiry as u64),
384-
cltv: Some(u32::try_from(6 + 2)?), // TODO: FETCH REAL VALUE!
425+
cltv: None,
385426
deschashonly: None,
386-
preimage: None,
427+
preimage: preimage.clone(),
387428
exposeprivatechannels: None,
388429
fallbacks: None,
389430
},
390431
)
391432
.await?;
392433

434+
// We need to reduce the expected amount if the invoice has an amount set
435+
if let Some(amount_msat) = reduced_amount_msat {
436+
debug!(
437+
"amount_msat is specified: create new invoice with reduced amount {}msat",
438+
amount_msat,
439+
);
440+
let _ = cln_client
441+
.call_typed(&DelinvoiceRequest {
442+
desconly: None,
443+
status: DelinvoiceStatus::UNPAID,
444+
label: req.label.clone(),
445+
})
446+
.await?;
447+
448+
let _: cln_rpc::model::responses::InvoiceResponse = cln_client
449+
.call_raw(
450+
"invoice",
451+
&InvoiceRequest {
452+
amount_msat: AmountOrAny::Amount(Amount::from_msat(amount_msat)),
453+
dev_routes: Some(vec![vec![hint]]),
454+
description: req.description,
455+
label: req.label,
456+
expiry: Some(expiry as u64),
457+
cltv: None,
458+
deschashonly: None,
459+
preimage,
460+
exposeprivatechannels: None,
461+
fallbacks: None,
462+
},
463+
)
464+
.await?;
465+
}
466+
393467
// 5. Approve jit_channel_scid for a jit channel opening.
394468
let appr_req = ClnRpcLsps2Approve {
395469
lsp_id: req.lsp_id,
396470
jit_channel_scid: buy_res.jit_channel_scid,
471+
payment_hash: public_inv.payment_hash.to_string(),
397472
client_trusts_lsp: Some(buy_res.client_trusts_lsp),
398473
};
399474
let _: serde_json::Value = cln_client.call_raw("lsps-lsps2-approve", &appr_req).await?;
400475

401476
// 6. Return invoice.
402477
let out = InvoiceResponse {
403-
bolt11: inv.bolt11,
404-
created_index: inv.created_index,
405-
warning_capacity: inv.warning_capacity,
406-
warning_deadends: inv.warning_deadends,
407-
warning_mpp: inv.warning_mpp,
408-
warning_offline: inv.warning_offline,
409-
warning_private_unused: inv.warning_private_unused,
410-
expires_at: inv.expires_at,
411-
payment_hash: inv.payment_hash,
412-
payment_secret: inv.payment_secret,
478+
bolt11: public_inv.bolt11,
479+
created_index: public_inv.created_index,
480+
warning_capacity: public_inv.warning_capacity,
481+
warning_deadends: public_inv.warning_deadends,
482+
warning_mpp: public_inv.warning_mpp,
483+
warning_offline: public_inv.warning_offline,
484+
warning_private_unused: public_inv.warning_private_unused,
485+
expires_at: public_inv.expires_at,
486+
payment_hash: public_inv.payment_hash,
487+
payment_secret: public_inv.payment_secret,
413488
};
414489
Ok(serde_json::to_value(out)?)
415490
}
416491

417492
async fn on_htlc_accepted(
418-
_p: cln_plugin::Plugin<State>,
493+
p: cln_plugin::Plugin<State>,
419494
v: serde_json::Value,
420495
) -> Result<serde_json::Value, anyhow::Error> {
421496
let req: HtlcAcceptedRequest = serde_json::from_value(v)?;
@@ -424,38 +499,87 @@ async fn on_htlc_accepted(
424499
let onion_amt = match req.onion.forward_msat {
425500
Some(a) => a,
426501
None => {
427-
debug!("onion is missing forward_msat");
502+
debug!("onion is missing forward_msat, continue");
428503
let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?;
429504
return Ok(value);
430505
}
431506
};
432507

433-
let is_lsp_payment = req
508+
let Some(payment_data) = req.onion.payload.get(TLV_PAYMENT_SECRET) else {
509+
debug!("payment is a forward, continue");
510+
let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?;
511+
return Ok(value);
512+
};
513+
514+
let extra_fee_msat = req
434515
.htlc
435516
.extra_tlvs
436517
.as_ref()
437-
.map_or(false, |tlv| tlv.contains(65537));
518+
.map(|tlvs| tlvs.get_u64(65537))
519+
.transpose()?
520+
.flatten();
521+
if let Some(amt) = extra_fee_msat {
522+
debug!("lsp htlc is deducted by an extra_fee={amt}");
523+
}
524+
525+
// Check that the htlc belongs to a jit-channel request.
526+
let dir = p.configuration().lightning_dir;
527+
let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file);
528+
let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?;
529+
let lsp_data = cln_client
530+
.call_typed(&ListdatastoreRequest {
531+
key: Some(vec![
532+
"lsps".to_string(),
533+
"invoice".to_string(),
534+
hex::encode(&req.htlc.payment_hash),
535+
]),
536+
})
537+
.await?;
438538

439-
if !is_lsp_payment || htlc_amt.msat() >= onion_amt.msat() {
440-
// Not an Lsp payment.
539+
if lsp_data.datastore.first().is_none() {
540+
// Not an LSP payment, just continue
541+
debug!("payment is a not a jit-channel-opening, continue");
441542
let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?;
442543
return Ok(value);
443-
}
444-
debug!("incoming jit-channel htlc");
544+
};
445545

446-
// Safe unwrap(): we already checked that `extra_tlvs` exists.
447-
let extra_tlvs = req.htlc.extra_tlvs.unwrap();
448-
let deducted_amt = match extra_tlvs.get_u64(65537)? {
449-
Some(amt) => amt,
546+
debug!(
547+
"incoming jit-channel htlc with htlc_amt={} and onion_amt={}",
548+
htlc_amt.msat(),
549+
onion_amt.msat()
550+
);
551+
552+
let inv_res = cln_client
553+
.call_typed(&ListinvoicesRequest {
554+
index: None,
555+
invstring: None,
556+
label: None,
557+
limit: None,
558+
offer_id: None,
559+
payment_hash: Some(hex::encode(&req.htlc.payment_hash)),
560+
start: None,
561+
})
562+
.await?;
563+
564+
let Some(invoice) = inv_res.invoices.first() else {
565+
debug!(
566+
"no invoice found for jit-channel opening with payment_hash={}",
567+
hex::encode(&req.htlc.payment_hash)
568+
);
569+
let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?;
570+
return Ok(value);
571+
};
572+
573+
let total_amt = match invoice.amount_msat {
574+
Some(a) => {
575+
debug!("invoice has total_amt={}msat", &a.msat());
576+
a.msat()
577+
}
450578
None => {
451-
warn!("htlc is missing the extra_fee amount");
452-
let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?;
453-
return Ok(value);
579+
debug!("invoice has no total amount, only accept single htlc");
580+
htlc_amt.msat()
454581
}
455582
};
456-
debug!("lsp htlc is deducted by an extra_fee={}", deducted_amt);
457-
458-
// Fixme: Check that it is not a forward (has payment_secret) before rpc_calls.
459583

460584
// Fixme: Check that we did not already pay for this channel.
461585
// - via datastore or invoice label.
@@ -465,18 +589,9 @@ async fn on_htlc_accepted(
465589

466590
let mut payload = req.onion.payload.clone();
467591
payload.set_tu64(TLV_FORWARD_AMT, htlc_amt.msat());
468-
let payment_secret = match payload.get(TLV_PAYMENT_SECRET) {
469-
Some(s) => s,
470-
None => {
471-
debug!("can't decode tlv payment_secret {:?}", payload);
472-
let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?;
473-
return Ok(value);
474-
}
475-
};
476592

477-
let total_amt = htlc_amt.msat();
478593
let mut ps = Vec::new();
479-
ps.extend_from_slice(&payment_secret[0..32]);
594+
ps.extend_from_slice(&payment_data[0..32]);
480595
ps.extend(encode_tu64(total_amt));
481596
payload.insert(TLV_PAYMENT_SECRET, ps);
482597
let payload_bytes = match payload.to_bytes() {
@@ -640,6 +755,12 @@ async fn check_peer_lsp_status(
640755
})
641756
}
642757

758+
pub fn gen_rand_preimage_hex<R: Rng + CryptoRng>(rng: &mut R) -> String {
759+
let mut pre = [0u8; 32];
760+
rng.fill_bytes(&mut pre);
761+
hex::encode(&pre)
762+
}
763+
643764
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
644765
struct LspsBuyJitChannelResponse {
645766
bolt11: String,
@@ -694,6 +815,7 @@ struct ClnRpcLsps2GetinfoRequest {
694815
struct ClnRpcLsps2Approve {
695816
lsp_id: String,
696817
jit_channel_scid: ShortChannelId,
818+
payment_hash: String,
697819
#[serde(default)]
698820
client_trusts_lsp: Option<bool>,
699821
}

plugins/lsps-plugin/src/lsps2/handler.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -402,12 +402,15 @@ impl<A: ClnApi> HtlcAcceptedHookHandler<A> {
402402
// ---
403403

404404
// Fixme: We only accept no-mpp for now, mpp and other flows will be added later on
405+
// Fixme: We continue mpp for now to let the test mock handle the htlc, as we need
406+
// to test the client implementation for mpp payments.
405407
if ds_rec.expected_payment_size.is_some() {
406408
warn!("mpp payments are not implemented yet");
407-
return Ok(HtlcAcceptedResponse::fail(
408-
Some(UNKNOWN_NEXT_PEER.to_string()),
409-
None,
410-
));
409+
return Ok(HtlcAcceptedResponse::continue_(None, None, None));
410+
// return Ok(HtlcAcceptedResponse::fail(
411+
// Some(UNKNOWN_NEXT_PEER.to_string()),
412+
// None,
413+
// ));
411414
}
412415

413416
// B) Is the fee option menu still valid?
@@ -1558,6 +1561,8 @@ mod tests {
15581561
}
15591562

15601563
#[tokio::test]
1564+
#[ignore] // We deactivate the mpp check on the experimental server for
1565+
// client side checks.
15611566
async fn test_htlc_mpp_not_implemented() {
15621567
let fake = FakeCln::default();
15631568
let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);

0 commit comments

Comments
 (0)