From bf4bb038f6b1b5236ceea7ae481e7f9f4c0407f6 Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Wed, 28 Oct 2026 00:42:39 +0100 Subject: [PATCH 1/6] lsps-plugin: don't let the reduced JIT invoice outlive valid_until For a fixed amount the client creates the invoice twice: the public one and, after deleting it, a copy for the amount minus the opening fee, which the reduced HTLCs pay. Both used an expiry computed before the first call, so the copy expired later than the public invoice by however long creating and deleting that took, which can be past the params' valid_until. Compute the copy's expiry from valid_until when creating it. Changelog-Fixed: lsps-plugin: for a fixed amount, the invoice `lsps-jitchannel` stores for the reduced payment no longer stays valid past the opening fee params' `valid_until`. --- plugins/lsps-plugin/src/client.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs index d631dd0298ac..24a4ba8c1fa1 100644 --- a/plugins/lsps-plugin/src/client.rs +++ b/plugins/lsps-plugin/src/client.rs @@ -418,7 +418,8 @@ async fn on_lsps_lsps2_invoice( // We define the invoice expiry here to avoid cloning `selected_params` // as they are about to be moved to the `Lsps2BuyRequest`. - let expiry = (selected_params.valid_until.as_chrono() - Utc::now()).num_seconds(); + let valid_until = selected_params.valid_until.as_chrono(); + let expiry = (valid_until - Utc::now()).num_seconds(); if expiry <= 10 { return Err(anyhow!( "Invoice lifetime is too short, options are valid until: {}", @@ -483,7 +484,9 @@ async fn on_lsps_lsps2_invoice( dev_routes: Some(vec![vec![hint]]), description: req.description, label: req.label, - expiry: Some(expiry as u64), + // Not `expiry`: creating the public invoice took time, + // and this one must not outlive `valid_until` either. + expiry: Some((valid_until - Utc::now()).num_seconds().max(0) as u64), cltv: None, deschashonly: None, preimage, From f22b6ec9e3af1b70f58363b794f937ad5db201ed Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Tue, 20 Oct 2026 15:09:45 +0200 Subject: [PATCH 2/6] tests: add amount and identity oracles for the LSPS2 client tests Every LSPS2 client test should end by checking the same invariants on what the client did, whatever the mock service misbehaved in. Add tests/lsps2_oracles.py with the first two: - Amount: a paid invoice received at least its amount minus the opening fee it owes (none for payments after the session), and the client holds no more in channels its peers opened than its paid invoices received. - Identity: invoices route only through known channels or through the LSP on a jit_channel_scid it issued to this client, and the only channel used before its funding confirms is the session's. The checks work on an Observation snapshot of plain data, gathered from the client and the mock's state, so they are unit tested on hand- written runs. For that the mock now timestamps its events, reports the params it issued, remembers which payment opened each session and records the incoming HTLC of each part it forwards, so that only the parts the client settled count. The honest JIT tests now run the oracles. --- tests/lsps2_oracles.py | 268 ++++++++++++++++++++++++++++++++++++ tests/plugins/lsps2_mock.py | 12 +- tests/test_cln_lsps.py | 17 +-- tests/test_lsps2_oracles.py | 186 +++++++++++++++++++++++++ 4 files changed, 468 insertions(+), 15 deletions(-) create mode 100644 tests/lsps2_oracles.py create mode 100644 tests/test_lsps2_oracles.py diff --git a/tests/lsps2_oracles.py b/tests/lsps2_oracles.py new file mode 100644 index 000000000000..d32b5eb1af99 --- /dev/null +++ b/tests/lsps2_oracles.py @@ -0,0 +1,268 @@ +"""Oracles for the LSPS2 client tests. + +Whatever the mock LSPS2 service (plugins/lsps2_mock.py) does, a test ends +by checking these invariants on what the client did: + +- Amount: no preimage for less than the invoice amount minus the opening + fee computed from the chosen params; the fee is deducted only once per + session. +- Identity: channels and HTLCs are accepted only from the session's LSP + and only on the promised SCID. +- Params and deadlines: expired, contradictory or modified params never + lead to a payment. +- Robustness: no LSP input crashes the client or leaves it hanging or + retrying without bound. + +The mock records what it received, issued, sent and forwarded; the client +is observed through its invoices, channels and log. Each check has an ID +such as "amount.received", and `check_oracles` fails with every violation +it finds. +""" + +import importlib.util +import os +from datetime import datetime, timezone + + +def _load_plugin_module(name): + # tests/plugins is not a package. + path = os.path.join(os.path.dirname(__file__), "plugins", name + ".py") + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +lsps2_mock_fee = _load_plugin_module("lsps2_mock_fee") + + +class Observation: + """What the client and the mock service on the LSP did, as plain data, + so that checks can also be fed by hand.""" + + def __init__(self, client_id, lsp_id, mock_state, invoices, channels): + self.client_id = client_id + self.lsp_id = lsp_id + self.issued = mock_state["issued"] + self.sessions = [ + s for s in mock_state["sessions"] if s["peer_id"] == client_id + ] + self.events = mock_state["events"] + # listinvoices entries, with the decoded bolt11 under "decoded". + self.invoices = invoices + # listpeerchannels entries, with the funding tx's confirmations + # under "confirmations". + self.channels = channels + + @classmethod + def from_nodes(cls, client, lsp): + mock_state = lsp.rpc.call("lsps2-mock-state") + # How each forward ended, so that parts the client failed don't + # count towards the payment. + statuses = { + (f["in_channel"], f.get("in_htlc_id")): f["status"] + for f in lsp.rpc.listforwards()["forwards"] + } + for e in mock_state["events"]: + if e["type"] == "htlc" and e["decision"] == "forward": + e["status"] = statuses.get((e["in_channel"], e["in_htlc_id"])) + invoices = [] + for inv in client.rpc.listinvoices()["invoices"]: + if "bolt11" in inv: + invoices.append({**inv, "decoded": client.rpc.decode(inv["bolt11"])}) + channels = [ + {**c, "confirmations": _confirmations(client, c.get("funding_txid"))} + for c in client.rpc.listpeerchannels()["channels"] + ] + return cls( + client.info["id"], + lsp.info["id"], + mock_state, + invoices, + channels, + ) + + def paid_invoices(self): + return [i for i in self.invoices if i["status"] == "paid"] + + def forwards(self, payment_hash): + """The parts of a payment the mock forwarded to the client and the + client settled.""" + return [ + e + for e in self.events + if e["type"] == "htlc" + and e["decision"] == "forward" + and e["status"] == "settled" + and e["payment_hash"] == payment_hash + ] + + def session(self, **match): + """The client's session whose fields equal `match`, or None.""" + for s in self.sessions: + if all(s.get(k) == v for k, v in match.items()): + return s + return None + + def channel_ids(self): + """Every SCID and alias the client knows for its channels.""" + ids = set() + for c in self.channels: + ids.add(c.get("short_channel_id")) + ids.update(c.get("alias", {}).values()) + ids.discard(None) + return ids + + +def _confirmations(node, txid): + if txid is None: + return 0 + try: + tx = node.bitcoin.rpc.getrawtransaction(txid, True) + except Exception: + # Not broadcast. + return 0 + return tx.get("confirmations", 0) + + +def opening_fee(params, payment_size_msat): + """The opening fee for `params` (as sent by the LSP), or None if it + cannot be computed.""" + try: + return lsps2_mock_fee.opening_fee( + payment_size_msat, int(params["min_fee_msat"]), int(params["proportional"]) + ) + except (lsps2_mock_fee.FeeOverflow, KeyError, TypeError, ValueError): + return None + + +def parse_lsps_datetime(s): + dt = datetime.strptime(s, "%Y-%m-%dT%H:%M:%S.%fZ") + return dt.replace(tzinfo=timezone.utc) + + +CHECKS = [] + + +def check(f): + """Register a check: a function of an Observation yielding + (check_id, message) for each violation.""" + CHECKS.append(f) + return f + + +class OracleViolation(AssertionError): + pass + + +def violations(obs, waive=()): + return [(cid, msg) for f in CHECKS for cid, msg in f(obs) if cid not in waive] + + +def check_oracles(client, lsp, waive=()): + """Check the oracles on what `client` did with the mock service on `lsp`; + `waive` lists check IDs to skip.""" + found = violations(Observation.from_nodes(client, lsp), waive) + if found: + raise OracleViolation( + "\n".join(f"{cid}: {msg}" for cid, msg in found) + ) + + +# --- Amount ---------------------------------------------------------------- + + +@check +def check_amount(obs): + """A paid invoice received at least what was paid for minus what it + owes. The payment that opened the channel owes the session's opening + fee on the payment size (the onion amount in no-MPP+var-invoice mode); + later payments owe nothing on their invoice amount (or onion amount).""" + for inv in obs.paid_invoices(): + forwards = obs.forwards(inv["payment_hash"]) + if not forwards: + # Not paid through the mock. + continue + onion_msat = sum(f["onion_forward_msat"] for f in forwards) + session = obs.session(payment_hash=inv["payment_hash"]) + if session is None: + expected_msat = inv.get("amount_msat", onion_msat) + fee_msat = 0 + else: + # Not the invoice's amount: the client keeps a copy of the + # invoice reduced by the fee. + expected_msat = session["payment_size_msat"] or onion_msat + fee_msat = opening_fee(session["opening_fee_params"], expected_msat) + if fee_msat is None: + yield "amount.received", ( + f"invoice {inv['label']!r} paid although the opening fee " + f"cannot be computed" + ) + elif inv["amount_received_msat"] < expected_msat - fee_msat: + yield "amount.received", ( + f"invoice {inv['label']!r} received " + f"{inv['amount_received_msat']} msat, less than " + f"{expected_msat} - {fee_msat} msat" + ) + + +@check +def check_balance(obs): + """The client's balance in channels it did not open does not exceed + what its paid invoices received: nothing was released outside an + invoice.""" + held_msat = sum(c["to_us_msat"] for c in obs.channels if c["opener"] == "remote") + received_msat = sum(i["amount_received_msat"] for i in obs.paid_invoices()) + if held_msat > received_msat: + yield "amount.balance", ( + f"client holds {held_msat} msat in channels but its paid " + f"invoices received {received_msat} msat" + ) + + +# --- Identity -------------------------------------------------------------- + + +@check +def check_route_hint(obs): + """An invoice routes only through channels the client has, or through + the LSP on a jit_channel_scid the LSP issued to this client; an LSPS2 + invoice has exactly that one single-hop route hint.""" + channel_ids = obs.channel_ids() + for inv in obs.invoices: + routes = inv["decoded"].get("routes", []) + hops = [hop for route in routes for hop in route] + sessions = [obs.session(jit_channel_scid=h["short_channel_id"]) for h in hops] + if any(sessions) and len(hops) != 1: + yield "identity.route_hint", ( + f"invoice {inv['label']!r} has more than the one route " + f"hint to the LSP" + ) + for hop, session in zip(hops, sessions): + if session is not None: + if hop["pubkey"] != obs.lsp_id: + yield "identity.route_hint", ( + f"invoice {inv['label']!r} routes through " + f"{hop['pubkey']} instead of the LSP" + ) + elif hop["short_channel_id"] not in channel_ids: + yield "identity.route_hint", ( + f"invoice {inv['label']!r} routes through " + f"{hop['short_channel_id']}, which is neither a channel " + f"of the client nor issued by the LSP" + ) + + +@check +def check_zero_conf_channel(obs): + """A channel the client uses before its funding confirms is the one the + LSP opened for one of this client's sessions.""" + session_channels = {s["channel_id"] for s in obs.sessions if s["channel_id"]} + for c in obs.channels: + if c["state"] != "CHANNELD_NORMAL" or c["confirmations"] > 0: + continue + if c["peer_id"] != obs.lsp_id or c["channel_id"] not in session_channels: + yield "identity.zero_conf_channel", ( + f"client uses unconfirmed channel {c['channel_id']} from " + f"{c['peer_id']}, which no session opened" + ) diff --git a/tests/plugins/lsps2_mock.py b/tests/plugins/lsps2_mock.py index e5c301139d85..b59c5cb85a91 100755 --- a/tests/plugins/lsps2_mock.py +++ b/tests/plugins/lsps2_mock.py @@ -157,6 +157,8 @@ def __init__(self, scid, peer_id, params, payment_size_msat, opening_fee_msat): # collecting -> opening -> forwarded self.state = "collecting" self.parts = [] + # The payment that opens the channel and pays the opening fee. + self.payment_hash = None self.channel_id = None self.htlc_minimum_msat = 0 @@ -168,6 +170,7 @@ def to_json(self): "payment_size_msat": self.payment_size_msat, "opening_fee_msat": self.opening_fee_msat, "state": self.state, + "payment_hash": self.payment_hash, "channel_id": self.channel_id, "held_parts": len(self.parts), } @@ -225,8 +228,9 @@ def build_config(overrides): def record(kind, **fields): + now = lsps_datetime(datetime.now(timezone.utc)) with state.lock: - state.events.append({"type": kind, **fields}) + state.events.append({"type": kind, "time": now, **fields}) def report_error(where, e, **fields): @@ -649,6 +653,8 @@ def forward_part(session, part, deduction_msat, extra_fee_msat): "htlc", jit_channel_scid=session.scid, payment_hash=part["htlc"]["payment_hash"], + in_channel=part["htlc"]["short_channel_id"], + in_htlc_id=part["htlc"]["id"], amount_msat=int(part["htlc"]["amount_msat"]), onion_forward_msat=onion_msat, forward_msat=forward_msat, @@ -819,6 +825,7 @@ def on_htlc_accepted(onion, htlc, request, **kwargs): session.opening_fee_msat = fee_msat if not session.parts: + session.payment_hash = htlc["payment_hash"] arm_expiry(session) session.parts.append(part) if session.state == "collecting" and collected_enough(session): @@ -857,10 +864,11 @@ def lsps2_mock_configure(config, **kwargs): @plugin.method("lsps2-mock-state") def lsps2_mock_state(**kwargs): - """Return config, sessions and every recorded event.""" + """Return config, issued params, sessions and every recorded event.""" with state.lock: return { "config": state.config, + "issued": list(state.issued.values()), "sessions": [s.to_json() for s in state.sessions.values()], "events": state.events, } diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py index 0f45f94484fe..1e879e5170a8 100644 --- a/tests/test_cln_lsps.py +++ b/tests/test_cln_lsps.py @@ -1,8 +1,8 @@ from fixtures import * # noqa: F401,F403 +from lsps2_oracles import check_oracles, lsps2_mock_fee from pyln.client import RpcError from pyln.testing.utils import RUST from utils import only_one, wait_for -import importlib.util import json import os import pytest @@ -359,18 +359,6 @@ def test_lsps2_non_approved_zero_conf(node_factory, bitcoind): LSPS2_MOCK = os.path.join(os.path.dirname(__file__), "plugins/lsps2_mock.py") -def load_lsps2_mock_fee(): - """The mock's fee module; tests/plugins is not a package.""" - path = os.path.join(os.path.dirname(__file__), "plugins/lsps2_mock_fee.py") - spec = importlib.util.spec_from_file_location("lsps2_mock_fee", path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -lsps2_mock_fee = load_lsps2_mock_fee() - - def lsps2_mock_nodes(node_factory, client_opts=None): """Client l1, LSP l2 running the mock service, payer l3. @@ -598,6 +586,7 @@ def test_lsps2_mock_jit_channel_var_invoice(node_factory): paid = only_one(l1.rpc.listinvoices("lsp-jit-channel-0")["invoices"]) assert paid["status"] == "paid" assert paid["amount_received_msat"] == amt - fee_msat + check_oracles(l1, l2) def test_lsps2_mock_jit_channel_mpp_fixed_invoice(node_factory): @@ -663,6 +652,7 @@ def test_lsps2_mock_jit_channel_mpp_fixed_invoice(node_factory): only_one(l1.rpc.listpeerchannels()["channels"]) paid = only_one(l1.rpc.listinvoices("lsp-jit-channel-0")["invoices"]) assert paid["status"] == "paid" + check_oracles(l1, l2) LIST_PROTOCOLS_RESULT = {"jsonrpc": "2.0", "result": {"protocols": [2]}, "id": "1"} @@ -942,6 +932,7 @@ def test_lsps2_mock_expired_session(node_factory): fail = only_one(lsps2_mock_events(l2, "htlc")) assert fail["reason"] == "expired" assert lsps2_mock_events(l2, "open") == [] + check_oracles(l1, l2) def test_lsps2_mock_expired_held_parts(node_factory): diff --git a/tests/test_lsps2_oracles.py b/tests/test_lsps2_oracles.py new file mode 100644 index 000000000000..3f5ae7671450 --- /dev/null +++ b/tests/test_lsps2_oracles.py @@ -0,0 +1,186 @@ +"""Unit tests for the LSPS2 oracles, on observations written by hand.""" + +from copy import deepcopy +from lsps2_oracles import Observation, violations + + +CLIENT = "02" + "11" * 32 +LSP = "03" + "22" * 32 +SCID = "1000000x1x0" +PAYMENT_HASH = "aa" * 32 +CHANNEL_ID = "cc" * 32 + +PARAMS = { + "min_fee_msat": "1000", + "proportional": 10_000, + "min_lifetime": 1008, + "max_client_to_self_delay": 2016, + "min_payment_size_msat": "1000", + "max_payment_size_msat": "100000000", + "valid_until": "2100-01-01T00:00:00.000Z", + "promise": "mock-promise-0001", +} + + +def honest_run(): + """A no-MPP+var-invoice session paid with 10_000_000 msat, minus the + 1% opening fee, over a confirmed channel.""" + return { + "client_id": CLIENT, + "lsp_id": LSP, + "mock_state": { + "issued": [PARAMS], + "sessions": [ + { + "jit_channel_scid": SCID, + "peer_id": CLIENT, + "opening_fee_params": PARAMS, + "payment_size_msat": None, + "opening_fee_msat": 100_000, + "state": "forwarded", + "payment_hash": PAYMENT_HASH, + "channel_id": CHANNEL_ID, + } + ], + "events": [ + forward_event(PAYMENT_HASH, 10_000_000, 100_000), + ], + }, + "invoices": [ + { + "label": "jit", + "status": "paid", + "payment_hash": PAYMENT_HASH, + "amount_received_msat": 9_900_000, + "decoded": { + "routes": [ + [ + { + "pubkey": LSP, + "short_channel_id": SCID, + "fee_base_msat": 0, + "fee_proportional_millionths": 0, + "cltv_expiry_delta": 144, + } + ] + ], + }, + } + ], + "channels": [ + { + "peer_id": LSP, + "channel_id": CHANNEL_ID, + "state": "CHANNELD_NORMAL", + "opener": "remote", + "confirmations": 1, + "to_us_msat": 9_900_000, + "alias": {"local": "5x5x5", "remote": "6x6x6"}, + "htlcs": [], + } + ], + } + + +def forward_event(payment_hash, onion_msat, deduction_msat, status="settled"): + return { + "type": "htlc", + "time": "2026-01-01T00:00:00.000Z", + "jit_channel_scid": SCID, + "payment_hash": payment_hash, + "in_channel": "7x7x7", + "in_htlc_id": 0, + "amount_msat": onion_msat, + "onion_forward_msat": onion_msat, + "forward_msat": onion_msat - deduction_msat, + "extra_fee_msat": deduction_msat or None, + "decision": "forward", + "status": status, + } + + +def check_ids(run): + return [cid for cid, _ in violations(Observation(**run))] + + +def test_lsps2_oracles_honest_run(): + assert check_ids(honest_run()) == [] + + +def test_lsps2_oracles_amount(): + run = honest_run() + run["invoices"][0]["amount_received_msat"] -= 1 + run["channels"][0]["to_us_msat"] -= 1 + assert check_ids(run) == ["amount.received"] + + # A part the client failed is not part of the payment. + run = honest_run() + failed = forward_event(PAYMENT_HASH, 10_000_000, 100_000, status="failed") + run["mock_state"]["events"].insert(2, failed) + assert check_ids(run) == [] + + # A fixed invoice owes the fee for the payment size bought. + run = honest_run() + run["mock_state"]["sessions"][0]["payment_size_msat"] = 10_000_000 + run["invoices"][0]["amount_msat"] = 9_900_000 + assert check_ids(run) == [] + run["mock_state"]["sessions"][0]["payment_size_msat"] = 11_000_000 + assert check_ids(run) == ["amount.received"] + + # Later payments owe no fee. + run = honest_run() + later = deepcopy(run["invoices"][0]) + later.update(label="later", payment_hash="bb" * 32) + run["invoices"].append(later) + run["mock_state"]["events"].append(forward_event("bb" * 32, 10_000_000, 100_000)) + run["channels"][0]["to_us_msat"] *= 2 + assert check_ids(run) == ["amount.received"] + + +def test_lsps2_oracles_balance(): + run = honest_run() + run["channels"][0]["to_us_msat"] += 1 + assert check_ids(run) == ["amount.balance"] + + # Funds in a channel the client opened are its own. + run = honest_run() + run["channels"].append( + {**run["channels"][0], "channel_id": "dd" * 32, "opener": "local", "alias": {}} + ) + assert check_ids(run) == [] + + +def test_lsps2_oracles_route_hint(): + run = honest_run() + run["invoices"][0]["decoded"]["routes"][0][0]["short_channel_id"] = "1x2x3" + assert check_ids(run) == ["identity.route_hint"] + + run = honest_run() + run["invoices"][0]["decoded"]["routes"][0][0]["pubkey"] = "02" + "33" * 32 + assert check_ids(run) == ["identity.route_hint"] + + run = honest_run() + routes = run["invoices"][0]["decoded"]["routes"] + routes.append(deepcopy(routes[0])) + assert check_ids(run) == ["identity.route_hint"] + + # A hint to a channel the client has is fine. + run = honest_run() + run["invoices"][0]["decoded"]["routes"][0][0]["short_channel_id"] = "6x6x6" + assert check_ids(run) == [] + + +def test_lsps2_oracles_zero_conf_channel(): + run = honest_run() + run["channels"][0]["confirmations"] = 0 + assert "identity.zero_conf_channel" not in check_ids(run) + + run["channels"].append( + { + **run["channels"][0], + "channel_id": "dd" * 32, + "to_us_msat": 0, + "alias": {}, + } + ) + assert "identity.zero_conf_channel" in check_ids(run) From 8f9be1d68c74a15ff6d4974b4fd231ae1f3ced7c Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Tue, 20 Oct 2026 15:14:33 +0200 Subject: [PATCH 3/6] tests: add the params and deadlines oracle for the LSPS2 client tests Expired, contradictory or modified params must never lead to a payment. Two checks: - Every lsps2.buy the client sent uses params exactly as the LSP issued them, before valid_until, with usable payment size limits, a payment size the fee does not eat up, and a promise of at most 512 bytes of printable ASCII without '"' or '\'. - Every LSPS2 invoice follows the buy result the client was sent: the lsp_cltv_expiry_delta in a route hint without fees, an expiry no later than valid_until and the payment size as amount. --- tests/lsps2_oracles.py | 143 ++++++++++++++++++++++++++++++++++++ tests/test_lsps2_oracles.py | 108 ++++++++++++++++++++++++++- 2 files changed, 248 insertions(+), 3 deletions(-) diff --git a/tests/lsps2_oracles.py b/tests/lsps2_oracles.py index d32b5eb1af99..42e59a7ed46d 100644 --- a/tests/lsps2_oracles.py +++ b/tests/lsps2_oracles.py @@ -20,6 +20,8 @@ """ import importlib.util +import json +import math import os from datetime import datetime, timezone @@ -35,6 +37,10 @@ def _load_plugin_module(name): lsps2_mock_fee = _load_plugin_module("lsps2_mock_fee") +# bLIP-52 lets the LSP send promises of up to 512 bytes, in printable ASCII +# that needs no escaping in JSON; the client aborts on anything else. +MAX_PROMISE_BYTES = 512 + class Observation: """What the client and the mock service on the LSP did, as plain data, @@ -104,6 +110,43 @@ def session(self, **match): return s return None + def messages(self, kind): + """(time, message) for each JSON object in the mock's `kind` events + with the client: "rx" received from it, "tx" sent to it.""" + out = [] + for e in self.events: + if e["type"] != kind or e["peer_id"] != self.client_id: + continue + try: + msg = json.loads(e["payload"]) + except ValueError: + continue + if isinstance(msg, dict): + out.append((e["time"], msg)) + return out + + def buy_result(self, scid): + """The first buy result the client was sent for `scid`.""" + for _, msg in self.messages("tx"): + result = msg.get("result") + if isinstance(result, dict) and result.get("jit_channel_scid") == scid: + return result + return None + + def lsps2_invoices(self): + """(invoice, session) for each invoice with a route hint on a + jit_channel_scid of the client's sessions.""" + out = [] + for inv in self.invoices: + seen = [] + for route in inv["decoded"].get("routes", []): + for hop in route: + session = self.session(jit_channel_scid=hop["short_channel_id"]) + if session is not None and session not in seen: + seen.append(session) + out.append((inv, session)) + return out + def channel_ids(self): """Every SCID and alias the client knows for its channels.""" ids = set() @@ -141,6 +184,13 @@ def parse_lsps_datetime(s): return dt.replace(tzinfo=timezone.utc) +def parse_msat(v): + """An LSPS0.msat (a string of decimal digits) as int, or None.""" + if isinstance(v, str) and v.isdigit(): + return int(v) + return None + + CHECKS = [] @@ -266,3 +316,96 @@ def check_zero_conf_channel(obs): f"client uses unconfirmed channel {c['channel_id']} from " f"{c['peer_id']}, which no session opened" ) + + +# --- Params and deadlines -------------------------------------------------- + + +def promise_ok(promise): + return ( + isinstance(promise, str) + and len(promise.encode()) <= MAX_PROMISE_BYTES + and all(0x20 <= ord(c) <= 0x7E and c not in '"\\' for c in promise) + ) + + +@check +def check_buy_params(obs): + """The client buys only with params the LSP issued, unchanged, before + they expire, and consistent.""" + for time, req in obs.messages("rx"): + if req.get("method") != "lsps2.buy": + continue + if not isinstance(req.get("params"), dict): + continue + params = req["params"] + ofp = params.get("opening_fee_params") + if ofp not in obs.issued: + yield "params.modified", "client bought with params the LSP did not issue" + continue + + try: + valid_until = parse_lsps_datetime(ofp["valid_until"]) + expired = valid_until <= parse_lsps_datetime(time) + except (KeyError, TypeError, ValueError): + expired = True + if expired: + yield "params.expired", ( + f"client bought with params valid until " + f"{ofp.get('valid_until')!r}" + ) + + min_msat = parse_msat(ofp.get("min_payment_size_msat")) + max_msat = parse_msat(ofp.get("max_payment_size_msat")) + size_msat = parse_msat(params.get("payment_size_msat")) + if min_msat is None or max_msat is None or min_msat > max_msat: + yield "params.contradictory", "client bought with unusable size limits" + elif "payment_size_msat" not in params: + pass + elif size_msat is None or not min_msat <= size_msat <= max_msat: + yield "params.contradictory", "client bought a size outside the limits" + else: + fee_msat = opening_fee(ofp, size_msat) + if fee_msat is None or fee_msat >= size_msat: + yield "params.contradictory", "client bought a size the fee eats up" + + if not promise_ok(ofp.get("promise")): + yield "params.promise", "client bought with an invalid promise" + + +@check +def check_invoice(obs): + """An LSPS2 invoice follows the buy result and the params: the LSP's + cltv delta is in the route hint with zero fees, the invoice expires by + valid_until, and its amount is the payment size.""" + for inv, session in obs.lsps2_invoices(): + result = obs.buy_result(session["jit_channel_scid"]) or {} + delta = result.get("lsp_cltv_expiry_delta") + for route in inv["decoded"]["routes"]: + for hop in route: + fees = (hop["fee_base_msat"], hop["fee_proportional_millionths"]) + if hop["cltv_expiry_delta"] != delta or fees != (0, 0): + yield "params.invoice", ( + f"invoice {inv['label']!r} route hint does not match " + f"the buy result" + ) + + # Invoice timestamps (BOLT 11) have one-second resolution. + params = session["opening_fee_params"] + valid_until = parse_lsps_datetime(params["valid_until"]) + if inv["expires_at"] > math.ceil(valid_until.timestamp()): + yield "params.invoice", ( + f"invoice {inv['label']!r} expires after the params' valid_until" + ) + + # The client replaces the invoice it hands out by a copy for the + # payment size minus the fee, which the reduced HTLCs pay. + size_msat = session["payment_size_msat"] + if size_msat is None: + amounts = [None] + else: + amounts = [size_msat, size_msat - (opening_fee(params, size_msat) or 0)] + if inv["decoded"].get("amount_msat") not in amounts: + yield "params.invoice", ( + f"invoice {inv['label']!r} amount is not the payment size bought" + ) diff --git a/tests/test_lsps2_oracles.py b/tests/test_lsps2_oracles.py index 3f5ae7671450..2b3324d5d7f8 100644 --- a/tests/test_lsps2_oracles.py +++ b/tests/test_lsps2_oracles.py @@ -2,6 +2,7 @@ from copy import deepcopy from lsps2_oracles import Observation, violations +import json CLIENT = "02" + "11" * 32 @@ -20,6 +21,7 @@ "valid_until": "2100-01-01T00:00:00.000Z", "promise": "mock-promise-0001", } +VALID_UNTIL_TIMESTAMP = 4102444800 def honest_run(): @@ -43,6 +45,19 @@ def honest_run(): } ], "events": [ + message_event("rx", buy_request(PARAMS)), + message_event( + "tx", + { + "jsonrpc": "2.0", + "id": "1", + "result": { + "jit_channel_scid": SCID, + "lsp_cltv_expiry_delta": 144, + "client_trusts_lsp": False, + }, + }, + ), forward_event(PAYMENT_HASH, 10_000_000, 100_000), ], }, @@ -52,6 +67,7 @@ def honest_run(): "status": "paid", "payment_hash": PAYMENT_HASH, "amount_received_msat": 9_900_000, + "expires_at": VALID_UNTIL_TIMESTAMP, "decoded": { "routes": [ [ @@ -82,6 +98,29 @@ def honest_run(): } +def buy_request(params, **extra): + return { + "jsonrpc": "2.0", + "id": "1", + "method": "lsps2.buy", + "params": {"opening_fee_params": params, **extra}, + } + + +def message_event(kind, message, time="2026-01-01T00:00:00.000Z"): + return {"type": kind, "time": time, "peer_id": CLIENT, "payload": json.dumps(message)} + + +def bought(params, **extra): + """An honest run, but the LSP issued `params` and the client bought them + with `extra` buy parameters.""" + run = honest_run() + run["mock_state"]["issued"] = [params] + run["mock_state"]["sessions"][0]["opening_fee_params"] = params + run["mock_state"]["events"][0] = message_event("rx", buy_request(params, **extra)) + return run + + def forward_event(payment_hash, onion_msat, deduction_msat, status="settled"): return { "type": "htlc", @@ -99,8 +138,10 @@ def forward_event(payment_hash, onion_msat, deduction_msat, status="settled"): } -def check_ids(run): - return [cid for cid, _ in violations(Observation(**run))] +def check_ids(run, oracle=""): + """The IDs of the checks `run` violates, of `oracle` only if given.""" + found = [cid for cid, _ in violations(Observation(**run))] + return [cid for cid in found if cid.startswith(oracle)] def test_lsps2_oracles_honest_run(): @@ -123,9 +164,10 @@ def test_lsps2_oracles_amount(): run = honest_run() run["mock_state"]["sessions"][0]["payment_size_msat"] = 10_000_000 run["invoices"][0]["amount_msat"] = 9_900_000 + run["invoices"][0]["decoded"]["amount_msat"] = 9_900_000 assert check_ids(run) == [] run["mock_state"]["sessions"][0]["payment_size_msat"] = 11_000_000 - assert check_ids(run) == ["amount.received"] + assert check_ids(run, "amount") == ["amount.received"] # Later payments owe no fee. run = honest_run() @@ -184,3 +226,63 @@ def test_lsps2_oracles_zero_conf_channel(): } ) assert "identity.zero_conf_channel" in check_ids(run) + + +def test_lsps2_oracles_buy_params(): + run = honest_run() + tampered = {**PARAMS, "promise": "mock-promise-0002"} + run["mock_state"]["events"][0] = message_event("rx", buy_request(tampered)) + assert check_ids(run) == ["params.modified"] + + run = honest_run() + request = buy_request(PARAMS) + run["mock_state"]["events"][0] = message_event("rx", request, PARAMS["valid_until"]) + assert check_ids(run) == ["params.expired"] + + inverted = {**PARAMS, "min_payment_size_msat": "100000001"} + assert check_ids(bought(inverted)) == ["params.contradictory"] + assert check_ids(bought(PARAMS, payment_size_msat="100000001")) == [ + "params.contradictory" + ] + assert check_ids(bought(PARAMS, payment_size_msat="1000")) == ["params.contradictory"] + free = {**PARAMS, "min_fee_msat": "0", "proportional": 0} + assert check_ids(bought(free, payment_size_msat="1000"), "params") == [] + + assert check_ids(bought({**PARAMS, "promise": "x" * 512})) == [] + assert check_ids(bought({**PARAMS, "promise": "x" * 513})) == ["params.promise"] + assert check_ids(bought({**PARAMS, "promise": 'a"b'})) == ["params.promise"] + assert check_ids(bought({**PARAMS, "promise": "caf\u00e9"})) == ["params.promise"] + + +def test_lsps2_oracles_invoice(): + run = honest_run() + run["invoices"][0]["decoded"]["routes"][0][0]["cltv_expiry_delta"] = 145 + assert check_ids(run) == ["params.invoice"] + + run = honest_run() + run["invoices"][0]["decoded"]["routes"][0][0]["fee_base_msat"] = 1 + assert check_ids(run) == ["params.invoice"] + + run = honest_run() + run["invoices"][0]["expires_at"] += 1 + assert check_ids(run) == ["params.invoice"] + + # Each hop once, even if several route hints name the session. + run = honest_run() + routes = run["invoices"][0]["decoded"]["routes"] + routes[0][0]["cltv_expiry_delta"] = 145 + routes.append(deepcopy(routes[0])) + assert check_ids(run, "params") == ["params.invoice"] * 2 + + run = honest_run() + run["invoices"][0]["decoded"]["amount_msat"] = 10_000_000 + assert check_ids(run) == ["params.invoice"] + + run = honest_run() + run["mock_state"]["sessions"][0]["payment_size_msat"] = 10_000_000 + run["invoices"][0]["decoded"]["amount_msat"] = 10_000_000 + assert check_ids(run) == [] + run["invoices"][0]["decoded"]["amount_msat"] = 9_900_000 + assert check_ids(run) == [] + run["invoices"][0]["decoded"]["amount_msat"] = 9_899_999 + assert check_ids(run) == ["params.invoice"] From 95183974d9e0fae51041dd4dcba344ed8ee94e4f Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Tue, 20 Oct 2026 15:19:35 +0200 Subject: [PATCH 4/6] tests: add the robustness oracle for the LSPS2 client tests The last oracle for the LSPS2 client tests: the LSPS client plugin is still running and never crashed, no HTLC is left pending in the client's channels, and the client sent no more than 20 LSPS requests. check_oracles first waits for the client's HTLCs to settle, so that a check does not race a payment still in flight, unless the test waives robustness.hanging. --- tests/lsps2_oracles.py | 72 ++++++++++++++++++++++++++++++++++++- tests/test_cln_lsps.py | 5 ++- tests/test_lsps2_oracles.py | 22 ++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/tests/lsps2_oracles.py b/tests/lsps2_oracles.py index 42e59a7ed46d..a11e3b59434a 100644 --- a/tests/lsps2_oracles.py +++ b/tests/lsps2_oracles.py @@ -23,8 +23,11 @@ import json import math import os +import re from datetime import datetime, timezone +from pyln.testing.utils import wait_for + def _load_plugin_module(name): # tests/plugins is not a package. @@ -41,12 +44,26 @@ def _load_plugin_module(name): # that needs no escaping in JSON; the client aborts on anything else. MAX_PROMISE_BYTES = 512 +# LSPS requests a client may send in one test; a retry loop exceeds it. +MAX_LSPS_REQUESTS = 20 +LSPS_CLIENT_PLUGIN = "cln-lsps-client" +CRASH_LOG = re.compile(LSPS_CLIENT_PLUGIN + r".*(panicked|Killing plugin)") + class Observation: """What the client and the mock service on the LSP did, as plain data, so that checks can also be fed by hand.""" - def __init__(self, client_id, lsp_id, mock_state, invoices, channels): + def __init__( + self, + client_id, + lsp_id, + mock_state, + invoices, + channels, + plugins, + crash_logs, + ): self.client_id = client_id self.lsp_id = lsp_id self.issued = mock_state["issued"] @@ -59,6 +76,10 @@ def __init__(self, client_id, lsp_id, mock_state, invoices, channels): # listpeerchannels entries, with the funding tx's confirmations # under "confirmations". self.channels = channels + # Names of the client's active plugins. + self.plugins = plugins + # Client log lines showing the LSPS client crashed. + self.crash_logs = crash_logs @classmethod def from_nodes(cls, client, lsp): @@ -80,12 +101,19 @@ def from_nodes(cls, client, lsp): {**c, "confirmations": _confirmations(client, c.get("funding_txid"))} for c in client.rpc.listpeerchannels()["channels"] ] + plugins = [ + os.path.basename(p["name"]) + for p in client.rpc.plugin("list")["plugins"] + if p["active"] + ] return cls( client.info["id"], lsp.info["id"], mock_state, invoices, channels, + plugins, + [line for line in client.daemon.logs if CRASH_LOG.search(line)], ) def paid_invoices(self): @@ -212,6 +240,18 @@ def violations(obs, waive=()): def check_oracles(client, lsp, waive=()): """Check the oracles on what `client` did with the mock service on `lsp`; `waive` lists check IDs to skip.""" + + def settled(): + channels = client.rpc.listpeerchannels()["channels"] + return not any(c.get("htlcs") for c in channels) + + # A test waiving robustness.hanging expects HTLCs to stay held. + if "robustness.hanging" not in waive: + try: + wait_for(settled) + except ValueError: + # Reported as hanging. + pass found = violations(Observation.from_nodes(client, lsp), waive) if found: raise OracleViolation( @@ -409,3 +449,33 @@ def check_invoice(obs): yield "params.invoice", ( f"invoice {inv['label']!r} amount is not the payment size bought" ) + + +# --- Robustness ------------------------------------------------------------ + + +@check +def check_crash(obs): + """The LSPS client plugin is still running and never crashed.""" + if LSPS_CLIENT_PLUGIN not in obs.plugins: + yield "robustness.crash", f"{LSPS_CLIENT_PLUGIN} is not running" + for line in obs.crash_logs: + yield "robustness.crash", line + + +@check +def check_hanging(obs): + """No HTLC is left pending in the client's channels.""" + for c in obs.channels: + if c.get("htlcs"): + yield "robustness.hanging", ( + f"{len(c['htlcs'])} HTLCs pending in channel {c['channel_id']}" + ) + + +@check +def check_retry_storm(obs): + """The client sent a bounded number of LSPS requests.""" + requests = [m for _, m in obs.messages("rx") if "method" in m] + if len(requests) > MAX_LSPS_REQUESTS: + yield "robustness.retry_storm", f"client sent {len(requests)} LSPS requests" diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py index 1e879e5170a8..974550892b88 100644 --- a/tests/test_cln_lsps.py +++ b/tests/test_cln_lsps.py @@ -1,5 +1,8 @@ from fixtures import * # noqa: F401,F403 -from lsps2_oracles import check_oracles, lsps2_mock_fee +from lsps2_oracles import ( + check_oracles, + lsps2_mock_fee, +) from pyln.client import RpcError from pyln.testing.utils import RUST from utils import only_one, wait_for diff --git a/tests/test_lsps2_oracles.py b/tests/test_lsps2_oracles.py index 2b3324d5d7f8..7f4010da4ed9 100644 --- a/tests/test_lsps2_oracles.py +++ b/tests/test_lsps2_oracles.py @@ -69,6 +69,7 @@ def honest_run(): "amount_received_msat": 9_900_000, "expires_at": VALID_UNTIL_TIMESTAMP, "decoded": { + "min_final_cltv_expiry": 21, "routes": [ [ { @@ -95,6 +96,8 @@ def honest_run(): "htlcs": [], } ], + "plugins": ["cln-lsps-client"], + "crash_logs": [], } @@ -286,3 +289,22 @@ def test_lsps2_oracles_invoice(): assert check_ids(run) == [] run["invoices"][0]["decoded"]["amount_msat"] = 9_899_999 assert check_ids(run) == ["params.invoice"] + + +def test_lsps2_oracles_robustness(): + run = honest_run() + run["plugins"] = [] + assert check_ids(run) == ["robustness.crash"] + + run = honest_run() + run["crash_logs"] = ["plugin-cln-lsps-client: Killing plugin: exited"] + assert check_ids(run) == ["robustness.crash"] + + run = honest_run() + run["channels"][0]["htlcs"] = [{"direction": "in", "amount_msat": 1000}] + assert check_ids(run) == ["robustness.hanging"] + + run = honest_run() + request = {"jsonrpc": "2.0", "id": "2", "method": "lsps2.get_info", "params": {}} + run["mock_state"]["events"] += [message_event("rx", request)] * 20 + assert check_ids(run) == ["robustness.retry_storm"] From f8f9e322bcd20e76ee41c39b9fe19237779d05ee Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Tue, 20 Oct 2026 15:43:29 +0200 Subject: [PATCH 5/6] tests: check extra_fee in the LSPS2 amount oracle A client that accepts a part whose extra_fee does not match what was deducted from it breaks bLIP-52 even when the total deducted happens to be the right fee, and the amount oracle did not see that. Check that each part of a paid invoice arrived short by exactly its extra_fee and that the extra_fees add up to at most what the payment owes. --- tests/lsps2_oracles.py | 31 +++++++++++++++++++++++++++++++ tests/test_lsps2_oracles.py | 26 +++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/tests/lsps2_oracles.py b/tests/lsps2_oracles.py index a11e3b59434a..f9b4be9ffb6c 100644 --- a/tests/lsps2_oracles.py +++ b/tests/lsps2_oracles.py @@ -296,6 +296,37 @@ def check_amount(obs): ) +@check +def check_extra_fee(obs): + """Each part of a paid invoice arrived short of its onion amount by + exactly its extra_fee, and the extra_fees add up to at most what the + payment owes (bLIP-52, "Channel Opening And Forwarding").""" + for inv in obs.paid_invoices(): + forwards = obs.forwards(inv["payment_hash"]) + for f in forwards: + extra_fee_msat = f["extra_fee_msat"] or 0 + if f["forward_msat"] + extra_fee_msat != f["onion_forward_msat"]: + yield "amount.extra_fee", ( + f"invoice {inv['label']!r} paid by a part of " + f"{f['forward_msat']} msat with extra_fee " + f"{f['extra_fee_msat']} for an onion amount of " + f"{f['onion_forward_msat']} msat" + ) + session = obs.session(payment_hash=inv["payment_hash"]) + owed_msat = 0 + if session is not None: + size_msat = session["payment_size_msat"] or sum( + f["onion_forward_msat"] for f in forwards + ) + owed_msat = opening_fee(session["opening_fee_params"], size_msat) + total_msat = sum(f["extra_fee_msat"] or 0 for f in forwards) + if owed_msat is not None and total_msat > owed_msat: + yield "amount.extra_fee", ( + f"invoice {inv['label']!r} paid with extra_fees of " + f"{total_msat} msat, more than the {owed_msat} msat owed" + ) + + @check def check_balance(obs): """The client's balance in channels it did not open does not exceed diff --git a/tests/test_lsps2_oracles.py b/tests/test_lsps2_oracles.py index 7f4010da4ed9..860fd124d9b9 100644 --- a/tests/test_lsps2_oracles.py +++ b/tests/test_lsps2_oracles.py @@ -179,7 +179,31 @@ def test_lsps2_oracles_amount(): run["invoices"].append(later) run["mock_state"]["events"].append(forward_event("bb" * 32, 10_000_000, 100_000)) run["channels"][0]["to_us_msat"] *= 2 - assert check_ids(run) == ["amount.received"] + assert check_ids(run) == ["amount.received", "amount.extra_fee"] + + +def test_lsps2_oracles_extra_fee(): + run = honest_run() + run["mock_state"]["events"][2]["extra_fee_msat"] = None + assert check_ids(run) == ["amount.extra_fee"] + + run = honest_run() + run["mock_state"]["events"][2]["extra_fee_msat"] = 99_999 + assert check_ids(run) == ["amount.extra_fee"] + + # Within the fee, as long as each part matches its extra_fee. + run = honest_run() + forward = run["mock_state"]["events"][2] + forward.update(forward_msat=9_950_000, extra_fee_msat=50_000) + run["invoices"][0]["amount_received_msat"] = 9_950_000 + run["channels"][0]["to_us_msat"] = 9_950_000 + assert check_ids(run) == [] + + # More extra_fee than the payment owes, even if the parts match. + run = honest_run() + forward = run["mock_state"]["events"][2] + forward.update(onion_forward_msat=10_100_001, extra_fee_msat=200_001) + assert check_ids(run) == ["amount.received", "amount.extra_fee"] def test_lsps2_oracles_balance(): From 220062b19177e85b8176fd602e31c79f024b495b Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Mon, 26 Oct 2026 10:18:21 +0100 Subject: [PATCH 6/6] tests: explain why the LSPS2 oracles have their own unit tests --- tests/test_lsps2_oracles.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_lsps2_oracles.py b/tests/test_lsps2_oracles.py index 860fd124d9b9..162df6f567c6 100644 --- a/tests/test_lsps2_oracles.py +++ b/tests/test_lsps2_oracles.py @@ -1,4 +1,11 @@ -"""Unit tests for the LSPS2 oracles, on observations written by hand.""" +"""Unit tests for the LSPS2 oracles, on observations written by hand. + +In the client tests the client behaves, so every oracle passes there, and +an oracle that can never fire would pass too. Each test here breaks one +thing in an honest run and asserts that exactly the matching check fires, +so we know the oracles can catch what they claim to. The honest run, and +the variants that stay within the rules, guard against false positives. +""" from copy import deepcopy from lsps2_oracles import Observation, violations