From 583a2eaa1a4e3c8ffe4eaeb7948e8bec5ddebde3 Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Mon, 29 Jun 2026 16:17:29 +0200 Subject: [PATCH 1/4] clnvm: Make CLN version selection deterministic and bounded The test harness picked the default CLN node version with a lexicographic `max()` over the manifest tags (and a `[... "gl" ...][-1]` in the `paths` fixture). As soon as a new release lands in the shared manifest.json it becomes the default for the whole suite, even if the client and signer do not support it yet. That is how `v26.06gl1` started being used before support was built in. Make selection deterministic and explicitly bounded: - Add `version_sort_key()`/`version_base()` parsing the `vX.Y[.Z][glN]` tags into numeric, ordered keys. The `glN` suffix is a separate component, so ordering is numeric (not lexicographic) and the base version can be compared independently of the greenlight suffix. - Add `ClnVersionManager.supported_versions(lowest, highest)` and `latest_supported(lowest, highest)`, filtering to base versions within `[lowest, highest]` inclusive and dropping non-numbered tags (`main`). - Make the existing `latest()` deterministic too. - In the gl-testing fixtures, pin `LOWEST_SUPPORTED_VERSION` explicitly and derive `HIGHEST_SUPPORTED_VERSION` from `glclient.__version__`, i.e. what the signer (libhsmd) actually supports. The suffix is ignored when comparing, so the signer reporting `v25.12` matches `v25.12gl1` but excludes `v26.06gl1`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../clnvm/cln_version_manager.py | 88 ++++++++++++++++++- .../tests/test_version_manager.py | 77 +++++++++++++++- libs/gl-testing/gltesting/fixtures.py | 24 ++++- 3 files changed, 180 insertions(+), 9 deletions(-) diff --git a/libs/cln-version-manager/clnvm/cln_version_manager.py b/libs/cln-version-manager/clnvm/cln_version_manager.py index c469c4f84..6a118d2a3 100644 --- a/libs/cln-version-manager/clnvm/cln_version_manager.py +++ b/libs/cln-version-manager/clnvm/cln_version_manager.py @@ -4,6 +4,7 @@ import logging import os from pathlib import Path +import re import subprocess import sys import tarfile @@ -43,6 +44,40 @@ class VersionDescriptor: logger = logging.getLogger(__name__) +# Matches tags like ``v25.12``, ``v25.12.``, ``v25.12gl1`` or ``v0.11.2gl2``. +# Group 1 is the dotted numeric base, group 2 the optional greenlight (``glN``) +# revision. +_VERSION_RE = re.compile(r"^v?(\d+(?:\.\d+)*)\.?(?:gl(\d+))?$") + + +def version_sort_key(tag: str) -> Optional[Tuple[Tuple[int, ...], int]]: + """Return a deterministic sort key for a CLN version tag. + + The key is ``(base, glrev)`` where ``base`` is the tuple of numeric + version components and ``glrev`` is the greenlight suffix revision + (``-1`` when there is no ``glN`` suffix, so a plain upstream build sorts + just below its greenlight counterpart). Returns ``None`` for tags that + are not numbered releases, e.g. ``main``, so callers can skip them. + """ + m = _VERSION_RE.match(tag) + if m is None: + return None + base = tuple(int(p) for p in m.group(1).split(".")) + glrev = int(m.group(2)) if m.group(2) is not None else -1 + return base, glrev + + +def version_base(tag: str) -> Optional[Tuple[int, ...]]: + """Return the numeric base version, ignoring any ``glN`` suffix. + + Used to compare against the supported-version bounds, which are + expressed without the greenlight suffix (the signer reports e.g. + ``v25.12``, which must match both ``v25.12`` and ``v25.12gl1``). + """ + key = version_sort_key(tag) + return key[0] if key is not None else None + + def _get_cache_dir() -> Path: cln_cache_dir = os.environ.get("CLNVM_CACHE_DIR") if cln_cache_dir is not None: @@ -250,11 +285,56 @@ def get_descriptor_from_tag(self, tag: str) -> VersionDescriptor: return descriptor + def supported_versions( + self, lowest: str, highest: str + ) -> List[VersionDescriptor]: + """Return the supported versions, sorted ascending. + + A version is supported when its base version (ignoring the ``glN`` + suffix) lies within ``[lowest, highest]`` inclusive. Tags that are + not numbered releases (e.g. ``main``) are dropped. + """ + low = version_base(lowest) + high = version_base(highest) + if low is None or high is None: + raise ValueError( + f"Invalid version bounds: lowest={lowest!r}, highest={highest!r}" + ) + + selected = [] + for d in self.get_versions(): + key = version_sort_key(d.tag) + if key is not None and low <= key[0] <= high: + selected.append((key, d)) + + selected.sort(key=lambda kd: kd[0]) + return [d for _, d in selected] + + def latest_supported(self, lowest: str, highest: str) -> NodeVersion: + """Return the newest supported version within ``[lowest, highest]``. + + Deterministic: versions are ordered by ``(base, glrev)`` so the + greenlight build wins over the plain upstream build of the same base + version. Use this rather than :meth:`latest` so that newer-but- + unsupported releases present in the manifest are never picked up. + """ + supported = self.supported_versions(lowest, highest) + if not supported: + raise ValueError( + f"No CLN version available in range [{lowest}, {highest}]" + ) + return self.get(supported[-1]) + def latest(self) -> NodeVersion: - vs = [d.tag for d in self.get_versions()] - latest = max(vs) - descriptor = self.get_descriptor_from_tag(latest) - return self.get(descriptor) + candidates = [] + for d in self.get_versions(): + key = version_sort_key(d.tag) + if key is not None: + candidates.append((key, d)) + if not candidates: + raise ValueError("No numbered CLN version available in the manifest") + _, latest = max(candidates, key=lambda kd: kd[0]) + return self.get(latest) def get(self, cln_version: VersionDescriptor, force: bool = False) -> NodeVersion: """ diff --git a/libs/cln-version-manager/tests/test_version_manager.py b/libs/cln-version-manager/tests/test_version_manager.py index be4dcf08b..8f2b750b8 100644 --- a/libs/cln-version-manager/tests/test_version_manager.py +++ b/libs/cln-version-manager/tests/test_version_manager.py @@ -5,7 +5,30 @@ import requests -from clnvm.cln_version_manager import ClnVersionManager, VersionDescriptor +from clnvm.cln_version_manager import ( + ClnVersionManager, + VersionDescriptor, + version_base, + version_sort_key, +) + + +def _descriptor(tag: str) -> VersionDescriptor: + return VersionDescriptor(tag=tag, url="", checksum=tag) + + +# Mirrors the kind of tags found in the production manifest, deliberately out +# of order. +_MANIFEST_TAGS = [ + "main", + "v0.11.2gl2", + "v23.08.", + "v23.08gl1", + "v24.11gl1", + "v25.12.", + "v25.12gl1", + "v26.06gl1", +] def get_tmp_dir(name: str) -> str: @@ -32,3 +55,55 @@ def test_download_cln_version() -> None: with mock.patch("requests.get") as request_mock: vm_test.get_all() assert not request_mock.get.called + + +def test_version_sort_key() -> None: + # ``main`` and other non-numbered tags are not orderable. + assert version_sort_key("main") is None + + # The glN suffix is captured separately, and absent suffixes sort below + # their greenlight counterpart of the same base. + assert version_sort_key("v25.12.") == ((25, 12), -1) + assert version_sort_key("v25.12gl1") == ((25, 12), 1) + assert version_sort_key("v0.11.2gl2") == ((0, 11, 2), 2) + assert version_sort_key("v25.12.") < version_sort_key("v25.12gl1") + assert version_sort_key("v25.12gl1") < version_sort_key("v26.06gl1") + + +def test_version_base_ignores_suffix() -> None: + # Base comparison ignores the glN suffix, so the signer reporting + # ``v25.12`` matches both the plain and greenlight builds. + assert version_base("v25.12") == version_base("v25.12gl1") == (25, 12) + assert version_base("v25.12.") == (25, 12) + assert version_base("main") is None + + +def test_supported_versions_filters_and_sorts() -> None: + vm = ClnVersionManager( + cln_versions=[_descriptor(t) for t in _MANIFEST_TAGS] + ) + + supported = vm.supported_versions("v23.08", "v25.12") + tags = [d.tag for d in supported] + + # ``main`` dropped (not numbered), ``v0.11.2gl2`` below the lower bound, + # ``v26.06gl1`` above the signer-supported upper bound. Result is sorted + # ascending with the greenlight build after the plain build of the same + # base. + assert tags == [ + "v23.08.", + "v23.08gl1", + "v24.11gl1", + "v25.12.", + "v25.12gl1", + ] + + +def test_supported_versions_latest_is_greenlight_build() -> None: + vm = ClnVersionManager( + cln_versions=[_descriptor(t) for t in _MANIFEST_TAGS] + ) + + # The newest supported version is the greenlight build at the upper + # bound, never the newer-but-unsupported v26.06gl1. + assert vm.supported_versions("v23.08", "v25.12")[-1].tag == "v25.12gl1" diff --git a/libs/gl-testing/gltesting/fixtures.py b/libs/gl-testing/gltesting/fixtures.py index 655848d2e..549185e5d 100644 --- a/libs/gl-testing/gltesting/fixtures.py +++ b/libs/gl-testing/gltesting/fixtures.py @@ -27,6 +27,7 @@ from gltesting.grpcweb import GrpcWebProxy, NodeHandler from gltesting.lnurl_server import LnurlServer from clnvm import ClnVersionManager +import glclient logging.basicConfig(level=logging.DEBUG, stream=sys.stdout) @@ -36,6 +37,18 @@ logger = logging.getLogger(__name__) +# Bounds for selecting the default CLN version used by the test suite. +# +# The lowest is pinned explicitly: bump it as we drop support for old +# releases. The highest is whatever the gl-client signer (libhsmd) supports, +# so a newer release that has landed in the manifest but for which we are +# still building client/signer support (e.g. v26.06gl1) is never picked up +# automatically. The ``glN`` suffix is ignored when comparing, so the signer +# reporting ``v25.12`` matches ``v25.12gl1``. +LOWEST_SUPPORTED_VERSION = "v23.08" +HIGHEST_SUPPORTED_VERSION = glclient.__version__ + + @pytest.fixture(autouse=True) def paths(): """A fixture to ensure that we have all CLN versions and that @@ -47,14 +60,15 @@ def paths(): """ vm = ClnVersionManager() - versions = vm.get_versions() # Should be a no-op after the first run vm.get_all() - latest = [v for v in versions if "gl" in v.tag][-1] + latest = vm.latest_supported( + LOWEST_SUPPORTED_VERSION, HIGHEST_SUPPORTED_VERSION + ) - os.environ["PATH"] += f":{vm.get_target_path(latest) / 'usr' / 'local' / 'bin'}" + os.environ["PATH"] += f":{latest.bin_path}" yield @@ -187,7 +201,9 @@ def cln_path() -> Path: https://en.wikipedia.org/wiki/Elephant_in_Cairo """ manager = ClnVersionManager() - v = manager.latest() + v = manager.latest_supported( + LOWEST_SUPPORTED_VERSION, HIGHEST_SUPPORTED_VERSION + ) os.environ["PATH"] += f":{v.bin_path}" return v.bin_path From c13c79663e9b37a94a67cd769a3b44d6f2f4694e Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Mon, 29 Jun 2026 16:27:34 +0200 Subject: [PATCH 2/4] ci: retrigger CI No functional change; empty commit to re-run the pipeline. Co-Authored-By: Claude Opus 4.8 (1M context) From cac7012c41305cbead24b18d3ea365f195d938f2 Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Mon, 29 Jun 2026 14:22:58 +0200 Subject: [PATCH 3/4] style(gl-plugin): apply rustfmt formatting Whitespace-only cleanup across gl-plugin (import ordering, line wrapping, trailing commas, struct field alignment). No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/gl-plugin/src/context.rs | 2 +- libs/gl-plugin/src/messages.rs | 10 ++++++---- libs/gl-plugin/src/node/mod.rs | 28 +++++++++++----------------- libs/gl-plugin/src/responses.rs | 7 +++---- libs/gl-plugin/src/stager.rs | 6 +++++- 5 files changed, 26 insertions(+), 27 deletions(-) diff --git a/libs/gl-plugin/src/context.rs b/libs/gl-plugin/src/context.rs index 48692fc56..fc1cfec8d 100644 --- a/libs/gl-plugin/src/context.rs +++ b/libs/gl-plugin/src/context.rs @@ -10,9 +10,9 @@ //! sign off actually match the authentic commands by a valid //! caller. +use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::Mutex; -use serde::{Serialize, Deserialize}; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Request { diff --git a/libs/gl-plugin/src/messages.rs b/libs/gl-plugin/src/messages.rs index f5bec0843..571744964 100644 --- a/libs/gl-plugin/src/messages.rs +++ b/libs/gl-plugin/src/messages.rs @@ -280,7 +280,7 @@ where /// `peer_connected` hook. #[derive(Serialize, Deserialize, Debug)] pub struct PeerConnectedCall { - pub peer: Peer + pub peer: Peer, } #[derive(Serialize, Deserialize, Debug)] @@ -295,10 +295,9 @@ pub struct Peer { #[serde(rename_all = "snake_case")] pub enum Direction { In, - Out + Out, } - #[cfg(test)] mod test { use super::*; @@ -315,7 +314,10 @@ mod test { }); let call = serde_json::from_str::(&msg.to_string()).unwrap(); - assert_eq!(call.peer.id, "03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f"); + assert_eq!( + call.peer.id, + "03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f" + ); assert_eq!(call.peer.direction, Direction::In); assert_eq!(call.peer.addr, "34.239.230.56:9735"); assert_eq!(call.peer.features, ""); diff --git a/libs/gl-plugin/src/node/mod.rs b/libs/gl-plugin/src/node/mod.rs index b6e68c465..2aa6031d8 100644 --- a/libs/gl-plugin/src/node/mod.rs +++ b/libs/gl-plugin/src/node/mod.rs @@ -7,10 +7,8 @@ use anyhow::{Context, Error, Result}; use base64::{engine::general_purpose, Engine as _}; use bytes::BufMut; use cln_rpc::Notification; +use gl_client::metrics::{savings_percent, signer_state_request_wire_bytes}; use gl_client::persist::{State, StateSketch}; -use gl_client::metrics::{ - signer_state_request_wire_bytes, savings_percent, -}; use governor::{ clock::MonotonicClock, state::direct::NotKeyed, state::InMemoryState, Quota, RateLimiter, }; @@ -230,14 +228,12 @@ impl Node for PluginNodeServer { // We require capacity + 5% buffer to account for fees and routing. // Only check for specific amounts (not "any" amount invoices). if req.amount_msat > 0 { - let receivable = self - .get_receivable_capacity(&mut rpc) - .await - .unwrap_or(0); + let receivable = self.get_receivable_capacity(&mut rpc).await.unwrap_or(0); // Add 5% buffer: capacity >= amount * 1.05 // Equivalent to: capacity * 100 >= amount * 105 - let has_sufficient_capacity = req.amount_msat + let has_sufficient_capacity = req + .amount_msat .saturating_mul(105) .checked_div(100) .map(|required| receivable >= required) @@ -274,7 +270,10 @@ impl Node for PluginNodeServer { bolt11: res.bolt11, created_index: res.created_index.unwrap_or(0) as u32, expires_at: res.expires_at as u32, - payment_hash: >::borrow(&res.payment_hash).to_vec(), + payment_hash: >::borrow( + &res.payment_hash, + ) + .to_vec(), payment_secret: res.payment_secret.to_vec(), opening_fee_msat: 0, })); @@ -446,9 +445,8 @@ impl Node for PluginNodeServer { // the large state with them. let state_snapshot = signer_state.lock().await.clone(); - let state_entries: Vec = state_snapshot - .omit_tombstones() - .into(); + let state_entries: Vec = + state_snapshot.omit_tombstones().into(); let state_wire_bytes = signer_state_request_wire_bytes(&state_entries); let state_entries: Vec = state_entries .into_iter() @@ -501,7 +499,6 @@ impl Node for PluginNodeServer { hsm_id ); - let state_snapshot = signer_state.lock().await.clone(); // Estimate the size of the full state to calculate the bandwidth savings of sending diffs let full_entries: Vec = @@ -754,10 +751,7 @@ impl Node for PluginNodeServer { if let Err(e) = address.require_network(network) { return Err(Status::new( Code::Unknown, - format!( - "Network validation failed: {}", - e - ), + format!("Network validation failed: {}", e), )); } } diff --git a/libs/gl-plugin/src/responses.rs b/libs/gl-plugin/src/responses.rs index 556f89e6f..913ac44ed 100644 --- a/libs/gl-plugin/src/responses.rs +++ b/libs/gl-plugin/src/responses.rs @@ -349,8 +349,7 @@ pub struct InvoiceResponse { #[derive(Debug, Clone, Deserialize)] pub struct LspGetinfoResponse { -pub opening_fee_params_menu: Vec, - + pub opening_fee_params_menu: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] // LSPS2 requires the client to fail if a field is unrecognized. @@ -360,8 +359,8 @@ pub struct OpeningFeeParams { pub valid_until: String, pub min_lifetime: u32, pub max_client_to_self_delay: u32, - pub min_payment_size_msat: String , - pub max_payment_size_msat: String , + pub min_payment_size_msat: String, + pub max_payment_size_msat: String, pub promise: String, // Max 512 bytes } diff --git a/libs/gl-plugin/src/stager.rs b/libs/gl-plugin/src/stager.rs index 8c21a87ef..05ecbc697 100644 --- a/libs/gl-plugin/src/stager.rs +++ b/libs/gl-plugin/src/stager.rs @@ -124,7 +124,11 @@ impl Stage { .filter_map(|r| { let head: [u16; 2] = [r.request.raw[0].into(), r.request.raw[1].into()]; let typ = head[0] << 8 | head[1]; - if sticky_types.contains(&typ) { Some(typ) } else { None } + if sticky_types.contains(&typ) { + Some(typ) + } else { + None + } }) .collect(); From 4d07aa1689fb49014b74264a9e7f01a73fa3ea66 Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Mon, 29 Jun 2026 14:26:24 +0200 Subject: [PATCH 4/4] fix(gl-plugin): skip LSPs returning empty opening params for LSPS2 An LSP could be selected for LSPS2 invoice negotiation even when it returned an empty opening-fee-params set, leaving us without valid opening params. Extract the selection into `select_opening_params`, which flattens params across all LSPs and picks the first available pair, so an LSP with no params is skipped in favor of the next LSP that returned usable params. Returns an error only when no LSP returned any params at all. Add unit tests covering the empty, first-LSP-empty, all-empty, and first-non-empty cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/gl-plugin/src/node/mod.rs | 123 ++++++++++++++++++++++++++++++--- 1 file changed, 114 insertions(+), 9 deletions(-) diff --git a/libs/gl-plugin/src/node/mod.rs b/libs/gl-plugin/src/node/mod.rs index 2aa6031d8..56ca42194 100644 --- a/libs/gl-plugin/src/node/mod.rs +++ b/libs/gl-plugin/src/node/mod.rs @@ -299,35 +299,42 @@ impl Node for PluginNodeServer { Status::not_found("Could not retrieve LSPS peers for invoice negotiation.") })?; - if lsps.len() < 1 { + if lsps.is_empty() { return Err(Status::not_found( "Could not find an LSP peer to negotiate the LSPS2 channel for this invoice.", )); } - let lsp = &lsps[0]; - log::info!("Selecting {:?} for invoice negotiation", lsp); + let (lsp_id, param) = select_opening_params(lsps).ok_or_else(|| { + Status::not_found("No opening params returned by any LSP, cannot create invoice.") + })?; + + log::info!( + "Selecting LSP {} with params {:?} for invoice negotiation", + lsp_id, + param + ); // Compute the expected opening fee from the LSP's fee parameters. - let opening_fee_msat = lsp.params.first().map_or(0, |p| { - let min_fee: u64 = p.min_fee_msat.parse().unwrap_or(0); + let opening_fee_msat = { + let min_fee: u64 = param.min_fee_msat.parse().unwrap_or(0); let proportional_fee = req .amount_msat - .saturating_mul(p.proportional) + .saturating_mul(param.proportional) .div_ceil(1_000_000); std::cmp::max(min_fee, proportional_fee) - }); + }; // Use the new RPC method name for versions > v25.05gl1 let mut res = if *version > *"v25.05gl1" { let mut invreq: crate::requests::LspInvoiceRequestV2 = req.into(); - invreq.lsp_id = lsp.node_id.to_owned(); + invreq.lsp_id = lsp_id.to_owned(); rpc.call_typed(&invreq) .await .map_err(|e| Status::new(Code::Internal, e.to_string()))? } else { let mut invreq: crate::requests::LspInvoiceRequest = req.into(); - invreq.lsp_id = lsp.node_id.to_owned(); + invreq.lsp_id = lsp_id.to_owned(); rpc.call_typed(&invreq) .await .map_err(|e| Status::new(Code::Internal, e.to_string()))? @@ -837,6 +844,22 @@ struct Lsps2Offer { params: Vec, } +/// Select the LSP and opening fee params to use for an LSPS2 invoice +/// negotiation. +/// +/// We flatten the params across all LSPs and pick the first available +/// pair. This way an LSP that got selected but returned an empty param +/// set is skipped in favor of the next LSP that actually returned +/// usable params, rather than ending up without valid opening params. +/// Returns `None` if no LSP returned any params at all. +fn select_opening_params( + lsps: Vec, +) -> Option<(String, crate::responses::OpeningFeeParams)> { + lsps.into_iter() + .flat_map(|l| l.params.into_iter().map(move |p| (l.node_id.clone(), p))) + .next() +} + impl PluginNodeServer { pub async fn run(self) -> Result<()> { let addr = self.grpc_binding.parse().unwrap(); @@ -1210,3 +1233,85 @@ where mod rpcwait; pub use rpcwait::RpcWaitService; + +#[cfg(test)] +mod test { + use super::*; + use crate::responses::OpeningFeeParams; + + fn param(min_fee_msat: &str) -> OpeningFeeParams { + OpeningFeeParams { + min_fee_msat: min_fee_msat.to_string(), + proportional: 0, + valid_until: "2100-01-01T00:00:00Z".to_string(), + min_lifetime: 144, + max_client_to_self_delay: 1024, + min_payment_size_msat: "0".to_string(), + max_payment_size_msat: "1000000000".to_string(), + promise: "promise".to_string(), + } + } + + #[test] + fn test_select_opening_params_empty() { + // No LSPs at all -> nothing to select. + assert!(select_opening_params(vec![]).is_none()); + } + + #[test] + fn test_select_opening_params_first_lsp_empty() { + // The first LSP got selected but returned an empty param set. + // We must skip it and fall back to the next LSP that actually + // returned usable params. + let lsps = vec![ + Lsps2Offer { + node_id: "lsp_empty".to_string(), + params: vec![], + }, + Lsps2Offer { + node_id: "lsp_good".to_string(), + params: vec![param("100")], + }, + ]; + + let (lsp_id, p) = select_opening_params(lsps).expect("should fall back to second LSP"); + assert_eq!(lsp_id, "lsp_good"); + assert_eq!(p.min_fee_msat, "100"); + } + + #[test] + fn test_select_opening_params_all_empty() { + // Every LSP returned an empty param set -> nothing valid to pick. + let lsps = vec![ + Lsps2Offer { + node_id: "lsp_empty_1".to_string(), + params: vec![], + }, + Lsps2Offer { + node_id: "lsp_empty_2".to_string(), + params: vec![], + }, + ]; + + assert!(select_opening_params(lsps).is_none()); + } + + #[test] + fn test_select_opening_params_prefers_first_nonempty() { + // When the first LSP does return params we keep using it. + let lsps = vec![ + Lsps2Offer { + node_id: "lsp_first".to_string(), + params: vec![param("1"), param("2")], + }, + Lsps2Offer { + node_id: "lsp_second".to_string(), + params: vec![param("3")], + }, + ]; + + let (lsp_id, p) = select_opening_params(lsps).expect("first LSP has params"); + assert_eq!(lsp_id, "lsp_first"); + assert_eq!(p.min_fee_msat, "1"); + } +}