AskreneLayer: extract shared helpers for clboss layer writes

Move the askrene-inform-channel and askrene-disable-node helpers
from Boss::Mod::FundsMover::Attempter::Impl (where they were
private member functions) into a new shared module
Boss::Mod::AskreneLayer.  Lift the "clboss" layer name from a
FundsMover-local constant into a shared constant.

Pure refactor, no behavioral change.  Existing tests still
pass (79/79).  Done now because PR4 (ActiveProber feeding the
same layer) will be the second producer; sharing avoids
duplicating the JSON construction, the layer-name string, and
the silent-RpcError-catch logic.

The new module exposes three free functions:

- inform_channel_constrained(rpc, layer, scid, dir, amount):
  same shape and semantics as Attempter's prior private method.
- inform_channel_succeeded(rpc, layer, scid, dir, amount): new
  variant emitting inform=succeeded instead of inform=constrained.
  Not used by FundsMover (sendpay failures only carry negative
  signals).  Added in this commit because the implementation is
  one line over the shared internal helper, and PR4's
  open-question may have ActiveProber call it on probe-success
  to record positive lower-bound observations on probed
  channels.
- disable_node(rpc, layer, node_id): same shape and semantics as
  Attempter's prior private method.

All three functions silently swallow RpcError, matching the
prior behavior in Attempter.  The rationale: if the layer is
unavailable (e.g. CLN < v24.11 where askrene layers do not
exist), the call gracefully degrades to no-op rather than
crashing the caller.  Better degraded learning than crashed
plugin.

The layer name "clboss" follows the xpay convention:
persistent shared-knowledge layer = owning plugin name.  All
CLBOSS-internal writers (FundsMover today, ActiveProber in
PR4, possibly others later) target the same single layer so
the accumulated knowledge benefits every downstream getroutes
call.
This commit is contained in:
Ken Sedgwick 2026-05-21 10:44:36 -07:00
parent 96dbec2d7d
commit 17ba012f9b
No known key found for this signature in database
GPG key ID: DBD2AF0849D711A9
5 changed files with 195 additions and 72 deletions

106
Boss/Mod/AskreneLayer.cpp Normal file
View file

@ -0,0 +1,106 @@
#include"Boss/Mod/AskreneLayer.hpp"
#include"Boss/Mod/Rpc.hpp"
#include"Ev/Io.hpp"
#include"Jsmn/Object.hpp"
#include"Json/Out.hpp"
#include"Util/stringify.hpp"
#include<assert.h>
namespace Boss { namespace Mod { namespace AskreneLayer {
std::string const clboss_layer_name = "clboss";
namespace {
/* Common machinery for the two inform_channel variants. askrene
* accepts inform=succeeded / constrained / unconstrained as the
* only difference between them; everything else (scid_dir,
* amount_msat, layer) is identical.
*/
Ev::Io<void>
inform_channel( Boss::Mod::Rpc& rpc
, std::string const& layer
, Ln::Scid scid
, std::uint32_t direction
, Ln::Amount amount
, char const* inform
) {
/* askrene only accepts direction 0 or 1 in
* short_channel_id_dir. All callers feed values from
* CLN's getroutes/sendpay responses, which are
* guaranteed to be 0/1, but guard explicitly: a bad
* direction would produce a syntactically valid but
* semantically wrong RPC param that askrene rejects, and
* the silent-swallow RpcError handler below would drop
* the learning update without a trace.
*/
assert(direction <= 1);
if (direction > 1)
return Ev::lift();
auto sdir = std::string(scid) + "/" + Util::stringify(direction);
auto parms = Json::Out()
.start_object()
.field("layer", layer)
.field("short_channel_id_dir", sdir)
.field("amount_msat", amount.to_msat())
.field("inform", std::string(inform))
.end_object()
;
return rpc.command( "askrene-inform-channel"
, std::move(parms)
).then([](Jsmn::Object _) {
return Ev::lift();
}).catching<RpcError>([](RpcError const&) {
/* Non-fatal -- if the layer is missing (e.g. layer-
* create failed at startup on CLN < v24.11),
* subsequent getroutes calls simply will not benefit
* from the constraint. Better to degrade learning
* than to crash the caller.
*/
return Ev::lift();
});
}
}
Ev::Io<void>
inform_channel_constrained( Boss::Mod::Rpc& rpc
, std::string const& layer
, Ln::Scid scid
, std::uint32_t direction
, Ln::Amount amount
) {
return inform_channel(rpc, layer, scid, direction, amount, "constrained");
}
Ev::Io<void>
inform_channel_unconstrained( Boss::Mod::Rpc& rpc
, std::string const& layer
, Ln::Scid scid
, std::uint32_t direction
, Ln::Amount amount
) {
return inform_channel(rpc, layer, scid, direction, amount, "unconstrained");
}
Ev::Io<void>
disable_node( Boss::Mod::Rpc& rpc
, std::string const& layer
, Ln::NodeId node
) {
auto parms = Json::Out()
.start_object()
.field("layer", layer)
.field("node", std::string(node))
.end_object()
;
return rpc.command( "askrene-disable-node"
, std::move(parms)
).then([](Jsmn::Object _) {
return Ev::lift();
}).catching<RpcError>([](RpcError const&) {
return Ev::lift();
});
}
}}}

