clboss/Boss/Mod/ActiveProber.cpp
Ken Sedgwick ba8ea5d0ba
ActiveProber: feed sendpay failures into the clboss askrene layer
When a 2-hop probe sendpay fails, parse the waitsendpay error and
record real route failures into the persistent clboss askrene
layer via Boss::Mod::AskreneLayer's shared helpers.  Future
getroutes calls (FundsMover's rebalances, ActiveProber's own
subsequent probes, anything else that consumes the clboss layer)
automatically steer around the failed channel or node.

The PR2 commit introduced the layer + the inform-channel /
disable-node call shape, but FundsMover is the only writer
today, and its writes are gated behind real sendpay attempts
that rarely fire (fees on small rebalance amounts almost always
exceed the prorated budget, so FundsMover gives up
pre-sendpay).  Net result: the layer is essentially empty in
production.

ActiveProber, by contrast, runs on a timer and sends real
sendpays without a budget filter.  Wiring its failure path to
the layer turns it into the dominant feeder, making the layer
infrastructure observable and useful.

Probe-outcome discrimination -- the failure interpretation is
necessarily tighter than FundsMover's because every probe is
designed to fail at the final hop:

  failcode 0x400F (WIRE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS)
                   -> id1 received the HTLC and rejected it
                      because our bit-flipped random
                      payment_hash matches no invoice.  This is
                      the EXPECTED outcome: the route was
                      viable all the way to id1.  Probe
                      success; no layer write.

  code 202 (unparsable onion)
                   -> cannot pin the failure to a specific
                      channel or node.  On a 2-hop route, id1
                      is the most likely culprit; disable_node(id1).

  echan == chan0   -> our outgoing channel had the issue.  Skip
                      the layer write: CLBOSS already has
                      authoritative knowledge of local channel
                      state via listpeerchannels, and the
                      shared layer is for inter-subsystem
                      knowledge that isn't already authoritatively
                      available.

  failcode & 0x2000 (NODE-level)
                   -> disable_node(erring_node).

  channel-level failcode on chan1
                   -> inform_channel_constrained(chan1, dir1, amount1).

  unparseable error data
                   -> swallow; don't guess.

No change to the existing probe-success accounting: the bool
returned by the catching handler still flows into the same
delpay-status / "Finished probing peer X" logic.  The only new
behavior is the side-effect layer write inside record_failure.

Open question, deliberately not addressed here: should
ActiveProber ALSO feed inform=succeeded on probe success
(raising lower-bound estimates on chan1)?  The DEVSTATE
ACTIVE-PROBER-LAYER-FEEDER-2026-05-20 recommends yes, ~5 lines
extra; defer until empirical signet observation shows whether
the failure-feedback alone is enough to drive observable
routing improvements.
2026-05-22 10:52:10 -07:00

744 lines
20 KiB
C++

