clboss/Boss/Mod/ActiveProber.cpp

772 lines
21 KiB
C++
Raw Normal View History

#include"Boss/Mod/ActiveProber.hpp"
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-21 10:54:16 -07:00
#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()
;
});
}
Matchmaker, ActiveProber: migrate getroute -> getroutes for CLN v26.06 compat Continuation of the v26.06 migration started in the Dowser commit. Two more getroute call sites: ChannelCandidateMatchmaker.cpp and ActiveProber.cpp. Both use maxparts=1 since each wants a single route, not a flow estimate. ActiveProber probes the local node's own outbound liquidity, so it uses layers=["auto.localchans","auto.sourcefree"] per the standardized recipe. Matchmaker probes from a remote source (a candidate patron) to a remote target, so it passes an empty layers array -- auto.localchans would inject our private local channels into a foreign source, and auto.sourcefree would zero out the source's outgoing fees, either of which could make askrene pick a patron the proposal cannot actually reach via public topology. maxfee_msat is 1% of the probe amount in both cases. Matchmaker is a near-clean swap. The patron id (route[0].id in the old getroute shape) is read from routes[0].path[0] in the new getroutes shape; at the parse site we bridge the v26.06+ name (node_id_out) with its deprecated pre-v26.06 predecessor (next_node_id) via has()/ternary, so the code works on either CLN flavor. ActiveProber is more involved. The previous code stashed res["route"] as a Jsmn::Object and later appended hops from it directly into the sendpay route parameter, relying on the fact that the old getroute hop shape (id/channel/direction/ amount_msat/delay/style) was already sendpay-compatible. The new getroutes path[] shape is NOT directly sendpay-compatible (different field names, short_channel_id_dir encodes scid and direction together), so we now extract path[0] into typed values (id1, chan1, direction1, amount1, delay1) and rebuild the sendpay hop1 explicitly from those. The Jsmn::Object route member is dropped. The hop-field reads in ActiveProber bridge between v26.06+ (node_id_out / amount_out_msat / cltv_out) and the deprecated pre-v26.06 names (next_node_id / amount_msat / delay) via the same has()/ternary pattern. short_channel_id_dir splits on '/' with an explicit npos check; a missing slash throws Jsmn::TypeError so the surrounding parse- error log path catches it cleanly. Without the check, sdir.substr(0, npos) and sdir.substr(npos + 1) would feed malformed input into Ln::Scid (which throws std::invalid_argument, not caught by the Jsmn::TypeError handler) or into std::stoul (silently producing a wrong direction). ActiveProber also drops its vestigial exclude=[self_id] parameter: askrene's source/destination model naturally excludes self when source != self, which is always the case here (the probe always flows from peer outward, never back through us). Three of the four getroute call sites in CLBOSS are now on getroutes; the fourth (FundsMover/Attempter) is deferred to PR2 because its exclude-vector pattern encodes failure feedback from real payment attempts and warrants a redesign around askrene-inform-channel rather than a mechanical port.
2026-05-19 13:40:52 -07:00
/* 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;
Matchmaker, ActiveProber: migrate getroute -> getroutes for CLN v26.06 compat Continuation of the v26.06 migration started in the Dowser commit. Two more getroute call sites: ChannelCandidateMatchmaker.cpp and ActiveProber.cpp. Both use maxparts=1 since each wants a single route, not a flow estimate. ActiveProber probes the local node's own outbound liquidity, so it uses layers=["auto.localchans","auto.sourcefree"] per the standardized recipe. Matchmaker probes from a remote source (a candidate patron) to a remote target, so it passes an empty layers array -- auto.localchans would inject our private local channels into a foreign source, and auto.sourcefree would zero out the source's outgoing fees, either of which could make askrene pick a patron the proposal cannot actually reach via public topology. maxfee_msat is 1% of the probe amount in both cases. Matchmaker is a near-clean swap. The patron id (route[0].id in the old getroute shape) is read from routes[0].path[0] in the new getroutes shape; at the parse site we bridge the v26.06+ name (node_id_out) with its deprecated pre-v26.06 predecessor (next_node_id) via has()/ternary, so the code works on either CLN flavor. ActiveProber is more involved. The previous code stashed res["route"] as a Jsmn::Object and later appended hops from it directly into the sendpay route parameter, relying on the fact that the old getroute hop shape (id/channel/direction/ amount_msat/delay/style) was already sendpay-compatible. The new getroutes path[] shape is NOT directly sendpay-compatible (different field names, short_channel_id_dir encodes scid and direction together), so we now extract path[0] into typed values (id1, chan1, direction1, amount1, delay1) and rebuild the sendpay hop1 explicitly from those. The Jsmn::Object route member is dropped. The hop-field reads in ActiveProber bridge between v26.06+ (node_id_out / amount_out_msat / cltv_out) and the deprecated pre-v26.06 names (next_node_id / amount_msat / delay) via the same has()/ternary pattern. short_channel_id_dir splits on '/' with an explicit npos check; a missing slash throws Jsmn::TypeError so the surrounding parse- error log path catches it cleanly. Without the check, sdir.substr(0, npos) and sdir.substr(npos + 1) would feed malformed input into Ln::Scid (which throws std::invalid_argument, not caught by the Jsmn::TypeError handler) or into std::stoul (silently producing a wrong direction). ActiveProber also drops its vestigial exclude=[self_id] parameter: askrene's source/destination model naturally excludes self when source != self, which is always the case here (the probe always flows from peer outward, never back through us). Three of the four getroute call sites in CLBOSS are now on getroutes; the fourth (FundsMover/Attempter) is deferred to PR2 because its exclude-vector pattern encodes failure feedback from real payment attempts and warrants a redesign around askrene-inform-channel rather than a mechanical port.
2026-05-19 13:40:52 -07:00
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()
Matchmaker, ActiveProber: migrate getroute -> getroutes for CLN v26.06 compat Continuation of the v26.06 migration started in the Dowser commit. Two more getroute call sites: ChannelCandidateMatchmaker.cpp and ActiveProber.cpp. Both use maxparts=1 since each wants a single route, not a flow estimate. ActiveProber probes the local node's own outbound liquidity, so it uses layers=["auto.localchans","auto.sourcefree"] per the standardized recipe. Matchmaker probes from a remote source (a candidate patron) to a remote target, so it passes an empty layers array -- auto.localchans would inject our private local channels into a foreign source, and auto.sourcefree would zero out the source's outgoing fees, either of which could make askrene pick a patron the proposal cannot actually reach via public topology. maxfee_msat is 1% of the probe amount in both cases. Matchmaker is a near-clean swap. The patron id (route[0].id in the old getroute shape) is read from routes[0].path[0] in the new getroutes shape; at the parse site we bridge the v26.06+ name (node_id_out) with its deprecated pre-v26.06 predecessor (next_node_id) via has()/ternary, so the code works on either CLN flavor. ActiveProber is more involved. The previous code stashed res["route"] as a Jsmn::Object and later appended hops from it directly into the sendpay route parameter, relying on the fact that the old getroute hop shape (id/channel/direction/ amount_msat/delay/style) was already sendpay-compatible. The new getroutes path[] shape is NOT directly sendpay-compatible (different field names, short_channel_id_dir encodes scid and direction together), so we now extract path[0] into typed values (id1, chan1, direction1, amount1, delay1) and rebuild the sendpay hop1 explicitly from those. The Jsmn::Object route member is dropped. The hop-field reads in ActiveProber bridge between v26.06+ (node_id_out / amount_out_msat / cltv_out) and the deprecated pre-v26.06 names (next_node_id / amount_msat / delay) via the same has()/ternary pattern. short_channel_id_dir splits on '/' with an explicit npos check; a missing slash throws Jsmn::TypeError so the surrounding parse- error log path catches it cleanly. Without the check, sdir.substr(0, npos) and sdir.substr(npos + 1) would feed malformed input into Ln::Scid (which throws std::invalid_argument, not caught by the Jsmn::TypeError handler) or into std::stoul (silently producing a wrong direction). ActiveProber also drops its vestigial exclude=[self_id] parameter: askrene's source/destination model naturally excludes self when source != self, which is always the case here (the probe always flows from peer outward, never back through us). Three of the four getroute call sites in CLBOSS are now on getroutes; the fourth (FundsMover/Attempter) is deferred to PR2 because its exclude-vector pattern encodes failure feedback from real payment attempts and warrants a redesign around askrene-inform-channel rather than a mechanical port.
2026-05-19 13:40:52 -07:00
.field("source", std::string(peer))
.field("destination", std::string(dest))
.field("amount_msat", amount.to_msat())
Matchmaker, ActiveProber: migrate getroute -> getroutes for CLN v26.06 compat Continuation of the v26.06 migration started in the Dowser commit. Two more getroute call sites: ChannelCandidateMatchmaker.cpp and ActiveProber.cpp. Both use maxparts=1 since each wants a single route, not a flow estimate. ActiveProber probes the local node's own outbound liquidity, so it uses layers=["auto.localchans","auto.sourcefree"] per the standardized recipe. Matchmaker probes from a remote source (a candidate patron) to a remote target, so it passes an empty layers array -- auto.localchans would inject our private local channels into a foreign source, and auto.sourcefree would zero out the source's outgoing fees, either of which could make askrene pick a patron the proposal cannot actually reach via public topology. maxfee_msat is 1% of the probe amount in both cases. Matchmaker is a near-clean swap. The patron id (route[0].id in the old getroute shape) is read from routes[0].path[0] in the new getroutes shape; at the parse site we bridge the v26.06+ name (node_id_out) with its deprecated pre-v26.06 predecessor (next_node_id) via has()/ternary, so the code works on either CLN flavor. ActiveProber is more involved. The previous code stashed res["route"] as a Jsmn::Object and later appended hops from it directly into the sendpay route parameter, relying on the fact that the old getroute hop shape (id/channel/direction/ amount_msat/delay/style) was already sendpay-compatible. The new getroutes path[] shape is NOT directly sendpay-compatible (different field names, short_channel_id_dir encodes scid and direction together), so we now extract path[0] into typed values (id1, chan1, direction1, amount1, delay1) and rebuild the sendpay hop1 explicitly from those. The Jsmn::Object route member is dropped. The hop-field reads in ActiveProber bridge between v26.06+ (node_id_out / amount_out_msat / cltv_out) and the deprecated pre-v26.06 names (next_node_id / amount_msat / delay) via the same has()/ternary pattern. short_channel_id_dir splits on '/' with an explicit npos check; a missing slash throws Jsmn::TypeError so the surrounding parse- error log path catches it cleanly. Without the check, sdir.substr(0, npos) and sdir.substr(npos + 1) would feed malformed input into Ln::Scid (which throws std::invalid_argument, not caught by the Jsmn::TypeError handler) or into std::stoul (silently producing a wrong direction). ActiveProber also drops its vestigial exclude=[self_id] parameter: askrene's source/destination model naturally excludes self when source != self, which is always the case here (the probe always flows from peer outward, never back through us). Three of the four getroute call sites in CLBOSS are now on getroutes; the fourth (FundsMover/Attempter) is deferred to PR2 because its exclude-vector pattern encodes failure feedback from real payment attempts and warrants a redesign around askrene-inform-channel rather than a mechanical port.
2026-05-19 13:40:52 -07:00
/* 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.
*/
Matchmaker, ActiveProber: migrate getroute -> getroutes for CLN v26.06 compat Continuation of the v26.06 migration started in the Dowser commit. Two more getroute call sites: ChannelCandidateMatchmaker.cpp and ActiveProber.cpp. Both use maxparts=1 since each wants a single route, not a flow estimate. ActiveProber probes the local node's own outbound liquidity, so it uses layers=["auto.localchans","auto.sourcefree"] per the standardized recipe. Matchmaker probes from a remote source (a candidate patron) to a remote target, so it passes an empty layers array -- auto.localchans would inject our private local channels into a foreign source, and auto.sourcefree would zero out the source's outgoing fees, either of which could make askrene pick a patron the proposal cannot actually reach via public topology. maxfee_msat is 1% of the probe amount in both cases. Matchmaker is a near-clean swap. The patron id (route[0].id in the old getroute shape) is read from routes[0].path[0] in the new getroutes shape; at the parse site we bridge the v26.06+ name (node_id_out) with its deprecated pre-v26.06 predecessor (next_node_id) via has()/ternary, so the code works on either CLN flavor. ActiveProber is more involved. The previous code stashed res["route"] as a Jsmn::Object and later appended hops from it directly into the sendpay route parameter, relying on the fact that the old getroute hop shape (id/channel/direction/ amount_msat/delay/style) was already sendpay-compatible. The new getroutes path[] shape is NOT directly sendpay-compatible (different field names, short_channel_id_dir encodes scid and direction together), so we now extract path[0] into typed values (id1, chan1, direction1, amount1, delay1) and rebuild the sendpay hop1 explicitly from those. The Jsmn::Object route member is dropped. The hop-field reads in ActiveProber bridge between v26.06+ (node_id_out / amount_out_msat / cltv_out) and the deprecated pre-v26.06 names (next_node_id / amount_msat / delay) via the same has()/ternary pattern. short_channel_id_dir splits on '/' with an explicit npos check; a missing slash throws Jsmn::TypeError so the surrounding parse- error log path catches it cleanly. Without the check, sdir.substr(0, npos) and sdir.substr(npos + 1) would feed malformed input into Ln::Scid (which throws std::invalid_argument, not caught by the Jsmn::TypeError handler) or into std::stoul (silently producing a wrong direction). ActiveProber also drops its vestigial exclude=[self_id] parameter: askrene's source/destination model naturally excludes self when source != self, which is always the case here (the probe always flows from peer outward, never back through us). Three of the four getroute call sites in CLBOSS are now on getroutes; the fourth (FundsMover/Attempter) is deferred to PR2 because its exclude-vector pattern encodes failure feedback from real payment attempts and warrants a redesign around askrene-inform-channel rather than a mechanical port.
2026-05-19 13:40:52 -07:00
.field("maxfee_msat",
(amount * 0.01).to_msat())
.field("final_cltv", 14)
.field("maxparts", 1)
.end_object()
;
Matchmaker, ActiveProber: migrate getroute -> getroutes for CLN v26.06 compat Continuation of the v26.06 migration started in the Dowser commit. Two more getroute call sites: ChannelCandidateMatchmaker.cpp and ActiveProber.cpp. Both use maxparts=1 since each wants a single route, not a flow estimate. ActiveProber probes the local node's own outbound liquidity, so it uses layers=["auto.localchans","auto.sourcefree"] per the standardized recipe. Matchmaker probes from a remote source (a candidate patron) to a remote target, so it passes an empty layers array -- auto.localchans would inject our private local channels into a foreign source, and auto.sourcefree would zero out the source's outgoing fees, either of which could make askrene pick a patron the proposal cannot actually reach via public topology. maxfee_msat is 1% of the probe amount in both cases. Matchmaker is a near-clean swap. The patron id (route[0].id in the old getroute shape) is read from routes[0].path[0] in the new getroutes shape; at the parse site we bridge the v26.06+ name (node_id_out) with its deprecated pre-v26.06 predecessor (next_node_id) via has()/ternary, so the code works on either CLN flavor. ActiveProber is more involved. The previous code stashed res["route"] as a Jsmn::Object and later appended hops from it directly into the sendpay route parameter, relying on the fact that the old getroute hop shape (id/channel/direction/ amount_msat/delay/style) was already sendpay-compatible. The new getroutes path[] shape is NOT directly sendpay-compatible (different field names, short_channel_id_dir encodes scid and direction together), so we now extract path[0] into typed values (id1, chan1, direction1, amount1, delay1) and rebuild the sendpay hop1 explicitly from those. The Jsmn::Object route member is dropped. The hop-field reads in ActiveProber bridge between v26.06+ (node_id_out / amount_out_msat / cltv_out) and the deprecated pre-v26.06 names (next_node_id / amount_msat / delay) via the same has()/ternary pattern. short_channel_id_dir splits on '/' with an explicit npos check; a missing slash throws Jsmn::TypeError so the surrounding parse- error log path catches it cleanly. Without the check, sdir.substr(0, npos) and sdir.substr(npos + 1) would feed malformed input into Ln::Scid (which throws std::invalid_argument, not caught by the Jsmn::TypeError handler) or into std::stoul (silently producing a wrong direction). ActiveProber also drops its vestigial exclude=[self_id] parameter: askrene's source/destination model naturally excludes self when source != self, which is always the case here (the probe always flows from peer outward, never back through us). Three of the four getroute call sites in CLBOSS are now on getroutes; the fourth (FundsMover/Attempter) is deferred to PR2 because its exclude-vector pattern encodes failure feedback from real payment attempts and warrants a redesign around askrene-inform-channel rather than a mechanical port.
2026-05-19 13:40:52 -07:00
return rpc.command("getroutes", std::move(parms));
}).then([this](Jsmn::Object res) {
try {
Matchmaker, ActiveProber: migrate getroute -> getroutes for CLN v26.06 compat Continuation of the v26.06 migration started in the Dowser commit. Two more getroute call sites: ChannelCandidateMatchmaker.cpp and ActiveProber.cpp. Both use maxparts=1 since each wants a single route, not a flow estimate. ActiveProber probes the local node's own outbound liquidity, so it uses layers=["auto.localchans","auto.sourcefree"] per the standardized recipe. Matchmaker probes from a remote source (a candidate patron) to a remote target, so it passes an empty layers array -- auto.localchans would inject our private local channels into a foreign source, and auto.sourcefree would zero out the source's outgoing fees, either of which could make askrene pick a patron the proposal cannot actually reach via public topology. maxfee_msat is 1% of the probe amount in both cases. Matchmaker is a near-clean swap. The patron id (route[0].id in the old getroute shape) is read from routes[0].path[0] in the new getroutes shape; at the parse site we bridge the v26.06+ name (node_id_out) with its deprecated pre-v26.06 predecessor (next_node_id) via has()/ternary, so the code works on either CLN flavor. ActiveProber is more involved. The previous code stashed res["route"] as a Jsmn::Object and later appended hops from it directly into the sendpay route parameter, relying on the fact that the old getroute hop shape (id/channel/direction/ amount_msat/delay/style) was already sendpay-compatible. The new getroutes path[] shape is NOT directly sendpay-compatible (different field names, short_channel_id_dir encodes scid and direction together), so we now extract path[0] into typed values (id1, chan1, direction1, amount1, delay1) and rebuild the sendpay hop1 explicitly from those. The Jsmn::Object route member is dropped. The hop-field reads in ActiveProber bridge between v26.06+ (node_id_out / amount_out_msat / cltv_out) and the deprecated pre-v26.06 names (next_node_id / amount_msat / delay) via the same has()/ternary pattern. short_channel_id_dir splits on '/' with an explicit npos check; a missing slash throws Jsmn::TypeError so the surrounding parse- error log path catches it cleanly. Without the check, sdir.substr(0, npos) and sdir.substr(npos + 1) would feed malformed input into Ln::Scid (which throws std::invalid_argument, not caught by the Jsmn::TypeError handler) or into std::stoul (silently producing a wrong direction). ActiveProber also drops its vestigial exclude=[self_id] parameter: askrene's source/destination model naturally excludes self when source != self, which is always the case here (the probe always flows from peer outward, never back through us). Three of the four getroute call sites in CLBOSS are now on getroutes; the fourth (FundsMover/Attempter) is deferred to PR2 because its exclude-vector pattern encodes failure feedback from real payment attempts and warrants a redesign around askrene-inform-channel rather than a mechanical port.
2026-05-19 13:40:52 -07:00
/* 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(
Matchmaker, ActiveProber: migrate getroute -> getroutes for CLN v26.06 compat Continuation of the v26.06 migration started in the Dowser commit. Two more getroute call sites: ChannelCandidateMatchmaker.cpp and ActiveProber.cpp. Both use maxparts=1 since each wants a single route, not a flow estimate. ActiveProber probes the local node's own outbound liquidity, so it uses layers=["auto.localchans","auto.sourcefree"] per the standardized recipe. Matchmaker probes from a remote source (a candidate patron) to a remote target, so it passes an empty layers array -- auto.localchans would inject our private local channels into a foreign source, and auto.sourcefree would zero out the source's outgoing fees, either of which could make askrene pick a patron the proposal cannot actually reach via public topology. maxfee_msat is 1% of the probe amount in both cases. Matchmaker is a near-clean swap. The patron id (route[0].id in the old getroute shape) is read from routes[0].path[0] in the new getroutes shape; at the parse site we bridge the v26.06+ name (node_id_out) with its deprecated pre-v26.06 predecessor (next_node_id) via has()/ternary, so the code works on either CLN flavor. ActiveProber is more involved. The previous code stashed res["route"] as a Jsmn::Object and later appended hops from it directly into the sendpay route parameter, relying on the fact that the old getroute hop shape (id/channel/direction/ amount_msat/delay/style) was already sendpay-compatible. The new getroutes path[] shape is NOT directly sendpay-compatible (different field names, short_channel_id_dir encodes scid and direction together), so we now extract path[0] into typed values (id1, chan1, direction1, amount1, delay1) and rebuild the sendpay hop1 explicitly from those. The Jsmn::Object route member is dropped. The hop-field reads in ActiveProber bridge between v26.06+ (node_id_out / amount_out_msat / cltv_out) and the deprecated pre-v26.06 names (next_node_id / amount_msat / delay) via the same has()/ternary pattern. short_channel_id_dir splits on '/' with an explicit npos check; a missing slash throws Jsmn::TypeError so the surrounding parse- error log path catches it cleanly. Without the check, sdir.substr(0, npos) and sdir.substr(npos + 1) would feed malformed input into Ln::Scid (which throws std::invalid_argument, not caught by the Jsmn::TypeError handler) or into std::stoul (silently producing a wrong direction). ActiveProber also drops its vestigial exclude=[self_id] parameter: askrene's source/destination model naturally excludes self when source != self, which is always the case here (the probe always flows from peer outward, never back through us). Three of the four getroute call sites in CLBOSS are now on getroutes; the fourth (FundsMover/Attempter) is deferred to PR2 because its exclude-vector pattern encodes failure feedback from real payment attempts and warrants a redesign around askrene-inform-channel rather than a mechanical port.
2026-05-19 13:40:52 -07:00
path0.has("amount_out_msat")
? path0["amount_out_msat"]
: path0["amount_msat"]
);
delay1 = std::uint32_t(double(
Matchmaker, ActiveProber: migrate getroute -> getroutes for CLN v26.06 compat Continuation of the v26.06 migration started in the Dowser commit. Two more getroute call sites: ChannelCandidateMatchmaker.cpp and ActiveProber.cpp. Both use maxparts=1 since each wants a single route, not a flow estimate. ActiveProber probes the local node's own outbound liquidity, so it uses layers=["auto.localchans","auto.sourcefree"] per the standardized recipe. Matchmaker probes from a remote source (a candidate patron) to a remote target, so it passes an empty layers array -- auto.localchans would inject our private local channels into a foreign source, and auto.sourcefree would zero out the source's outgoing fees, either of which could make askrene pick a patron the proposal cannot actually reach via public topology. maxfee_msat is 1% of the probe amount in both cases. Matchmaker is a near-clean swap. The patron id (route[0].id in the old getroute shape) is read from routes[0].path[0] in the new getroutes shape; at the parse site we bridge the v26.06+ name (node_id_out) with its deprecated pre-v26.06 predecessor (next_node_id) via has()/ternary, so the code works on either CLN flavor. ActiveProber is more involved. The previous code stashed res["route"] as a Jsmn::Object and later appended hops from it directly into the sendpay route parameter, relying on the fact that the old getroute hop shape (id/channel/direction/ amount_msat/delay/style) was already sendpay-compatible. The new getroutes path[] shape is NOT directly sendpay-compatible (different field names, short_channel_id_dir encodes scid and direction together), so we now extract path[0] into typed values (id1, chan1, direction1, amount1, delay1) and rebuild the sendpay hop1 explicitly from those. The Jsmn::Object route member is dropped. The hop-field reads in ActiveProber bridge between v26.06+ (node_id_out / amount_out_msat / cltv_out) and the deprecated pre-v26.06 names (next_node_id / amount_msat / delay) via the same has()/ternary pattern. short_channel_id_dir splits on '/' with an explicit npos check; a missing slash throws Jsmn::TypeError so the surrounding parse- error log path catches it cleanly. Without the check, sdir.substr(0, npos) and sdir.substr(npos + 1) would feed malformed input into Ln::Scid (which throws std::invalid_argument, not caught by the Jsmn::TypeError handler) or into std::stoul (silently producing a wrong direction). ActiveProber also drops its vestigial exclude=[self_id] parameter: askrene's source/destination model naturally excludes self when source != self, which is always the case here (the probe always flows from peer outward, never back through us). Three of the four getroute call sites in CLBOSS are now on getroutes; the fourth (FundsMover/Attempter) is deferred to PR2 because its exclude-vector pattern encodes failure feedback from real payment attempts and warrants a redesign around askrene-inform-channel rather than a mechanical port.
2026-05-19 13:40:52 -07:00
path0.has("cltv_out")
? path0["cltv_out"]
: path0["delay"]
));
Matchmaker, ActiveProber: migrate getroute -> getroutes for CLN v26.06 compat Continuation of the v26.06 migration started in the Dowser commit. Two more getroute call sites: ChannelCandidateMatchmaker.cpp and ActiveProber.cpp. Both use maxparts=1 since each wants a single route, not a flow estimate. ActiveProber probes the local node's own outbound liquidity, so it uses layers=["auto.localchans","auto.sourcefree"] per the standardized recipe. Matchmaker probes from a remote source (a candidate patron) to a remote target, so it passes an empty layers array -- auto.localchans would inject our private local channels into a foreign source, and auto.sourcefree would zero out the source's outgoing fees, either of which could make askrene pick a patron the proposal cannot actually reach via public topology. maxfee_msat is 1% of the probe amount in both cases. Matchmaker is a near-clean swap. The patron id (route[0].id in the old getroute shape) is read from routes[0].path[0] in the new getroutes shape; at the parse site we bridge the v26.06+ name (node_id_out) with its deprecated pre-v26.06 predecessor (next_node_id) via has()/ternary, so the code works on either CLN flavor. ActiveProber is more involved. The previous code stashed res["route"] as a Jsmn::Object and later appended hops from it directly into the sendpay route parameter, relying on the fact that the old getroute hop shape (id/channel/direction/ amount_msat/delay/style) was already sendpay-compatible. The new getroutes path[] shape is NOT directly sendpay-compatible (different field names, short_channel_id_dir encodes scid and direction together), so we now extract path[0] into typed values (id1, chan1, direction1, amount1, delay1) and rebuild the sendpay hop1 explicitly from those. The Jsmn::Object route member is dropped. The hop-field reads in ActiveProber bridge between v26.06+ (node_id_out / amount_out_msat / cltv_out) and the deprecated pre-v26.06 names (next_node_id / amount_msat / delay) via the same has()/ternary pattern. short_channel_id_dir splits on '/' with an explicit npos check; a missing slash throws Jsmn::TypeError so the surrounding parse- error log path catches it cleanly. Without the check, sdir.substr(0, npos) and sdir.substr(npos + 1) would feed malformed input into Ln::Scid (which throws std::invalid_argument, not caught by the Jsmn::TypeError handler) or into std::stoul (silently producing a wrong direction). ActiveProber also drops its vestigial exclude=[self_id] parameter: askrene's source/destination model naturally excludes self when source != self, which is always the case here (the probe always flows from peer outward, never back through us). Three of the four getroute call sites in CLBOSS are now on getroutes; the fourth (FundsMover/Attempter) is deferred to PR2 because its exclude-vector pattern encodes failure feedback from real payment attempts and warrants a redesign around askrene-inform-channel rather than a mechanical port.
2026-05-19 13:40:52 -07:00
} 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 "
Matchmaker, ActiveProber: migrate getroute -> getroutes for CLN v26.06 compat Continuation of the v26.06 migration started in the Dowser commit. Two more getroute call sites: ChannelCandidateMatchmaker.cpp and ActiveProber.cpp. Both use maxparts=1 since each wants a single route, not a flow estimate. ActiveProber probes the local node's own outbound liquidity, so it uses layers=["auto.localchans","auto.sourcefree"] per the standardized recipe. Matchmaker probes from a remote source (a candidate patron) to a remote target, so it passes an empty layers array -- auto.localchans would inject our private local channels into a foreign source, and auto.sourcefree would zero out the source's outgoing fees, either of which could make askrene pick a patron the proposal cannot actually reach via public topology. maxfee_msat is 1% of the probe amount in both cases. Matchmaker is a near-clean swap. The patron id (route[0].id in the old getroute shape) is read from routes[0].path[0] in the new getroutes shape; at the parse site we bridge the v26.06+ name (node_id_out) with its deprecated pre-v26.06 predecessor (next_node_id) via has()/ternary, so the code works on either CLN flavor. ActiveProber is more involved. The previous code stashed res["route"] as a Jsmn::Object and later appended hops from it directly into the sendpay route parameter, relying on the fact that the old getroute hop shape (id/channel/direction/ amount_msat/delay/style) was already sendpay-compatible. The new getroutes path[] shape is NOT directly sendpay-compatible (different field names, short_channel_id_dir encodes scid and direction together), so we now extract path[0] into typed values (id1, chan1, direction1, amount1, delay1) and rebuild the sendpay hop1 explicitly from those. The Jsmn::Object route member is dropped. The hop-field reads in ActiveProber bridge between v26.06+ (node_id_out / amount_out_msat / cltv_out) and the deprecated pre-v26.06 names (next_node_id / amount_msat / delay) via the same has()/ternary pattern. short_channel_id_dir splits on '/' with an explicit npos check; a missing slash throws Jsmn::TypeError so the surrounding parse- error log path catches it cleanly. Without the check, sdir.substr(0, npos) and sdir.substr(npos + 1) would feed malformed input into Ln::Scid (which throws std::invalid_argument, not caught by the Jsmn::TypeError handler) or into std::stoul (silently producing a wrong direction). ActiveProber also drops its vestigial exclude=[self_id] parameter: askrene's source/destination model naturally excludes self when source != self, which is always the case here (the probe always flows from peer outward, never back through us). Three of the four getroute call sites in CLBOSS are now on getroutes; the fourth (FundsMover/Attempter) is deferred to PR2 because its exclude-vector pattern encodes failure feedback from real payment attempts and warrants a redesign around askrene-inform-channel rather than a mechanical port.
2026-05-19 13:40:52 -07:00
"result from getroutes: %s"
, Util::stringify(res).c_str()
2020-11-02 19:02:35 +08:00
).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();
}
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-21 10:54:16 -07:00
/* 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.
ActiveProber: feed inform=unconstrained on probe success The previous commit fed only the FAILURE side: probes that hit WIRE_TEMPORARY_CHANNEL_FAILURE etc. wrote constraints into the clboss layer. Successful probes (id1 returned WIRE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, the expected outcome) returned bool=true and did not touch the layer. This commit adds the symmetric positive feed: when a probe succeeds, call askrene-inform-channel inform=succeeded on chan1 (peer's outgoing channel to id1) at amount1. askrene treats this as a raised lower-bound on the directed channel's capacity estimate, so future getroutes calls assign chan1 a higher success-probability score. Why this is the dominant signal source in practice: Failures are stochastic and bounded (one channel-or-node can only fail a few times before askrene routes around it entirely). Successes are repeated -- the same chan1 can push the same amount1 many times, raising the lower bound monotonically. Over a day of probes, the success-feed contribution to the layer's expected-flow estimate vastly exceeds the failure-feed contribution. Symmetric with the failure path on which channels we feed: Only chan1 (peer's outbound to id1) is fed. chan0 (our outbound to peer) is deliberately skipped on the success path for the same reason it is skipped on the failure path: CLBOSS already has authoritative knowledge of local channel state via listpeerchannels, and the shared askrene layer exists for cross-subsystem knowledge that is not already authoritatively available locally. No new RPC types, no new error paths. Boss::Mod::AskreneLayer:: inform_channel_succeeded was already added in the earlier shared-helpers refactor commit anticipating exactly this use.
2026-05-21 11:44:01 -07:00
*
* Record a positive lower-bound observation on chan1:
* we just proved this directed channel can push at
* least amount1 right now. askrene's
* inform=unconstrained mode stores this as a
* constraint with min=amount, max=NULL -- exactly
* the lower-bound raise we want for future getroutes
* calls to trust this channel more.
*
* (Askrene also has an inform=succeeded mode but that
* branch is currently a no-op stub in askrene.c
* "FIXME: We could do something useful here!" -- so
* we follow xpay's convention of using
* inform=unconstrained for the post-success
* lower-bound-raise pattern.)
*
* We do NOT record a parallel observation on chan0
* (our outbound channel to peer). CLBOSS already
* has authoritative knowledge of local channel state
* via listpeerchannels; the shared askrene layer is
* for cross-subsystem knowledge that isn't already
* locally available.
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-21 10:54:16 -07:00
*/
auto const WIRE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS
= std::uint16_t(0x400F);
ActiveProber: feed inform=unconstrained on probe success The previous commit fed only the FAILURE side: probes that hit WIRE_TEMPORARY_CHANNEL_FAILURE etc. wrote constraints into the clboss layer. Successful probes (id1 returned WIRE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, the expected outcome) returned bool=true and did not touch the layer. This commit adds the symmetric positive feed: when a probe succeeds, call askrene-inform-channel inform=succeeded on chan1 (peer's outgoing channel to id1) at amount1. askrene treats this as a raised lower-bound on the directed channel's capacity estimate, so future getroutes calls assign chan1 a higher success-probability score. Why this is the dominant signal source in practice: Failures are stochastic and bounded (one channel-or-node can only fail a few times before askrene routes around it entirely). Successes are repeated -- the same chan1 can push the same amount1 many times, raising the lower bound monotonically. Over a day of probes, the success-feed contribution to the layer's expected-flow estimate vastly exceeds the failure-feed contribution. Symmetric with the failure path on which channels we feed: Only chan1 (peer's outbound to id1) is fed. chan0 (our outbound to peer) is deliberately skipped on the success path for the same reason it is skipped on the failure path: CLBOSS already has authoritative knowledge of local channel state via listpeerchannels, and the shared askrene layer exists for cross-subsystem knowledge that is not already authoritatively available locally. No new RPC types, no new error paths. Boss::Mod::AskreneLayer:: inform_channel_succeeded was already added in the earlier shared-helpers refactor commit anticipating exactly this use.
2026-05-21 11:44:01 -07:00
if (fail == WIRE_INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS) {
return Boss::Mod::AskreneLayer::inform_channel_unconstrained(
rpc, layer_name, chan1, direction1, amount1
).then([]() {
return Ev::lift(true);
});
}
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-21 10:54:16 -07:00
/* 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();
Matchmaker, ActiveProber: migrate getroute -> getroutes for CLN v26.06 compat Continuation of the v26.06 migration started in the Dowser commit. Two more getroute call sites: ChannelCandidateMatchmaker.cpp and ActiveProber.cpp. Both use maxparts=1 since each wants a single route, not a flow estimate. ActiveProber probes the local node's own outbound liquidity, so it uses layers=["auto.localchans","auto.sourcefree"] per the standardized recipe. Matchmaker probes from a remote source (a candidate patron) to a remote target, so it passes an empty layers array -- auto.localchans would inject our private local channels into a foreign source, and auto.sourcefree would zero out the source's outgoing fees, either of which could make askrene pick a patron the proposal cannot actually reach via public topology. maxfee_msat is 1% of the probe amount in both cases. Matchmaker is a near-clean swap. The patron id (route[0].id in the old getroute shape) is read from routes[0].path[0] in the new getroutes shape; at the parse site we bridge the v26.06+ name (node_id_out) with its deprecated pre-v26.06 predecessor (next_node_id) via has()/ternary, so the code works on either CLN flavor. ActiveProber is more involved. The previous code stashed res["route"] as a Jsmn::Object and later appended hops from it directly into the sendpay route parameter, relying on the fact that the old getroute hop shape (id/channel/direction/ amount_msat/delay/style) was already sendpay-compatible. The new getroutes path[] shape is NOT directly sendpay-compatible (different field names, short_channel_id_dir encodes scid and direction together), so we now extract path[0] into typed values (id1, chan1, direction1, amount1, delay1) and rebuild the sendpay hop1 explicitly from those. The Jsmn::Object route member is dropped. The hop-field reads in ActiveProber bridge between v26.06+ (node_id_out / amount_out_msat / cltv_out) and the deprecated pre-v26.06 names (next_node_id / amount_msat / delay) via the same has()/ternary pattern. short_channel_id_dir splits on '/' with an explicit npos check; a missing slash throws Jsmn::TypeError so the surrounding parse- error log path catches it cleanly. Without the check, sdir.substr(0, npos) and sdir.substr(npos + 1) would feed malformed input into Ln::Scid (which throws std::invalid_argument, not caught by the Jsmn::TypeError handler) or into std::stoul (silently producing a wrong direction). ActiveProber also drops its vestigial exclude=[self_id] parameter: askrene's source/destination model naturally excludes self when source != self, which is always the case here (the probe always flows from peer outward, never back through us). Three of the four getroute call sites in CLBOSS are now on getroutes; the fourth (FundsMover/Attempter) is deferred to PR2 because its exclude-vector pattern encodes failure feedback from real payment attempts and warrants a redesign around askrene-inform-channel rather than a mechanical port.
2026-05-19 13:40:52 -07:00
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();
Matchmaker, ActiveProber: migrate getroute -> getroutes for CLN v26.06 compat Continuation of the v26.06 migration started in the Dowser commit. Two more getroute call sites: ChannelCandidateMatchmaker.cpp and ActiveProber.cpp. Both use maxparts=1 since each wants a single route, not a flow estimate. ActiveProber probes the local node's own outbound liquidity, so it uses layers=["auto.localchans","auto.sourcefree"] per the standardized recipe. Matchmaker probes from a remote source (a candidate patron) to a remote target, so it passes an empty layers array -- auto.localchans would inject our private local channels into a foreign source, and auto.sourcefree would zero out the source's outgoing fees, either of which could make askrene pick a patron the proposal cannot actually reach via public topology. maxfee_msat is 1% of the probe amount in both cases. Matchmaker is a near-clean swap. The patron id (route[0].id in the old getroute shape) is read from routes[0].path[0] in the new getroutes shape; at the parse site we bridge the v26.06+ name (node_id_out) with its deprecated pre-v26.06 predecessor (next_node_id) via has()/ternary, so the code works on either CLN flavor. ActiveProber is more involved. The previous code stashed res["route"] as a Jsmn::Object and later appended hops from it directly into the sendpay route parameter, relying on the fact that the old getroute hop shape (id/channel/direction/ amount_msat/delay/style) was already sendpay-compatible. The new getroutes path[] shape is NOT directly sendpay-compatible (different field names, short_channel_id_dir encodes scid and direction together), so we now extract path[0] into typed values (id1, chan1, direction1, amount1, delay1) and rebuild the sendpay hop1 explicitly from those. The Jsmn::Object route member is dropped. The hop-field reads in ActiveProber bridge between v26.06+ (node_id_out / amount_out_msat / cltv_out) and the deprecated pre-v26.06 names (next_node_id / amount_msat / delay) via the same has()/ternary pattern. short_channel_id_dir splits on '/' with an explicit npos check; a missing slash throws Jsmn::TypeError so the surrounding parse- error log path catches it cleanly. Without the check, sdir.substr(0, npos) and sdir.substr(npos + 1) would feed malformed input into Ln::Scid (which throws std::invalid_argument, not caught by the Jsmn::TypeError handler) or into std::stoul (silently producing a wrong direction). ActiveProber also drops its vestigial exclude=[self_id] parameter: askrene's source/destination model naturally excludes self when source != self, which is always the case here (the probe always flows from peer outward, never back through us). Three of the four getroute call sites in CLBOSS are now on getroutes; the fourth (FundsMover/Attempter) is deferred to PR2 because its exclude-vector pattern encodes failure feedback from real payment attempts and warrants a redesign around askrene-inform-channel rather than a mechanical port.
2026-05-19 13:40:52 -07:00
/* 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));
2020-11-02 19:02:35 +08:00
}).then([](Jsmn::Object _) {
/* Oh look, we succeeded.
* Should not happen though.
*/
return Ev::lift(true);
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-21 10:54:16 -07:00
}).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
});
});
}
}}