lsp_plugin: add client side check for zero_conf

We only allow zero_conf channels if we approved the a jit-channel from
the LSP in advance.

Signed-off-by: Peter Neuroth <pet.v.ne@gmail.com>
This commit is contained in:
Peter Neuroth
2025-09-25 17:49:56 +02:00
committed by Rusty Russell
parent ffc0e42f7d
commit e789b969ec
3 changed files with 105 additions and 21 deletions

View File

@@ -17,7 +17,9 @@ use cln_lsps::lsps2::model::{
use cln_lsps::util;
use cln_lsps::LSP_FEATURE_BIT;
use cln_plugin::options;
use cln_rpc::model::requests::{DatastoreMode, DatastoreRequest, ListpeersRequest};
use cln_rpc::model::requests::{
DatastoreMode, DatastoreRequest, DeldatastoreRequest, ListdatastoreRequest, ListpeersRequest,
};
use cln_rpc::model::responses::InvoiceResponse;
use cln_rpc::primitives::{AmountOrAny, PublicKey, ShortChannelId};
use cln_rpc::ClnRpc;
@@ -238,7 +240,6 @@ async fn on_lsps_lsps2_approve(
) -> Result<serde_json::Value, anyhow::Error> {
let req: ClnRpcLsps2Approve = serde_json::from_value(v)?;
let ds_rec = DatastoreRecord {
lsp_id: req.lsp_id,
jit_channel_scid: req.jit_channel_scid,
client_trusts_lsp: req.client_trusts_lsp.unwrap_or_default(),
};
@@ -253,11 +254,7 @@ 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.jit_channel_scid.to_string(),
],
key: vec!["lsps".to_string(), "client".to_string(), req.lsp_id],
};
let _ds_res = cln_client.call_typed(&ds_req).await?;
Ok(serde_json::Value::default())
@@ -494,19 +491,52 @@ async fn on_htlc_accepted(
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,
p: cln_plugin::Plugin<State>,
v: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
// Fixme: Register a list of trusted LSPs and check if LSP is allowlisted.
// And if we expect a channel to be opened.
// - either datastore or invoice label possible.
info!("Allowing zero-conf channel from LSP");
Ok(serde_json::json!({
"result": "continue",
"reserve": "0msat",
"mindepth": 0,
}))
#[derive(Deserialize)]
struct Request {
id: String,
}
let req: Request = serde_json::from_value(v.get("openchannel").unwrap().clone())
.context("Failed to parse request JSON")?;
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 = ListdatastoreRequest {
key: Some(vec![
"lsps".to_string(),
"client".to_string(),
req.id.clone(),
]),
};
let ds_res = cln_client.call_typed(&ds_req).await?;
if let Some(_rec) = ds_res.datastore.iter().next() {
info!("Allowing zero-conf channel from LSP {}", &req.id);
let ds_req = DeldatastoreRequest {
generation: None,
key: vec!["lsps".to_string(), "client".to_string(), req.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",
"reserve": "0msat",
"mindepth": 0,
}));
} else {
// Not a requested JIT-channel opening, continue.
Ok(serde_json::json!({"result": "continue"}))
}
}
async fn on_lsps_listprotocols(
@@ -659,7 +689,6 @@ struct ClnRpcLsps2Approve {
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DatastoreRecord {
lsp_id: String,
jit_channel_scid: ShortChannelId,
client_trusts_lsp: bool,
}

View File

@@ -507,8 +507,7 @@ impl<A: ClnApi> HtlcAcceptedHookHandler<A> {
push_msat: None,
request_amt: None,
reserve: None,
channel_type: None, // Fimxe: Core-Lightning is complaining that it doesn't support these channel_types
// channel_type: Some(vec![46, 50]), // Sets `option_zeroconf` and `option_scid_alias`
channel_type: Some(vec![12, 22, 50]),
utxos: None,
amount: AmountOrAll::Amount(Amount::from_msat(cap)),
id: ds_rec.peer_id,

View File

@@ -161,3 +161,59 @@ def test_lsps2_buyjitchannel_no_mpp_var_invoice(node_factory, bitcoind):
# 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.
"""
# We need a policy service to fetch from.
plugin = os.path.join(os.path.dirname(__file__), 'plugins/lsps2_policy.py')
l1, l2, l3= node_factory.get_nodes(3, opts=[
{"dev-lsps-client-enabled": None},
{
"dev-lsps-service-enabled": None,
"dev-lsps2-service-enabled": None,
"dev-lsps2-promise-secret": "00" * 32,
"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,
},
{"disable-mpp": None},
])
# 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']
fee_opt = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id'])['opening_fee_params_menu'][0]
buy_res = l1.rpc.lsps_lsps2_buy(lsp_id=l2.info['id'], opening_fee_params=fee_opt)
hint = [[{
"id": l2.info['id'],
"short_channel_id": buy_res['jit_channel_scid'],
"fee_base_msat": 0,
"fee_proportional_millionths": 0,
"cltv_expiry_delta": buy_res['lsp_cltv_expiry_delta'],
}]]
bolt11 = l1.dev_invoice(
amount_msat="any",
description="lsp-invoice-1",
label="lsp-invoice-1",
dev_routes=hint,
)['bolt11']
with pytest.raises(ValueError):
l3.rpc.pay(bolt11, amount_msat=10000000)
# l1 shouldn't have a new channel.
chs = l1.rpc.listpeerchannels()['channels']
assert len(chs) == 0