#include"Boss/Mod/ActiveProber.hpp"
#include"Boss/Mod/AskreneLayer.hpp"
#include"Boss/Mod/ChannelCandidateInvestigator/Main.hpp"
#include"Boss/Mod/Rpc.hpp"
#include"Boss/Msg/Init.hpp"
#include"Boss/Msg/ProbeActively.hpp"
#include"Boss/Msg/ProvideDeletablePaymentLabelFilter.hpp"
#include"Boss/Msg/SolicitDeletablePaymentLabelFilter.hpp"
#include"Boss/concurrent.hpp"
#include"Boss/log.hpp"
#include"Boss/random_engine.hpp"
#include"Ev/Io.hpp"
#include"Ev/yield.hpp"
#include"Jsmn/Object.hpp"
#include"Json/Out.hpp"
#include"Ln/Amount.hpp"
#include"Ln/NodeId.hpp"
#include"Ln/Preimage.hpp"
#include"Ln/Scid.hpp"
#include"S/Bus.hpp"
#include"Sha256/Hash.hpp"
#include"Util/Str.hpp"
#include"Util/stringify.hpp"
#include<algorithm>
#include<memory>
#include<queue>
#include<set>
#include<vector>
namespace {
/* Try to probe the following amount. */
auto const reference_amount = Ln::Amount::sat(160000);
Ev::Io<void> wait_for_rpc(Boss::Mod::Rpc*& rpc) {
return Ev::yield().then([&rpc]() {
if (!rpc)
return wait_for_rpc(rpc);
return Ev::lift();
});
}
/* Prefix for all labels. */
auto const label_prefix = std::string( "CLBOSS ActiveProber "
"payment, this will fail "
"and should automatically "
"get deleted. Hash: "
);
bool is_our_label(std::string const& label) {
if (label.length() != label_prefix.length() + 64)
return false;
if ( std::string(label.begin(), label.begin() + label_prefix.length())
!= label_prefix
)
return false;
auto hash = std::string( label.begin() + label_prefix.length()
, label.end()
);
return Util::Str::ishex(hash);
}
}
namespace Boss { namespace Mod {
class ActiveProber::Run : public std::enable_shared_from_this<Run> {
private:
S::Bus& bus;
ChannelCandidateInvestigator::Main& investigator;
Rpc& rpc;
Ln::NodeId self_id;
Ln::NodeId peer;
Secp256k1::Random& random;
Run( ActiveProber& prober
, Ln::NodeId const& peer_
) : bus(prober.bus)
, investigator(prober.investigator)
, rpc(*prober.rpc)
, self_id(prober.self_id)
, peer(peer_)
, random(prober.random)
{ }
public:
static
std::shared_ptr<Run>
create( ActiveProber& prober
, Ln::NodeId const& peer
) {
return std::shared_ptr<Run>(new Run(prober, peer));
}
Ev::Io<void> run() {
auto self = shared_from_this();
return self->core_run().then([self]() {
return Ev::lift();
});
}
private:
/* First channel. */
Ln::Scid chan0;
Ln::Amount cap0;
Ln::Amount amount;
Ev::Io<void> core_run() {
return Ev::lift().then([this]() {
return Boss::log( bus, Info
, "ActiveProber: Probe peer %s."
, std::string(peer).c_str()
);
}).then([this]() {
auto parms = Json::Out()
.start_object()
.field("id", std::string(peer))
.end_object()
;
return rpc.command("listpeerchannels", std::move(parms));
}).then([this](Jsmn::Object res) {
try {
auto cs = res["channels"];
for (auto c : cs) {
if (!c.has("short_channel_id"))
continue;
if (!c.has("spendable_msat"))
continue;
auto state = std::string(
c["state"]
);
if (state != "CHANNELD_NORMAL")
continue;
chan0 = Ln::Scid(std::string(
c["short_channel_id"]
));
cap0 = Ln::Amount::object(
c["spendable_msat"]
);
break;
}
} catch (Jsmn::TypeError const& _) {
return Boss::log( bus, Error
, "ActiveProber: unexpected "
"listpeerchannels result: %s"
, Util::stringify(res).c_str()
);
}
if (!chan0)
return Boss::log( bus, Info
, "ActiveProber: No "
"CHANNELD_NORMAL channel "
"with node %s, cannot probe."
, std::string(peer).c_str()
);
amount = reference_amount;
if (amount > cap0 * 0.95)
amount = cap0 * 0.95;
return get_destinations();
});
}
/* A candidate set of destinations. */
std::queue<Ln::NodeId> to_try;
Ev::Io<void> get_destinations() {
return Ev::lift().then([this]() {
return investigator.get_channel_candidates();
}).then([this](std::vector<std::pair< Ln::NodeId
, Ln::NodeId
>> cands) {
/* Put both proposal and patron into the
* set of candidate nodes. */
auto n_set = std::set<Ln::NodeId>();
for (auto& e : cands) {
n_set.insert(std::move(e.first));
n_set.insert(std::move(e.second));
}
/* If the peer itself is in the set, remove it. */
auto it = n_set.find(peer);
if (it != n_set.end())
n_set.erase(it);
/* Copy to a vector and shuffle. */
auto n_vec = std::vector<Ln::NodeId>(n_set.size());
std::copy( n_set.begin(), n_set.end()
, n_vec.begin()
);
std::shuffle( n_vec.begin(), n_vec.end()
, Boss::random_engine
);
/* Push to queue. */
for (auto& n : n_vec)
to_try.push(std::move(n));
if (to_try.empty())
return Boss::log( bus, Info
, "ActiveProber: No trial "
"destinations found "
"for peer %s."
, std::string(peer).c_str()
);
return Boss::log( bus, Debug
, "ActiveProber: Found %zu trial "
"destinations for peer %s."
, to_try.size()
, std::string(peer).c_str()
)
+ getroute()
;
});
}
/* First hop after `peer`: the peer's neighbor that we probe
* towards. Extracted into typed values from getroutes' path[0]
* so we can rebuild the sendpay hop later without keeping the
* raw Jsmn::Object around.
*/
Ln::NodeId id1;
Ln::Scid chan1;
std::uint32_t direction1;
Ln::Amount amount1;
std::uint32_t delay1;
Ev::Io<void> getroute() {
if (to_try.empty())
return Boss::log( bus, Info
, "ActiveProber: No more trial "
"destinations for peer %s."
, std::string(peer).c_str()
);
return Ev::yield().then([this]() {
auto const& dest = to_try.front();
auto parms = Json::Out()
.start_object()
.field("source", std::string(peer))
.field("destination", std::string(dest))
.field("amount_msat", amount.to_msat())
/* No layers: source is a remote node
* (peer), not us, so the
* auto.localchans / auto.sourcefree
* helpers do not apply -- they would
* inject our private local channels
* and zero out the source's outgoing
* fees, either of which could make
* askrene pick a path[0] that the
* peer cannot actually reach via
* public topology (and could even
* produce a route that loops back
* through us).
*/
.start_array("layers").end_array()
/* Generous max-fee tolerance for a
* probe; askrene optimizes for
* cheaper paths anyway via its
* probability scoring.
*/
.field("maxfee_msat",
(amount * 0.01).to_msat())
.field("final_cltv", 14)
.field("maxparts", 1)
.end_object()
;
return rpc.command("getroutes", std::move(parms));
}).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.
*/
auto path0 = res["routes"][0]["path"][0];
id1 = Ln::NodeId(std::string(
path0.has("node_id_out")
? path0["node_id_out"]
: path0["next_node_id"]
));
/* short_channel_id_dir is "SCID/dir"; split
* into the SCID and the direction. If
* the slash is missing the response is
* malformed; treat it as a parse error
* so the enclosing handler logs and
* skips this destination rather than
* feeding garbage to Ln::Scid / std::stoul.
*/
auto sdir = std::string(
path0["short_channel_id_dir"]
);
auto slash = sdir.find('/');
if (slash == std::string::npos)
throw Jsmn::TypeError();
chan1 = Ln::Scid(sdir.substr(0, slash));
direction1 = std::uint32_t(std::stoul(
sdir.substr(slash + 1)
));
amount1 = Ln::Amount::object(
path0.has("amount_out_msat")
? path0["amount_out_msat"]
: path0["amount_msat"]
);
delay1 = std::uint32_t(double(
path0.has("cltv_out")
? path0["cltv_out"]
: path0["delay"]
));
} catch (std::exception const& _) {
/* Broaden catch to std::exception so we also
* handle std::invalid_argument and
* std::out_of_range from std::stoul on a
* malformed short_channel_id_dir tail. Pop
* the bad destination before logging so the
* recursive getroute() picks a different
* candidate instead of looping on the same
* one indefinitely.
*/
if (!to_try.empty())
to_try.pop();
return Boss::log( bus, Error
, "ActiveProber: Unexpected "
"result from getroutes: %s"
, Util::stringify(res).c_str()
).then([]() {
return Ev::lift(false);
});
}
return Ev::lift(true);
}).catching<RpcError>([this](RpcError const& e) {
/* Go to next. */
to_try.pop();
return Ev::lift(false);
}).then([this](bool flag) {
if (flag)
return compute_hop0();
else
return getroute();
});
}
/* Details from the first hop in the found route, which have to be
* added in the 0th hop we will insert.
*/
Ln::Amount base_fee;
std::uint32_t proportional_fee;
std::uint32_t cltv_delta;
Ln::Amount amount0;
std::uint32_t delay0;
Ev::Io<void> compute_hop0() {
return Ev::lift().then([this]() {
/* Get information on chan1. */
auto parms = Json::Out()
.start_object()
.field( "short_channel_id"
, std::string(chan1)
)
.end_object()
;
return rpc.command("listchannels", std::move(parms));
}).then([this](Jsmn::Object res) {
auto found = false;
try {
auto cs = res["channels"];
for (auto c : cs) {
auto src = Ln::NodeId(std::string(
c["source"]
));
if (src != peer)
continue;
base_fee = Ln::Amount::msat(double(
c["base_fee_millisatoshi"]
));
proportional_fee
= std::uint32_t(double(
c["fee_per_millionth"]
));
cltv_delta = std::uint32_t(double(
c["delay"]
));
found = true;
}
} catch (Jsmn::TypeError const&) {
return Boss::log( bus, Error
, "ActiveProber: Unexpected "
"result from listchannels: "
"%s"
, Util::stringify(res).c_str()
);
}
/* Channel could have disappeared from under us. */
if (!found) {
to_try.pop();
return getroute();
}
amount0 = amount1 + base_fee
+ (amount1 * ( double(proportional_fee)
/ 1000000
))
/* Fudge roundoff erors. */
+ Ln::Amount::msat(1)
;
delay0 = delay1 + cltv_delta;
return select_hash();
});
}
Sha256::Hash hash;
Ev::Io<void> select_hash() {
/* Generate a random preimage and hash it. */
auto preimage = Ln::Preimage(random);
hash = preimage.sha256();
/* Flip the bits of the generated hash.
* This ensures that even if the entropy of our preimage
* is low, this is still not exploitable, as an attacker
* that knows every bit of our preimage still cannot
* reverse the inverse-hash of our preimage.
*/
std::uint8_t buf[32];
hash.to_buffer(buf);
for (auto i = std::size_t(0); i < 32; ++i)
buf[i] = ~buf[i];
hash.from_buffer(buf);
return sendpay();
}
/* Parse waitsendpay's error data and return whether the probe
* should be considered successful. Side effect: on real
* route failures, write to the persistent clboss askrene
* layer so future getroutes calls steer around the failing
* channel/node.
*
* Probe-success outcome: the final hop (id1) replied with
* WIRE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS (failcode
* 0x400F), which is the expected failure given our
* bit-flipped random payment_hash and proves the route
* worked all the way through to id1.
*
* Failure outcomes that feed the layer:
* - Code 202 (unparsable onion): cannot pin a specific
* channel; disable id1 as the best-guess culprit on
* this 2-hop route.
* - NODE-level failcode (bit 0x2000 set): disable the
* erring_node.
* - CHANNEL-level failcode on a non-chan0 channel
* (= chan1): inform_channel_constrained at amount1.
*
* Failures we DO NOT feed:
* - echan == chan0: our local outgoing channel had the
* issue. CLBOSS already has authoritative knowledge of
* local channel state via listpeerchannels; no need to
* re-record into the shared layer.
* - Unparseable error data: don't guess.
*/
Ev::Io<bool> record_failure(RpcError const& err) {
auto code = int();
auto eidx = std::size_t();
auto echan = Ln::Scid();
auto edir = std::uint32_t();
auto enode = Ln::NodeId();
auto fail = std::uint16_t();
auto parsed = false;
try {
auto& error = err.error;
code = int(double(error["code"]));
/* Both codes carry the same data shape but
* different origin signal:
* 203 = destination permanently failed
* (e.g. final-hop responded with
* WIRE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS,
* the expected probe-success outcome).
* 204 = intermediate failure ("try another route").
*/
if (code == 203 || code == 204) {
auto data = error["data"];
eidx = std::size_t(double(
data["erring_index"]
));
echan = Ln::Scid(std::string(
data["erring_channel"]
));
edir = std::uint32_t(double(
data["erring_direction"]
));
enode = Ln::NodeId(std::string(
data["erring_node"]
));
fail = std::uint16_t(double(
data["failcode"]
));
parsed = true;
}
} catch (std::exception const&) {
/* Couldn't parse; treat as real failure with
* no layer feedback. Avoid acting on garbage.
*/
return Ev::lift(false);
}
(void) eidx; /* Currently unused; relying on echan and
* failcode to discriminate. Future:
* cross-check eidx against expected route
* positions if discrepancies emerge.
*/
auto const& layer_name =
Boss::Mod::AskreneLayer::clboss_layer_name;
if (code == 202) {
/* Unparsable onion: best guess on a 2-hop
* probe is that id1 is the culprit.
*/
return Boss::Mod::AskreneLayer::disable_node(
rpc, layer_name, id1
).then([]() {
return Ev::lift(false);
});
}
if (!parsed) {
/* Other error code, no actionable hop info. */
return Ev::lift(false);
}
/* WIRE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS at the
* destination is the expected probe outcome: id1
* received the HTLC and rejected it because the
* random bit-flipped payment_hash matches no
* invoice. The route was viable all the way.
*/
auto const WIRE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS
= std::uint16_t(0x400F);
if (fail == WIRE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS)
return Ev::lift(true);
/* Don't record local-channel failures. */
if (echan == chan0)
return Ev::lift(false);
/* NODE-level: 0x2000 bit set. */
if (fail & 0x2000) {
return Boss::Mod::AskreneLayer::disable_node(
rpc, layer_name, enode
).then([]() {
return Ev::lift(false);
});
}
/* CHANNEL-level failure on a non-chan0 channel
* (= chan1). Record an upper-bound constraint at
* the amount that failed to push through.
*/
return Boss::Mod::AskreneLayer::inform_channel_constrained(
rpc, layer_name, echan, edir, amount1
).then([]() {
return Ev::lift(false);
});
}
Ev::Io<void> sendpay() {
return Ev::lift().then([this]() {
auto os = std::ostringstream();
os << chan0 << " " << std::string(chan1);
return Boss::log( bus, Debug
, "ActiveProber: Probe %s by route %s."
, std::string(peer).c_str()
, os.str().c_str()
);
}).then([this]() {
auto routeparm = Json::Out();
auto routearr = routeparm.start_array();
/* Load the 0th hop. */
routearr.start_object()
.field("id", std::string(peer))
.field("channel", std::string(chan0))
.field( "direction"
, self_id > peer ? 1 : 0
)
.field( "amount_msat"
, amount0.to_msat()
)
.field("delay", delay0)
.field("style", "tlv")
.end_object();
/* Load the first hop after the peer, rebuilt from
* the getroutes path[0] we extracted earlier.
*
* We always probe with a short two-hop route (hop
* 0 above, and this hop 1). This gives the peer
* the "benefit of the doubt": we only probe the
* peer and *its* direct peer for uptime and
* capacity. Still "realistic" since the
* destinations were chosen as popular nodes (or
* at least to nodes that CLBOSS thinks are good
* to have capacity towards).
*/
routearr.start_object()
.field("id", std::string(id1))
.field("channel", std::string(chan1))
.field("direction", direction1)
.field("amount_msat", amount1.to_msat())
.field("delay", delay1)
.field("style", "tlv")
.end_object();
routearr.end_array();
auto label = label_prefix + std::string(hash);
auto parms = Json::Out()
.start_object()
.field("route", std::move(routeparm))
.field( "payment_hash"
, std::string(hash)
)
.field("label", label)
.end_object()
;
return rpc.command("sendpay", std::move(parms));
}).then([this](Jsmn::Object _) {
auto parms = Json::Out()
.start_object()
.field( "payment_hash"
, std::string(hash)
)
.end_object()
;
return rpc.command("waitsendpay", std::move(parms));
}).then([](Jsmn::Object _) {
/* Oh look, we succeeded.
* Should not happen though.
*/
return Ev::lift(true);
}).catching<RpcError>([this](RpcError const& err) {
/* Inspect the error to determine: was this a
* successful probe (id1 received the HTLC and
* rejected it with
* WIRE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS as
* expected given our bit-flipped random
* payment_hash) or a real route failure? Real
* failures are recorded into the persistent
* clboss askrene layer so future getroutes
* calls steer around the failing channel/node.
*
* record_failure returns whether the probe was
* useful (reached destination), but the bool
* we hand downstream tracks sendpay-completion,
* not probe-usefulness. We are in the
* RpcError handler, so the sendpay failed in
* CLN's view regardless of how useful the
* probe was -- delpay below must pass
* status="failed" to match what CLN recorded.
* Discard the probe-useful bit here.
*/
return record_failure(err).then([](bool) {
return Ev::lift(false);
});
}).then([this](bool success) {
auto status = std::string(
success ? "complete" : "failed"
);
/* Now delete the payment, so that the operator
* does not get confused with random failing
* payments they did not make. */
auto parms = Json::Out()
.start_object()
.field( "payment_hash"
, std::string(hash)
)
.field( "status"
, status
)
.end_object()
;
return rpc.command("delpay", std::move(parms));
}).then([](Jsmn::Object _) {
/* We do not actually care if the `delpay` succeeds
* or not.
*/
return Ev::lift();
}).catching<RpcError>([](RpcError const& _) {
return Ev::lift();
}).then([this]() {
return Boss::log( bus, Info
, "ActiveProber: Finished probing "
"peer %s."
, std::string(peer).c_str()
);
});
}
};
void ActiveProber::start() {
bus.subscribe<Msg::Init
>([this](Msg::Init const& init) {
rpc = &init.rpc;
self_id = init.self_id;
return Ev::lift();
});
bus.subscribe<Msg::ProbeActively
>([this](Msg::ProbeActively const& m) {
auto run = Run::create(*this, m.peer);
return Boss::concurrent( wait_for_rpc(rpc)
+ run->run()
);
});
using Msg::ProvideDeletablePaymentLabelFilter;
using Msg::SolicitDeletablePaymentLabelFilter;
bus.subscribe<SolicitDeletablePaymentLabelFilter
>([this](SolicitDeletablePaymentLabelFilter const& _) {
return bus.raise(ProvideDeletablePaymentLabelFilter{
&is_our_label
});
});
}
}}