mirror of
https://github.com/ZmnSCPxj/clboss.git
synced 2026-08-14 12:43:19 +02:00
Replaces the scaffold's echo-only stub with the real per-request
flow. clboss-xmovefunds now:
1. Manifests an idempotent startup-time create of the
persistent askrene layer named "clboss-xrebalance"
(created with persistent=true so it survives CLN restart
and accumulates probe knowledge across calls).
2. Per request:
a. Parses params (unchanged from the scaffold commit).
b. Waits for layer-ready.
c. Lists peer channels via listpeerchannels.
d. Generates a fresh transient layer name
clboss-xrebalance-tmp-<uuid> and creates it
(persistent=false).
e. Writes the per-direction masks to the transient layer:
every us->peer not listed in source_scid is disabled,
every peer->us not listed in dest_scid is disabled,
via askrene-update-channel enabled=false. Direction is
computed from BOLT 7 canonical id ordering.
f. Calls getroutes with source=self_id, destination=self_id,
layers=["auto.localchans", "clboss-xrebalance",
<transient>], amount_msat, maxfee_msat, final_cltv=14,
maxparts. Patched askrene (circular-askrene4 branch of
ksedgwic/lightning) interprets source=destination as
circular self-rebalance routing; stock CLN crashes here
with "child died with signal 6", which is the operator's
signal to apply the patch.
g. Removes the transient layer (best-effort -- swallows
errors on the cleanup path).
h. Replies with the original parsed plan plus the askrene
response embedded under "askrene". Status is "planned"
when execute=false, "ready" when execute=true (sendpay
path comes in a subsequent commit; for now execute=true
still falls through to the plan reply with the same
shape, just a different status string).
Architecture decisions
- Persistent xrebalance layer for accumulated knowledge;
transient layer per request for ephemeral masks. See
DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section
"Two-layer pattern per getroutes call".
- AskreneLayer helpers reused -- they already take a layer
name parameter and live at the neutral Boss::Mod::AskreneLayer
namespace. This commit adds the constant
xrebalance_layer_name = "clboss-xrebalance" alongside the
existing clboss_layer_name = "clboss" so both subsystems
coexist without commingling their layer state.
- The patched-askrene requirement is intentional and
opt-in: clboss-xmovefunds is a manual RPC trigger, no
autonomous code path will exercise circular routing until
the periodic xrebalance (Layer 3) and JIT xrebalance
(Layer 4) code paths land. At that point we will need a
startup feature-detection probe; deferred until then.
198 lines
5.5 KiB
C++
198 lines
5.5 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";
|
|
std::string const xrebalance_layer_name = "clboss-xrebalance";
|
|
|
|
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();
|
|
});
|
|
}
|
|
|
|
}}}
|