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>
This commit is contained in:
Peter Neuroth
2025-11-05 13:09:21 +01:00
committed by Rusty Russell
parent 3b05a81317
commit be015898ae
4 changed files with 483 additions and 51 deletions

View File

@@ -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}");
}
if !is_lsp_payment || htlc_amt.msat() >= onion_amt.msat() {
// Not an Lsp payment.
// 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 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>,
}

View File

@@ -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);

View File

@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""
Zeroconf 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()

View File

@@ -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.