Require the CLN v26.06 getroutes fields; refuse to start on older CLN
Some checks failed
Code Base Sanity Check / tests (push) Has been cancelled
Code Base Sanity Check / coverage (push) Has been cancelled
Code Base Sanity Check / build-clang (push) Has been cancelled

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.
This commit is contained in:
Ken Sedgwick 2026-07-02 14:08:15 -07:00
parent a18daeb65b
commit 04f6021551
No known key found for this signature in database
GPG key ID: DBD2AF0849D711A9
6 changed files with 269 additions and 89 deletions

View file

@ -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

View file

@ -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<void>{
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

View file

@ -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);
}

View file

@ -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<algorithm>
#include<assert.h>
#include<cstdio>
#include<set>
#include<sstream>
#include<stdlib.h>
@ -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<Net::Connector> 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<void> 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<void> 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<Secp256k1::SignerIF> 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<Secp256k1::SignerIF> 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<Msg::Manifestation
>([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;

View file

@ -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

View file

@ -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