74
Boss/Mod/AskreneLayer.hpp Normal file
View file

@ -0,0 +1,74 @@
#ifndef BOSS_MOD_ASKRENELAYER_HPP_
#define BOSS_MOD_ASKRENELAYER_HPP_
#include"Ev/Io.hpp"
#include"Ln/Amount.hpp"
#include"Ln/NodeId.hpp"
#include"Ln/Scid.hpp"
#include<cstdint>
#include<string>
namespace Boss { namespace Mod { class Rpc; } }
namespace Boss { namespace Mod { namespace AskreneLayer {
/* Name of the persistent askrene layer that CLBOSS subsystems
* write failure-feedback and (optionally) success-observations
* into. Following the xpay convention -- the layer is named
* after the plugin that owns it. All CLBOSS writes go to this
* layer; downstream getroutes calls that include it in their
* layers array benefit from the accumulated knowledge.
*/
extern std::string const clboss_layer_name;
/* Tell askrene that a directed channel could not push at least
* the given amount recently. Future getroutes calls that
* include the clboss layer will treat this as an upper-bound
* constraint and steer around it. Non-fatal on RpcError: if
* the layer is missing (e.g. CLN < v24.11 where askrene layers
* are unavailable) the call silently completes -- failure-mode
* is degraded learning, not crashed caller.
*/
Ev::Io<void> inform_channel_constrained( Boss::Mod::Rpc& rpc
, std::string const& layer
, Ln::Scid scid
, std::uint32_t direction
, Ln::Amount amount
);
/* Tell askrene that a directed channel successfully pushed at
* least the given amount recently. Future getroutes calls
* that include the layer will treat this as a lower-bound on
* the channel's capacity (min=amount, max=NULL). Non-fatal
* on RpcError, same rationale as inform_channel_constrained.
*
* Naming note: this maps to askrene's `inform=unconstrained`
* mode (which means "no upper-bound; minimum is amount").
* Askrene also has an `inform=succeeded` mode but as of
* v26.06 that branch is a no-op stub in askrene.c
* (`FIXME: We could do something useful here!`). xpay uses
* `inform=unconstrained` for the same after-success
* lower-bound-raise pattern (see plugins/xpay/xpay.c
* around `"We learned something about prior nodes"`).
*/
Ev::Io<void> inform_channel_unconstrained( Boss::Mod::Rpc& rpc
, std::string const& layer
, Ln::Scid scid
, std::uint32_t direction
, Ln::Amount amount
);
/* Tell askrene to avoid the given node entirely for routes
* that include this layer. Used for NODE-level onion failures
* (failcode bit 0x2000) and for unparsable-onion fallback,
* where we cannot pin the failure to a specific channel.
* Non-fatal on RpcError.
*/
Ev::Io<void> disable_node( Boss::Mod::Rpc& rpc
, std::string const& layer
, Ln::NodeId node
);
}}}
#endif /* !defined(BOSS_MOD_ASKRENELAYER_HPP_) */

View file

