From 04f60215511f3f4c8f8807b780e40ae694d77f1f Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Thu, 2 Jul 2026 14:08:15 -0700 Subject: [PATCH] Require the CLN v26.06 getroutes fields; refuse to start on older CLN The pre-v26.06 getroutes compatibility fallback read the deprecated per-hop fields (next_node_id / amount_msat / delay), whose values CLN defines as the in-side of each hop -- one hop shifted from the out-side values (node_id_out / amount_out_msat / cltv_out, shipped in v26.06) that sendpay routes must be built from. On stock CLN v24.11..v26.04 the fallback therefore built mispriced routes: small overpays on the routes that survived, spurious FEE_INSUFFICIENT / INCORRECT_CLTV_EXPIRY failures on the rest, and -- worst -- those failures hard-excluded healthy channels in the persistent failure-learning layer for hours, compounding across restarts. Drop the fallback entirely and enforce the requirement twice: - Initiator gains a CLN version gate, ordered before the database and signer steps so a refusal leaves no on-disk trace: a getinfo version older than v26.06 logs a detailed Error and aborts. The new clboss-skip-cln-version-check flag bypasses the gate for operators whose older CLN carries a backport of the v26.06 getroutes fields (the version string alone cannot show that); an unparseable version string warns and proceeds rather than locking out custom builds. - The three getroutes parse sites (FundsMover/Attempter, ActiveProber, ChannelCandidateMatchmaker) verify the new fields on every response and fail the attempt with a clear Error if absent, so a mistakenly bypassed gate degrades into loud per-attempt failures rather than shifted routes. CHANGELOG.md gains a prominent BREAKING entry; README documents the requirement and the escape hatch. Users on older CLN releases stay on CLBOSS 0.16.x, which uses the legacy getroute/pay APIs those versions still provide. --- Boss/Mod/ActiveProber.cpp | 59 +++++---- Boss/Mod/ChannelCandidateMatchmaker.cpp | 33 +++-- Boss/Mod/FundsMover/Attempter.cpp | 66 ++++++---- Boss/Mod/Initiator.cpp | 165 ++++++++++++++++++++---- CHANGELOG.md | 20 +++ README.md | 15 +++ 6 files changed, 269 insertions(+), 89 deletions(-) diff --git a/Boss/Mod/ActiveProber.cpp b/Boss/Mod/ActiveProber.cpp index e9d3f6f..53832a8 100644 --- a/Boss/Mod/ActiveProber.cpp +++ b/Boss/Mod/ActiveProber.cpp @@ -293,30 +293,39 @@ private: return rpc.command("getroutes", std::move(pj)); }).then([this](Jsmn::Object res) { try { - /* getroutes path[] hop fields were renamed - * in CLN v26.06. Which names actually get - * emitted depends on the CLN version AND - * whether the node runs with developer - * mode (which suppresses deprecated - * outputs): - * - * v26.04 -> old names only - * v26.06+ no developer -> both names emitted - * v26.06+ developer=true -> new names only - * - * Bridge by preferring the new name, falling - * back to the old. TODO: drop the fallback - * once CLN v26.04 is no longer supported and - * the old names are gone for good in v27.06. - * - * short_channel_id_dir is older (v24.11) - * and is emitted unconditionally. + /* getroutes path[] hop fields node_id_out / + * amount_out_msat / cltv_out shipped in CLN + * v26.06 (short_channel_id_dir is older, + * v24.11, and unconditional). The + * deprecated pre-v26.06 names carry + * one-hop-shifted in-side values that must + * never feed a sendpay route, so there is + * deliberately NO fallback: the Initiator + * version gate refuses stock older CLN at + * startup, and this check keeps a bypassed + * gate (clboss-skip-cln-version-check) loud + * instead of subtly wrong. */ auto path0 = res["routes"][0]["path"][0]; + if ( !path0.has("node_id_out") + || !path0.has("amount_out_msat") + || !path0.has("cltv_out") + ) { + if (!to_try.empty()) + to_try.pop(); + return Boss::log( bus, Error + , "ActiveProber: getroutes " + "hop lacks node_id_out/" + "amount_out_msat/cltv_out; " + "CLBOSS requires CLN " + "v26.06+ (or a backport of " + "those getroutes fields)." + ).then([]() { + return Ev::lift(false); + }); + } id1 = Ln::NodeId(std::string( - path0.has("node_id_out") - ? path0["node_id_out"] - : path0["next_node_id"] + path0["node_id_out"] )); /* short_channel_id_dir is "SCID/dir"; split * into the SCID and the direction. If @@ -337,14 +346,10 @@ private: sdir.substr(slash + 1) )); amount1 = Ln::Amount::object( - path0.has("amount_out_msat") - ? path0["amount_out_msat"] - : path0["amount_msat"] + path0["amount_out_msat"] ); delay1 = std::uint32_t(double( - path0.has("cltv_out") - ? path0["cltv_out"] - : path0["delay"] + path0["cltv_out"] )); } catch (std::exception const& _) { /* Broaden catch to std::exception so we also diff --git a/Boss/Mod/ChannelCandidateMatchmaker.cpp b/Boss/Mod/ChannelCandidateMatchmaker.cpp index da0e54e..3dc7961 100644 --- a/Boss/Mod/ChannelCandidateMatchmaker.cpp +++ b/Boss/Mod/ChannelCandidateMatchmaker.cpp @@ -128,20 +128,31 @@ private: ).then([this](Jsmn::Object res) { auto patron = Ln::NodeId(); try { - /* getroutes' path[K].node_id_out was added - * v26.06; the deprecated old name is - * next_node_id, kept through v27.06 except - * when developer mode suppresses - * deprecated outputs. Prefer the new - * name, fall back to the old. TODO: drop - * the fallback once v26.04 is no longer - * supported. + /* getroutes' path[K].node_id_out shipped in + * CLN v26.06. No fallback to the + * deprecated next_node_id: the Initiator + * version gate refuses stock older CLN at + * startup, and this check keeps a bypassed + * gate (clboss-skip-cln-version-check) + * loud instead of subtly wrong. */ auto path0 = res["routes"][0]["path"][0]; + if (!path0.has("node_id_out")) + return Boss::log( bus, Error + , "ChannelCandidateMatchmaker: " + "getroutes hop lacks " + "node_id_out; CLBOSS " + "requires CLN v26.06+ (or " + "a backport of the " + "getroutes fields)." + ).then([]() + -> Ev::Io{ + throw RpcError( "getroutes" + , Jsmn::Object() + ); + }); patron = Ln::NodeId(std::string( - path0.has("node_id_out") - ? path0["node_id_out"] - : path0["next_node_id"] + path0["node_id_out"] )); } catch (std::exception const&) { /* Jsmn::TypeError from the field access OR diff --git a/Boss/Mod/FundsMover/Attempter.cpp b/Boss/Mod/FundsMover/Attempter.cpp index ca1b99c..d4045cd 100644 --- a/Boss/Mod/FundsMover/Attempter.cpp +++ b/Boss/Mod/FundsMover/Attempter.cpp @@ -637,33 +637,47 @@ private: if (!path.is_array() || path.size() == 0) throw Jsmn::TypeError(); - /* getroutes path[] hop fields were renamed - * in CLN v26.06. Which set of names - * actually appears in the response - * depends on the CLN version AND whether - * the node runs with developer mode - * (which suppresses deprecated outputs): - * - * v26.04 -> old only - * v26.06+ no developer -> both emitted - * v26.06+ developer=true -> new only - * - * Bridge by preferring the new name, - * falling back to the old. TODO: drop - * the fallback once CLN v26.04 is no - * longer supported and the old names are - * removed in v27.06. - * - * short_channel_id_dir is older (v24.11) - * and is emitted unconditionally. + /* getroutes path[] hop fields node_id_out / + * amount_out_msat / cltv_out shipped in + * CLN v26.06 (short_channel_id_dir is + * older, v24.11, and unconditional). The + * deprecated pre-v26.06 names carry + * one-hop-shifted in-side values that + * must never be spliced into a sendpay + * route, so there is deliberately NO + * fallback: the Initiator version gate + * refuses stock older CLN at startup, and + * this per-response check keeps a + * bypassed gate (clboss-skip-cln-version- + * check) loud instead of subtly wrong. */ route.clear(); for (auto hop_j : path) { + if ( !hop_j.has("node_id_out") + || !hop_j.has("amount_out_msat") + || !hop_j.has("cltv_out") + ) { + return Boss::log( bus, Error + , "FundsMover[%s]: " + "getroutes hop lacks " + "node_id_out/" + "amount_out_msat/" + "cltv_out; CLBOSS " + "requires CLN v26.06+ " + "(or a backport of " + "those getroutes " + "fields). Refusing to " + "build a route from " + "the deprecated " + "shifted fields." + , attempt_tag().c_str() + ).then([]() { + return Ev::lift(false); + }); + } Hop hop; hop.id = Ln::NodeId(std::string( - hop_j.has("node_id_out") - ? hop_j["node_id_out"] - : hop_j["next_node_id"] + hop_j["node_id_out"] )); auto sdir = std::string( hop_j["short_channel_id_dir"] @@ -678,14 +692,10 @@ private: std::stoul(sdir.substr(slash + 1)) ); hop.amount_msat = Ln::Amount::object( - hop_j.has("amount_out_msat") - ? hop_j["amount_out_msat"] - : hop_j["amount_msat"] + hop_j["amount_out_msat"] ); hop.delay = std::uint32_t(double( - hop_j.has("cltv_out") - ? hop_j["cltv_out"] - : hop_j["delay"] + hop_j["cltv_out"] )); route.push_back(hop); } diff --git a/Boss/Mod/Initiator.cpp b/Boss/Mod/Initiator.cpp index 192b18a..cfcb4ad 100644 --- a/Boss/Mod/Initiator.cpp +++ b/Boss/Mod/Initiator.cpp @@ -7,6 +7,7 @@ #include"Boss/Msg/EndOfOptions.hpp" #include"Boss/Msg/Init.hpp" #include"Boss/Msg/ManifestOption.hpp" +#include"Boss/Msg/Manifestation.hpp" #include"Boss/Msg/Option.hpp" #include"Boss/Msg/ProvideStatus.hpp" #include"Boss/Msg/SolicitStatus.hpp" @@ -29,6 +30,7 @@ #include"Util/make_unique.hpp" #include #include +#include #include #include #include @@ -68,6 +70,9 @@ private: std::string proxy; bool always_use_proxy; + /* Set from the clboss-skip-cln-version-check flag at init; + * see check_cln_version() below. */ + bool skip_version_check; std::unique_ptr connector; Secp256k1::Random random; @@ -109,6 +114,85 @@ private: }); } + /* The getroutes hop fields CLBOSS builds sendpay routes from + * (node_id_out / amount_out_msat / cltv_out) shipped in CLN + * v26.06. Older stock CLN emits only the deprecated names, + * whose values are one-hop-shifted (in-side): routes built + * from them misprice every middle hop, and the resulting + * failures hard-exclude healthy channels in the persistent + * failure-learning layer -- strictly worse than not running + * at all. Refuse to start instead. + * + * clboss-skip-cln-version-check bypasses the gate for + * operators whose older CLN carries a backport of the v26.06 + * getroutes fields (the version string alone cannot show + * that); the getroutes parse sites still verify the fields on + * every response, so a mistaken bypass fails loudly per + * attempt instead of building shifted routes. An unparseable + * version string is treated the same fail-open way -- custom + * builds deserve a warning, not a lockout. + */ + Ev::Io check_cln_version(Jsmn::Object info) { + auto version = std::string(); + if (info.has("version") && info["version"].is_string()) + version = std::string(info["version"]); + if (skip_version_check) + return Boss::log( bus, Info + , "Initiator: clboss-skip-cln-" + "version-check set; not enforcing " + "the CLN v26.06 minimum against " + "\"%s\"." + , version.c_str() + ); + auto major = unsigned(0); + auto minor = unsigned(0); + if (std::sscanf(version.c_str(), "v%u.%u", &major, &minor) != 2) + return Boss::log( bus, Warn + , "Initiator: unrecognized CLN " + "version \"%s\"; proceeding -- the " + "getroutes parse verifies the " + "required v26.06 fields on every " + "response." + , version.c_str() + ); + if (major > 26 || (major == 26 && minor >= 6)) + return Ev::lift(); + return refuse_to_start( std::string("Initiator: CLN ") + + version + + " is older than v26.06: its getroutes " + "lacks the node_id_out/" + "amount_out_msat/cltv_out fields " + "CLBOSS builds routes from, and the " + "deprecated fields carry one-hop-" + "shifted values that would misroute " + "rebalances and poison the failure-" + "learning layer. Refusing to start; " + "no state was created or modified. " + "Upgrade CLN to v26.06 or newer, or " + "-- only if your CLN carries a " + "backport of the v26.06 getroutes " + "fields -- start with " + "clboss-skip-cln-version-check." + ); + } + /* Same log-flush-then-abort dance as error() above: give the + * output machinery a chance to push the Error line to + * lightningd before the process exits. */ + Ev::Io refuse_to_start(std::string reason) { + return Boss::log( bus, Boss::Error + , "%s" + , reason.c_str() + ).then([]() { + auto act = Ev::lift(); + for (auto i = 0; i < 32; ++i) + act += Ev::yield(); + return act; + }).then([]() { + abort(); + return Ev::lift(); + }); + } + void setup_proxy(std::string proxy) { auto host = std::string(); auto port = int(); @@ -144,6 +228,7 @@ public: , initted(false) , proxy("") , always_use_proxy(false) + , skip_version_check(false) { assert(open_rpc_socket); @@ -256,28 +341,6 @@ public: return Boss::log( bus, Debug , "RPC socket opened." ); - }).then([this]() { - db = Sqlite3::Db("data.clboss"); - return db.transact(); - }).then([this](Sqlite3::Tx tx) { - tx.query_execute("PRAGMA application_id = 0x424F5353;"); - tx.query_execute("PRAGMA user_version = 0x2020434C;"); - tx.commit(); - return Boss::log( bus, Debug - , "Database file opened." - ); - }).then([this]() { - return bus.raise(Msg::DbResource{db}); - }).then([this]() { - return Boss::Signer( "keys.clboss" - , random - , db - ).construct(); - }).then([this](std::unique_ptr n_signer) { - signer = std::move(n_signer); - return Boss::log( bus, Debug - , "Privkey file loaded." - ); }).then([this]() { return rpc->command( "getinfo" , Json::Out::empty_object() @@ -310,7 +373,34 @@ public: Net::DirectConnector >(); - return Ev::lift(); + /* CLN version gate. Deliberately ahead + * of the database and signer steps below: + * a refusal must leave no on-disk trace + * (no data.clboss, no schema, no + * keys.clboss). */ + return check_cln_version(info); + }).then([this]() { + db = Sqlite3::Db("data.clboss"); + return db.transact(); + }).then([this](Sqlite3::Tx tx) { + tx.query_execute("PRAGMA application_id = 0x424F5353;"); + tx.query_execute("PRAGMA user_version = 0x2020434C;"); + tx.commit(); + return Boss::log( bus, Debug + , "Database file opened." + ); + }).then([this]() { + return bus.raise(Msg::DbResource{db}); + }).then([this]() { + return Boss::Signer( "keys.clboss" + , random + , db + ).construct(); + }).then([this](std::unique_ptr n_signer) { + signer = std::move(n_signer); + return Boss::log( bus, Debug + , "Privkey file loaded." + ); }).then([this]() { return rpc->command( "listconfigs" , Json::Out::empty_object() @@ -407,6 +497,24 @@ public: options.insert(o.name); return Ev::lift(); }); + + bus.subscribe([this](Msg::Manifestation const&) { + return bus.raise(Msg::ManifestOption{ + "clboss-skip-cln-version-check", + Msg::OptionType_Flag, + Json::Out::direct(false), + "Skip the CLN >= v26.06 startup check. ONLY " + "for CLN builds older than v26.06 that carry " + "a backport of the v26.06 getroutes fields " + "(node_id_out/amount_out_msat/cltv_out). On " + "a stock older CLN, CLBOSS would build " + "mispriced routes and poison its failure-" + "learning layer; every getroutes response is " + "verified even with this set.", + false + }); + }); } private: @@ -423,6 +531,17 @@ private: if (!options_j.has(o)) continue; auto value = options_j[o]; + /* Stashed directly rather than via a Msg::Option + * subscription: the version gate consults it + * before most modules are even listening, and + * Initiator itself owns the option. */ + if (o == "clboss-skip-cln-version-check") { + if (value.is_boolean()) + skip_version_check = !!value; + else if (value.is_string()) + skip_version_check = + std::string(value) == "true"; + } rv += bus.raise(Msg::Option{o, std::move(value)}); } return rv; diff --git a/CHANGELOG.md b/CHANGELOG.md index 41e82bc..bab0b6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [Unreleased] + +### Changed + +- **BREAKING**: CLBOSS now requires **Core Lightning v26.06 or later**. + The rebalancer and probing subsystems build routes from the + `getroutes` per-hop fields `node_id_out` / `amount_out_msat` / + `cltv_out`, which shipped in v26.06; the deprecated pre-v26.06 + fields carry one-hop-shifted values that would misprice routes and + poison CLBOSS's failure-learning askrene layer. CLBOSS checks the + CLN version at startup and refuses to run on older nodes, before + creating or modifying any on-disk state (note: with + `important-plugin`, a refused start stops lightningd itself). + Operators running an older CLN that carries a backport of the + v26.06 `getroutes` fields can bypass the startup check with + `--clboss-skip-cln-version-check`; every `getroutes` response is + still verified even with the check skipped. Users on older CLN + releases should stay on CLBOSS 0.16.x, which uses the legacy + `getroute`/`pay` APIs that older CLN still provides. + ## [0.16.0] - 2026-04-21: "Darkness on the Edge of the Mempool" ### Added diff --git a/README.md b/README.md index b37831a..749a288 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,21 @@ further. Installing ---------- +### Requirements + +CLBOSS requires **Core Lightning v26.06 or later**. Its rebalancer +and probing subsystems build routes from the `getroutes` per-hop +fields `node_id_out` / `amount_out_msat` / `cltv_out`, which shipped +in v26.06; the deprecated pre-v26.06 fields carry one-hop-shifted +values that would misprice routes and poison CLBOSS's +failure-learning askrene layer. CLBOSS checks the CLN version at +startup and refuses to run on older nodes — before creating or +modifying any on-disk state. If (and only if) your older CLN carries +a backport of the v26.06 `getroutes` fields, you can bypass the +startup check with `--clboss-skip-cln-version-check`; every +`getroutes` response is still verified even with the check skipped. +Users on older CLN releases should stay on CLBOSS 0.16.x. + From an [official source release](https://github.com/ZmnSCPxj/clboss/releases), just: ./configure && make