clboss/Boss/Mod/AskreneLayer.cpp
Ken Sedgwick 5939f7cb2f
XMoveFunds: wire the askrene call + plan response
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.
2026-05-31 17:32:09 -07:00

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