lsp_plugin: add cleanup on "on_invoice_payment"
What changed, and why it matters
This commit adds a cleanup step to the experimental LSPS (Lightning Service Provider Specification) plugin. When a JIT-channel invoice is fully paid, the plugin now deletes its stored record from Core Lightning's datastore. The change is defensive housekeeping rather than a fix for an active vulnerability, and the commit message does not describe any security issue.
No urgent action. If reviewing, consider whether silently ignoring `deldatastore` failures is acceptable, and confirm the datastore key cannot collide with or delete entries belonging to other invoices or plugins.
Security signals we found
New hook handler deletes datastore entries after invoice completion
Deletion errors are silently swallowed with `.ok()`, which could mask RPC failures
No input validation beyond hex decoding of the preimage
No commit message or vendor reference describes this as a security fix
Evidence from the diff
The LSPS plugin registers a new invoice_payment hook (on_invoice_payment). On successful invoice payment it parses the preimage, computes the payment hash (SHA256), and calls deldatastore for the key ['lsps', 'invoice', <hash>]. Errors from the deletion are silently ignored (.ok()). Tests are updated to assert the datastore is empty after payment. The change prevents stale JIT-channel records from accumulating in the datastore.
Changed components
plugins/lsps-plugin/src/client.rsplugins/lsps-plugin/src/lsps2/cln.rstests/test_cln_lsps.pyInspect captured patch +48 / −2
diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs
index d7531f8b..90e7b6b8 100644
--- a/plugins/lsps-plugin/src/client.rs
+++ b/plugins/lsps-plugin/src/client.rs
@@ -1,4 +1,5 @@
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;
@@ -8,7 +9,8 @@ use cln_lsps::lsps0::{
};
use cln_lsps::lsps2::cln::tlv::encode_tu64;
use cln_lsps::lsps2::cln::{
- HtlcAcceptedRequest, HtlcAcceptedResponse, TLV_FORWARD_AMT, TLV_PAYMENT_SECRET,
+ HtlcAcceptedRequest, HtlcAcceptedResponse, InvoicePaymentRequest, TLV_FORWARD_AMT,
+ TLV_PAYMENT_SECRET,
};
use cln_lsps::lsps2::model::{
compute_opening_fee, Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest,
@@ -80,6 +82,7 @@ async fn main() -> Result<(), anyhow::Error> {
"Requests a new jit channel from LSP and returns the matching invoice",
on_lsps_jitchannel,
)
+ .hook("invoice_payment", on_invoice_payment)
.hook("htlc_accepted", on_htlc_accepted)
.hook("openchannel", on_openchannel)
.configure()
@@ -251,7 +254,7 @@ async fn on_lsps_lsps2_approve(
) -> 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,
+ 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)?;
@@ -489,6 +492,29 @@ async fn on_lsps_jitchannel(
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 = serde_json::from_value(v).context("invalid hook request")?;
+ let preimage = <[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 = cln_rpc::ClnRpc::new(rpc_path.clone()).await?;
+ cln_client
+ .call_typed(&DeldatastoreRequest {
+ key: vec!["lsps".to_string(), "invoice".to_string(), hash.to_string()],
+ generation: None,
+ })
+ .await
+ .ok();
+
+ Ok(serde_json::json!({"result": "continue"}))
+}
+
async fn on_htlc_accepted(
p: cln_plugin::Plugin<State>,
v: serde_json::Value,
@@ -761,6 +787,10 @@ pub fn gen_rand_preimage_hex<R: Rng + CryptoRng>(rng: &mut R) -> String {
hex::encode(&pre)
}
+pub fn payment_hash(preimage: &[u8]) -> sha256::Hash {
+ sha256::Hash::hash(preimage)
+}
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct LspsBuyJitChannelResponse {
bolt11: String,
diff --git a/plugins/lsps-plugin/src/lsps2/cln.rs b/plugins/lsps-plugin/src/lsps2/cln.rs
index fcda49f7..949409db 100644
--- a/plugins/lsps-plugin/src/lsps2/cln.rs
+++ b/plugins/lsps-plugin/src/lsps2/cln.rs
@@ -117,6 +117,17 @@ impl HtlcAcceptedResponse {
}
}
+#[derive(Debug, Deserialize)]
+pub struct InvoicePaymentRequest {
+ pub payment: InvoicePaymentRequestPayment,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct InvoicePaymentRequestPayment {
+ pub label: String,
+ pub preimage: String,
+ pub msat: u64,
+}
/// Deserializes a lowercase hex string to a `Vec<u8>`.
pub fn from_hex<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py
index 6d464488..256a37a9 100644
--- a/tests/test_cln_lsps.py
+++ b/tests/test_cln_lsps.py
@@ -191,6 +191,8 @@ def test_lsps2_buyjitchannel_no_mpp_var_invoice(node_factory, bitcoind):
chs = l1.rpc.listpeerchannels()["channels"]
assert len(chs) == 1
+ # Check that the client cleaned up after themselves.
+ assert l1.rpc.listdatastore(["lsps"]) == {"datastore": []}
def test_lsps2_buyjitchannel_mpp_fixed_invoice(node_factory, bitcoind):
@@ -291,6 +293,9 @@ def test_lsps2_buyjitchannel_mpp_fixed_invoice(node_factory, bitcoind):
chs = l1.rpc.listpeerchannels()["channels"]
assert len(chs) == 1
+ # Check that the client cleaned up after themselves.
+ assert l1.rpc.listdatastore("lsps") == {"datastore": []}
+
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
Why this scored 18/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.