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, diff --git a/tests/lsps2_oracles.py b/tests/lsps2_oracles.py new file mode 100644 index 000000000000..f9b4be9ffb6c --- /dev/null +++ b/tests/lsps2_oracles.py @@ -0,0 +1,512 @@ +"""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 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. + 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") + +# 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 + +# 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, + plugins, + crash_logs, + ): + 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 + # 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): + 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"] + ] + 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): + 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 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() + 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) + + +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 = [] + + +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.""" + + 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( + "\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_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 + 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" + ) + + +# --- 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" + ) + + +# --- 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/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..974550892b88 100644 --- a/tests/test_cln_lsps.py +++ b/tests/test_cln_lsps.py @@ -1,8 +1,11 @@ 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 +362,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 +589,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 +655,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 +935,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..162df6f567c6 --- /dev/null +++ b/tests/test_lsps2_oracles.py @@ -0,0 +1,341 @@ +"""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 +import json + + +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", +} +VALID_UNTIL_TIMESTAMP = 4102444800 + + +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": [ + 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), + ], + }, + "invoices": [ + { + "label": "jit", + "status": "paid", + "payment_hash": PAYMENT_HASH, + "amount_received_msat": 9_900_000, + "expires_at": VALID_UNTIL_TIMESTAMP, + "decoded": { + "min_final_cltv_expiry": 21, + "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": [], + } + ], + "plugins": ["cln-lsps-client"], + "crash_logs": [], + } + + +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", + "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, 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(): + 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 + 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") == ["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", "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(): + 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) + + +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"] + + +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"]