lsp_plugin: add basic lsps2 mpp support to client
What changed, and why it matters
This commit adds experimental support for multi-part payments (MPP) in a new Lightning Service Provider (LSP) feature called LSPS2. It is a feature addition with test code, not a security fix. There is no indication in the commit that it patches a vulnerability or that the project treats it as a security-relevant change.
No immediate security action required. Treat as normal feature review. Monitor the experimental LSPS2 implementation for completion of MPP server-side handling and ensure fee/replay protections are added before production use.
Security signals we found
No security-relevant keywords in commit title or message
Feature addition with changelog entry, not a bug or vulnerability fix
FIXME comments indicate incomplete implementation, but no unsafe pattern is introduced
Random preimage generation uses a cryptographically secure RNG (CryptoRng)
Datastore key now includes payment_hash for invoice lookup
Evidence from the diff
The commit extends the lsps-plugin’s LSPS2 client to accept MPP payments for just-in-time (JIT) channel openings. Key changes include: storing a payment_hash-to-lsp_id mapping in the datastore, generating a random preimage for fixed-amount invoices, reducing the invoice amount by the computed opening fee, and updating the htlc_accepted hook to look up invoices and reconstruct the payment secret/forward amount for MPP parts. A mocked LSP service plugin and an integration test are added. The server-side MPP check is temporarily disabled (commented out with a FIXME) to allow client-side testing.
Changed components
plugins/lsps-plugin/src/client.rsplugins/lsps-plugin/src/lsps2/handler.rstests/plugins/lsps2_service_mock.pytests/test_cln_lsps.pyInspect captured patch +483 / −51
diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs
index f23f8ec7..d7531f8b 100644
--- a/plugins/lsps-plugin/src/client.rs
+++ b/plugins/lsps-plugin/src/client.rs
@@ -18,12 +18,14 @@ use cln_lsps::util;
use cln_lsps::LSP_FEATURE_BIT;
use cln_plugin::options;
use cln_rpc::model::requests::{
- DatastoreMode, DatastoreRequest, DeldatastoreRequest, ListdatastoreRequest, ListpeersRequest,
+ DatastoreMode, DatastoreRequest, DeldatastoreRequest, DelinvoiceRequest, DelinvoiceStatus,
+ ListdatastoreRequest, ListinvoicesRequest, ListpeersRequest,
};
use cln_rpc::model::responses::InvoiceResponse;
-use cln_rpc::primitives::{AmountOrAny, PublicKey, ShortChannelId};
+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 _;
@@ -263,7 +265,15 @@ async fn on_lsps_lsps2_approve(
hex: None,
mode: Some(DatastoreMode::CREATE_OR_REPLACE),
string: Some(ds_rec_json),
- key: vec!["lsps".to_string(), "client".to_string(), req.lsp_id],
+ 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())
@@ -338,6 +348,30 @@ async fn on_lsps_jitchannel(
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(
@@ -372,50 +406,91 @@ async fn on_lsps_jitchannel(
cltv_expiry_delta: u16::try_from(buy_res.lsp_cltv_expiry_delta)?,
};
- let inv: cln_rpc::model::responses::InvoiceResponse = cln_client
+ // 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]]),
- description: req.description,
- label: req.label,
+ dev_routes: Some(vec![vec![hint.clone()]]),
+ description: req.description.clone(),
+ label: req.label.clone(),
expiry: Some(expiry as u64),
- cltv: Some(u32::try_from(6 + 2)?), // TODO: FETCH REAL VALUE!
+ cltv: None,
deschashonly: None,
- preimage: 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: inv.bolt11,
- created_index: inv.created_index,
- warning_capacity: inv.warning_capacity,
- warning_deadends: inv.warning_deadends,
- warning_mpp: inv.warning_mpp,
- warning_offline: inv.warning_offline,
- warning_private_unused: inv.warning_private_unused,
- expires_at: inv.expires_at,
- payment_hash: inv.payment_hash,
- payment_secret: inv.payment_secret,
+ 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_htlc_accepted(
- _p: cln_plugin::Plugin<State>,
+ p: cln_plugin::Plugin<State>,
v: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
let req: HtlcAcceptedRequest = serde_json::from_value(v)?;
@@ -424,38 +499,87 @@ async fn on_htlc_accepted(
let onion_amt = match req.onion.forward_msat {
Some(a) => a,
None => {
- debug!("onion is missing forward_msat");
+ debug!("onion is missing forward_msat, continue");
let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?;
return Ok(value);
}
};
- let is_lsp_payment = req
+ let Some(payment_data) = req.onion.payload.get(TLV_PAYMENT_SECRET) else {
+ debug!("payment is a forward, continue");
+ let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?;
+ return Ok(value);
+ };
+
+ let extra_fee_msat = req
.htlc
.extra_tlvs
.as_ref()
- .map_or(false, |tlv| tlv.contains(65537));
+ .map(|tlvs| tlvs.get_u64(65537))
+ .transpose()?
+ .flatten();
+ if let Some(amt) = extra_fee_msat {
+ debug!("lsp htlc is deducted by an extra_fee={amt}");
+ }
+
+ // 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 = cln_rpc::ClnRpc::new(rpc_path.clone()).await?;
+ let lsp_data = cln_client
+ .call_typed(&ListdatastoreRequest {
+ key: Some(vec![
+ "lsps".to_string(),
+ "invoice".to_string(),
+ hex::encode(&req.htlc.payment_hash),
+ ]),
+ })
+ .await?;
- if !is_lsp_payment || htlc_amt.msat() >= onion_amt.msat() {
- // Not an Lsp payment.
+ if lsp_data.datastore.first().is_none() {
+ // Not an LSP payment, just continue
+ debug!("payment is a not a jit-channel-opening, continue");
let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?;
return Ok(value);
- }
- debug!("incoming jit-channel htlc");
+ };
- // Safe unwrap(): we already checked that `extra_tlvs` exists.
- let extra_tlvs = req.htlc.extra_tlvs.unwrap();
- let deducted_amt = match extra_tlvs.get_u64(65537)? {
- Some(amt) => amt,
+ debug!(
+ "incoming jit-channel htlc with htlc_amt={} and onion_amt={}",
+ htlc_amt.msat(),
+ onion_amt.msat()
+ );
+
+ let inv_res = 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?;
+
+ let Some(invoice) = inv_res.invoices.first() else {
+ debug!(
+ "no invoice found for jit-channel opening with payment_hash={}",
+ hex::encode(&req.htlc.payment_hash)
+ );
+ let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?;
+ return Ok(value);
+ };
+
+ let total_amt = match invoice.amount_msat {
+ Some(a) => {
+ debug!("invoice has total_amt={}msat", &a.msat());
+ a.msat()
+ }
None => {
- warn!("htlc is missing the extra_fee amount");
- let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?;
- return Ok(value);
+ debug!("invoice has no total amount, only accept single htlc");
+ htlc_amt.msat()
}
};
- debug!("lsp htlc is deducted by an extra_fee={}", deducted_amt);
-
- // Fixme: Check that it is not a forward (has payment_secret) before rpc_calls.
// Fixme: Check that we did not already pay for this channel.
// - via datastore or invoice label.
@@ -465,18 +589,9 @@ async fn on_htlc_accepted(
let mut payload = req.onion.payload.clone();
payload.set_tu64(TLV_FORWARD_AMT, htlc_amt.msat());
- let payment_secret = match payload.get(TLV_PAYMENT_SECRET) {
- Some(s) => s,
- None => {
- debug!("can't decode tlv payment_secret {:?}", payload);
- let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?;
- return Ok(value);
- }
- };
- let total_amt = htlc_amt.msat();
let mut ps = Vec::new();
- ps.extend_from_slice(&payment_secret[0..32]);
+ ps.extend_from_slice(&payment_data[0..32]);
ps.extend(encode_tu64(total_amt));
payload.insert(TLV_PAYMENT_SECRET, ps);
let payload_bytes = match payload.to_bytes() {
@@ -640,6 +755,12 @@ async fn check_peer_lsp_status(
})
}
+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)
+}
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct LspsBuyJitChannelResponse {
bolt11: String,
@@ -694,6 +815,7 @@ struct ClnRpcLsps2GetinfoRequest {
struct ClnRpcLsps2Approve {
lsp_id: String,
jit_channel_scid: ShortChannelId,
+ payment_hash: String,
#[serde(default)]
client_trusts_lsp: Option<bool>,
}
diff --git a/plugins/lsps-plugin/src/lsps2/handler.rs b/plugins/lsps-plugin/src/lsps2/handler.rs
index 7c470ae2..4e86943f 100644
--- a/plugins/lsps-plugin/src/lsps2/handler.rs
+++ b/plugins/lsps-plugin/src/lsps2/handler.rs
@@ -402,12 +402,15 @@ impl<A: ClnApi> HtlcAcceptedHookHandler<A> {
// ---
// 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::fail(
- Some(UNKNOWN_NEXT_PEER.to_string()),
- None,
- ));
+ 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?
@@ -1558,6 +1561,8 @@ mod tests {
}
#[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);
diff --git a/tests/plugins/lsps2_service_mock.py b/tests/plugins/lsps2_service_mock.py
new file mode 100755
index 00000000..fecd1a58
--- /dev/null
+++ b/tests/plugins/lsps2_service_mock.py
@@ -0,0 +1,205 @@
+#!/usr/bin/env python3
+"""
+Zero‑conf LSPS2 mock
+====================
+
+• On the **first incoming HTLC**, call `connect` and `fundchannel` with **zeroconf** to a configured peer.
+• **Hold all HTLCs** until the channel reports `CHANNELD_NORMAL`, then **continue** them all.
+• After the channel is ready, future HTLCs are continued immediately.
+"""
+
+import threading
+import time
+import struct
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta, timezone
+from typing import Dict, Optional
+from pyln.client import Plugin
+from pyln.proto.onion import TlvPayload
+
+
+plugin = Plugin()
+
+
+@plugin.method("lsps2-policy-getpolicy")
+def lsps2_policy_getpolicy(request):
+ """Returns an opening fee menu for the LSPS2 plugin."""
+ now = datetime.now(timezone.utc)
+
+ # Is ISO 8601 format "YYYY-MM-DDThh:mm:ss.uuuZ"
+ valid_until = (now + timedelta(hours=1)).isoformat().replace("+00:00", "Z")
+
+ return {
+ "policy_opening_fee_params_menu": [
+ {
+ "min_fee_msat": "1000000",
+ "proportional": 0,
+ "valid_until": valid_until,
+ "min_lifetime": 2000,
+ "max_client_to_self_delay": 2016,
+ "min_payment_size_msat": "1000",
+ "max_payment_size_msat": "100000000",
+ },
+ ]
+ }
+
+
+@plugin.method("lsps2-policy-getchannelcapacity")
+def lsps2_policy_getchannelcapacity(
+ request, init_payment_size, scid, opening_fee_params
+):
+ """Returns an opening fee menu for the LSPS2 plugin."""
+ return {"channel_capacity_msat": 100000000}
+
+
+TLV_OPENING_FEE = 65537
+
+
+@dataclass
+class Held:
+ htlc: dict
+ onion: dict
+ event: threading.Event = field(default_factory=threading.Event)
+ response: Optional[dict] = None
+
+
+@dataclass
+class State:
+ target_peer: Optional[str] = None
+ channel_cap: Optional[int] = None
+ opening_fee_msat: Optional[int] = None
+ pending: Dict[str, Held] = field(default_factory=dict)
+ funding_started: bool = False
+ channel_ready: bool = False
+ channel_id_hex: Optional[str] = None
+ fee_remaining_msat: int = 0
+ worker_thread: Optional[threading.Thread] = None
+ lock: threading.Lock = field(default_factory=threading.Lock)
+
+
+state = State()
+
+
+def _key(h: dict) -> str:
+ return f"{h.get('id', '?')}:{h.get('payment_hash', '?')}"
+
+
+def _ensure_zero_conf_channel(peer_id: str, capacity: int) -> bool:
+ plugin.log(f"fundchannel zero-conf to {peer_id} for {capacity} sat...")
+ res = plugin.rpc.fundchannel(
+ peer_id,
+ capacity,
+ announce=False,
+ mindepth=0,
+ channel_type=[12, 46, 50],
+ )
+ plugin.log(f"got channel response {res}")
+ state.channel_id_hex = res["channel_id"]
+
+ for _ in range(120):
+ channels = plugin.rpc.listpeerchannels(peer_id)["channels"]
+ for c in channels:
+ if c.get("state") == "CHANNELD_NORMAL":
+ plugin.log("zero-conf channel is NORMAL; releaseing HTLCs")
+ return True
+ time.sleep(1)
+ return False
+
+
+def _modify_payload_and_build_response(held: Held):
+ amt_msat = int(held.htlc.get("amount_msat", 0))
+ fee_applied = 0
+ if state.fee_remaining_msat > 0:
+ fee_applied = min(state.fee_remaining_msat, max(amt_msat - 1, 0))
+ state.fee_remaining_msat -= fee_applied
+ forward_msat = max(1, amt_msat - fee_applied)
+
+ payload = None
+ extra = None
+ if amt_msat != forward_msat:
+ amt_byte = struct.pack("!Q", forward_msat)
+ while len(amt_byte) > 1 and amt_byte[0] == 0:
+ amt_byte = amt_byte[1:]
+ payload = TlvPayload().from_hex(held.onion["payload"])
+ p = TlvPayload()
+ p.add_field(2, amt_byte)
+ p.add_field(4, payload.get(4).value)
+ p.add_field(6, payload.get(6).value)
+ payload = p.to_bytes(include_prefix=False)
+
+ amt_byte = fee_applied.to_bytes(8, "big")
+ e = TlvPayload()
+ e.add_field(TLV_OPENING_FEE, amt_byte)
+ extra = e.to_bytes(include_prefix=False)
+
+ resp = {"result": "continue"}
+ if payload:
+ resp["payload"] = payload.hex()
+ if extra:
+ resp["extra_tlvs"] = extra.hex()
+ if state.channel_id_hex:
+ resp["forward_to"] = state.channel_id_hex
+ return resp
+
+
+def _release_all_locked():
+ # called with state.lock held
+ items = list(state.pending.items())
+ state.pending.clear()
+ for _k, held in items:
+ if held.response is None:
+ held.response = _modify_payload_and_build_response(held)
+ held.event.set()
+
+
+def _worker():
+ plugin.log("collecting htlcs and fund channel...")
+ with state.lock:
+ peer = state.target_peer
+ cap = state.channel_cap
+ fee = state.opening_fee_msat
+ if not peer or not cap or not fee:
+ with state.lock:
+ _release_all_locked()
+ return
+
+ ok = _ensure_zero_conf_channel(peer, cap)
+ with state.lock:
+ state.channel_ready = ok
+ state.fee_remaining_msat = fee if ok else 0
+ _release_all_locked()
+
+
+@plugin.method("setuplsps2service")
+def setuplsps2service(plugin, peer_id, channel_cap, opening_fee_msat):
+ state.target_peer = peer_id
+ state.channel_cap = channel_cap
+ state.opening_fee_msat = opening_fee_msat
+
+
+@plugin.async_hook("htlc_accepted")
+def on_htlc_accepted(htlc, onion, request, plugin, **kwargs):
+ key = _key(htlc)
+
+ with state.lock:
+ if state.channel_ready:
+ held_now = Held(htlc=htlc, onion=onion)
+ resp = _modify_payload_and_build_response(held_now)
+ request.set_result(resp)
+ return
+
+ if not state.funding_started:
+ state.funding_started = True
+ state.worker_thread = threading.Thread(target=_worker, daemon=True)
+ state.worker_thread.start()
+
+ # enqueue and block until the worker releases us
+ held = Held(htlc=htlc, onion=onion)
+ state.pending[key] = held
+
+ held.event.wait()
+ request.set_result(held.response)
+
+
+if __name__ == "__main__":
+ plugin.run()
diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py
index 5f43588c..6d464488 100644
--- a/tests/test_cln_lsps.py
+++ b/tests/test_cln_lsps.py
@@ -192,6 +192,106 @@ def test_lsps2_buyjitchannel_no_mpp_var_invoice(node_factory, bitcoind):
assert len(chs) == 1
+
+def test_lsps2_buyjitchannel_mpp_fixed_invoice(node_factory, bitcoind):
+ """Tests the creation of a "Just-In-Time-Channel" (jit-channel).
+
+ At the beginning we have the following situation where l2 acts as the LSP
+ (LSP)
+ l1 l2----l3
+
+ l1 now wants to get a channel from l2 via the lsps2 jit-channel protocol:
+ - l1 requests a new jit channel form l2
+ - l1 creates an invoice based on the opening fee parameters it got from l2
+ - l3 pays the invoice
+ - l2 opens a channel to l1 and forwards the payment (deducted by a fee)
+
+ eventualy this will result in the following situation
+ (LSP)
+ l1----l2----l3
+ """
+ # A mock for lsps2 mpp payments, contains the policy plugin as well.
+ plugin = os.path.join(os.path.dirname(__file__), "plugins/lsps2_service_mock.py")
+
+ l1, l2, l3 = node_factory.get_nodes(
+ 3,
+ opts=[
+ {"experimental-lsps-client": None},
+ {
+ "experimental-lsps2-service": None,
+ "experimental-lsps2-promise-secret": "0" * 64,
+ "plugin": plugin,
+ "fee-base": 0, # We are going to deduct our fee anyways,
+ "fee-per-satoshi": 0, # We are going to deduct our fee anyways,
+ },
+ {},
+ ],
+ )
+
+ # Give the LSP some funds to open jit-channels
+ addr = l2.rpc.newaddr()["bech32"]
+ bitcoind.rpc.sendtoaddress(addr, 1)
+ bitcoind.generate_block(1)
+
+ node_factory.join_nodes([l3, l2], fundchannel=True, wait_for_announce=True)
+ node_factory.join_nodes([l1, l2], fundchannel=False)
+
+ chanid = only_one(l3.rpc.listpeerchannels(l2.info["id"])["channels"])[
+ "short_channel_id"
+ ]
+
+ amt = 10_000_000
+ inv = l1.rpc.lsps_jitchannel(
+ lsp_id=l2.info["id"],
+ amount_msat=f"{amt}msat",
+ description="lsp-jit-channel-0",
+ label="lsp-jit-channel-0",
+ )
+ dec = l3.rpc.decode(inv["bolt11"])
+
+ l2.rpc.setuplsps2service(
+ peer_id=l1.info["id"], channel_cap=100_000, opening_fee_msat=1000_000
+ )
+
+ routehint = only_one(only_one(dec["routes"]))
+
+ parts = 10
+ route_part = [
+ {
+ "amount_msat": amt // parts,
+ "id": l2.info["id"],
+ "delay": routehint["cltv_expiry_delta"] + 6,
+ "channel": chanid,
+ },
+ {
+ "amount_msat": amt // parts,
+ "id": l1.info["id"],
+ "delay": 6,
+ "channel": routehint["short_channel_id"],
+ },
+ ]
+
+ # MPP-payment of fixed amount
+ for partid in range(1, parts + 1):
+ r = l3.rpc.sendpay(
+ route_part,
+ dec["payment_hash"],
+ payment_secret=inv["payment_secret"],
+ bolt11=inv["bolt11"],
+ amount_msat=f"{amt}msat",
+ groupid=1,
+ partid=partid,
+ )
+ assert r
+
+ res = l3.rpc.waitsendpay(dec["payment_hash"], partid=parts, groupid=1)
+ assert res["payment_preimage"]
+
+ # l1 should have gotten a jit-channel.
+ chs = l1.rpc.listpeerchannels()["channels"]
+ assert len(chs) == 1
+
+
def test_lsps2_non_approved_zero_conf(node_factory, bitcoind):
"""Checks that we don't allow zerof_conf channels from an LSP if we did
not approve it first.
Why this scored 11/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.