@ -1,3 +1,4 @@
#include"Boss/Mod/AskreneLayer.hpp"
#include"Boss/Mod/FundsMover/Attempter.hpp"
#include"Boss/Mod/FundsMover/create_label.hpp"
#include"Boss/Mod/Rpc.hpp"
@ -16,22 +17,6 @@
#include<string>
#include<vector>
namespace {
/* Name of the persistent askrene layer that holds CLBOSS's
* failure-feedback knowledge -- channel capacity constraints
* (askrene-inform-channel) and node-level exclusions
* (askrene-disable-node) accumulated across attempts. Created
* idempotently at startup by Boss::Mod::FundsMover::Main.
*
* Naming follows the convention established by plugins/xpay
* (which uses "xpay"): the persistent shared-knowledge layer is
* named after the plugin that owns it.
*/
auto const clboss_layer = std::string("clboss");
}
namespace Boss { namespace Mod { namespace FundsMover {
class Attempter::Impl : public std::enable_shared_from_this<Impl> {
@ -175,7 +160,7 @@ private:
.start_array("layers")
.entry("auto.localchans")
.entry("auto.sourcefree")
.entry(clboss_layer)
.entry(Boss::Mod::AskreneLayer::clboss_layer_name)
.end_array()
.field("maxfee_msat",
route_maxfee.to_msat())
@ -397,58 +382,6 @@ private:
});
}
/* Tell askrene about a failed-channel constraint so future
* getroutes calls steer around it. Recorded into the
* persistent "clboss" layer; benefits all subsequent attempts
* and (when they migrate to consume the same layer) other
* CLBOSS subsystems too.
*/
Ev::Io<void> inform_channel_constrained( Ln::Scid scid
, int dir
, Ln::Amount at
) {
auto sdir = std::string(scid) + "/" + Util::stringify(dir);
auto parms = Json::Out()
.start_object()
.field("layer", clboss_layer)
.field("short_channel_id_dir", sdir)
.field("amount_msat", at.to_msat())
.field("inform", "constrained")
.end_object()
;
return rpc.command( "askrene-inform-channel"
, std::move(parms)
).then([](Jsmn::Object _) {
return Ev::lift();
}).catching<RpcError>([](RpcError const&) {
/* Non-fatal -- if the layer is missing
* (e.g. layer-create failed at startup on a
* CLN < v24.11), subsequent getroutes calls
* simply will not benefit from the constraint.
*/
return Ev::lift();
});
}
/* Tell askrene to avoid a node entirely; same persistent
* layer. Used for NODE-level onion failures (failcode bit
* 0x2000) and for unparsable-onion fallback.
*/
Ev::Io<void> disable_node(Ln::NodeId node) {
auto parms = Json::Out()
.start_object()
.field("layer", clboss_layer)
.field("node", std::string(node))
.end_object()
;
return rpc.command( "askrene-disable-node"
, std::move(parms)
).then([](Jsmn::Object _) {
return Ev::lift();
}).catching<RpcError>([](RpcError const&) {
return Ev::lift();
});
}
Ev::Io<void> sendpay() {
auto payment_hash = std::make_shared<Sha256::Hash>();
@ -612,7 +545,11 @@ private:
;
/* 0x2000 == NODE level error. */
if ((fail & 0x2000))
feedback = disable_node(enode);
feedback = Boss::Mod::AskreneLayer::disable_node(
rpc,
Boss::Mod::AskreneLayer::clboss_layer_name,
enode
);
else
/* The amount to pass to askrene-inform-channel
* is the HTLC amount attempted on the specific
@ -623,7 +560,9 @@ private:
* [1, route.size()] here and route[eidx - 1]
* describes the failing channel.
*/
feedback = inform_channel_constrained(
feedback = Boss::Mod::AskreneLayer::inform_channel_constrained(
rpc,
Boss::Mod::AskreneLayer::clboss_layer_name,
echan, edir,
route[eidx - 1].amount_msat
);

View file

@ -1,3 +1,4 @@
#include"Boss/Mod/AskreneLayer.hpp"
#include"Boss/Mod/FundsMover/Claimer.hpp"
#include"Boss/Mod/FundsMover/Main.hpp"
#include"Boss/Mod/FundsMover/Runner.hpp"
@ -133,7 +134,8 @@ private:
return Ev::lift().then([this]() {
auto parms = Json::Out()
.start_object()
.field("layer", "clboss")
.field("layer",
Boss::Mod::AskreneLayer::clboss_layer_name)
.field("persistent", true)
.end_object()
;

View file

@ -69,6 +69,8 @@ libclboss_la_SOURCES = \
Boss/Mod/ActiveProber.hpp \
Boss/Mod/AmountSettingsHandler.cpp \
Boss/Mod/AmountSettingsHandler.hpp \
Boss/Mod/AskreneLayer.cpp \
Boss/Mod/AskreneLayer.hpp \
Boss/Mod/AutoDisconnector.cpp \
Boss/Mod/AutoDisconnector.hpp \
Boss/Mod/AvailableRpcCommandsAnnouncer.cpp \