clboss/Boss/Mod/AskreneLayer.cpp
Ken Sedgwick 2115cab12f
AskreneLayer: new shared module for clboss askrene-layer writes
Introduces Boss::Mod::AskreneLayer, a small shared module of free
functions that wrap the askrene layer-mutation RPCs against a named
layer.  This is the common infrastructure that the classic rebalancer
(FundsMover) and ActiveProber write their routing feedback into; both
producers land in following commits.

The layer name follows the xpay convention -- the persistent
shared-knowledge layer is named after the owning plugin.
clboss_layer_name = "clboss"; every CLBOSS writer targets this single
layer so downstream getroutes calls that include it in their layers
array benefit from the accumulated knowledge.

Functions:

  - inform_channel_constrained / inform_channel_unconstrained:
    askrene-inform-channel with inform=constrained / =unconstrained,
    recording a recent could-not-push upper bound on a directed
    channel, or clearing it.
  - disable_node: askrene-disable-node, steering routes away from a
    node.
  - is_node_disabled: reads askrene-listlayers to test whether a node
    is already disabled in the layer (lets writers dedup).
  - update_channel: askrene-update-channel, applying an xpay-style
    channel_update refresh.

All writers silently swallow RpcError: if the layer is unavailable
(e.g. CLN without askrene layers) the call degrades to a no-op rather
than crashing the caller -- degraded learning beats a crashed plugin.

A unit test (tests/boss/test_askrene_layer) covers each helper's method
and JSON shape plus the silent-RpcError-swallow contract, using the
socketpair MockRpcServer pattern.
2026-08-04 11:01:43 -07:00

197 lines
5.4 KiB
C++

#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>
update_channel( Boss::Mod::Rpc& rpc
, std::string const& layer
, Ln::Scid scid
, std::uint32_t direction
, bool enabled
, Ln::Amount htlc_minimum_msat
, Ln::Amount htlc_maximum_msat
, Ln::Amount fee_base_msat
, std::uint32_t fee_proportional_millionths
, std::uint16_t cltv_expiry_delta
) {
/* Same direction-validity guard as inform_channel: askrene
* only accepts 0 or 1 in short_channel_id_dir.
*/
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("enabled", enabled)
.field("htlc_minimum_msat", htlc_minimum_msat.to_msat())
.field("htlc_maximum_msat", htlc_maximum_msat.to_msat())
.field("fee_base_msat", fee_base_msat.to_msat())
.field( "fee_proportional_millionths"
, fee_proportional_millionths
)
.field( "cltv_expiry_delta"
, cltv_expiry_delta
)
.end_object()
;
return rpc.command( "askrene-update-channel"
, std::move(parms)
).then([](Jsmn::Object _) {
return Ev::lift();
}).catching<RpcError>([](RpcError const&) {
return Ev::lift();
});
}
Ev::Io<bool>
is_node_disabled( Boss::Mod::Rpc& rpc
, std::string const& layer
, Ln::NodeId node
) {
auto target = std::string(node);
auto parms = Json::Out()
.start_object()
.field("layer", layer)
.end_object()
;
return rpc.command( "askrene-listlayers"
, std::move(parms)
).then([target = std::move(target)
](Jsmn::Object res) {
try {
auto layers = res["layers"];
if (!layers.is_array() || layers.size() == 0)
return Ev::lift(false);
auto layer_obj = layers[0];
if (!layer_obj.has("disabled_nodes"))
return Ev::lift(false);
auto disabled = layer_obj["disabled_nodes"];
if (!disabled.is_array())
return Ev::lift(false);
for (auto entry : disabled) {
if (std::string(entry) == target)
return Ev::lift(true);
}
} catch (std::exception const&) {
/* Malformed response shape -- fall through to
* false so the caller continues without
* deduping rather than crashing.
*/
}
return Ev::lift(false);
}).catching<RpcError>([](RpcError const&) {
/* Conservative on RPC error: returning false lets
* the caller fall through to its disable_node call
* (which also swallows RpcError). Worst case is
* an accumulating duplicate, same as the pre-dedup
* behaviour.
*/
return Ev::lift(false);
});
}
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();
});
}
}}}