From 31bbf093d758e2be112698eeef812f15acf86856 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Thu, 25 Jun 2026 14:14:21 -0700 Subject: [PATCH] FundsMover: gate rebalances below clboss-min-rebalance-ppm Decline a rebalance whose fee budget, as ppm of the amount being moved, is below a configurable floor, before any work begins. When a RequestMoveFunds arrives with fee_budget/amount under the floor, FundsMover emits the zero ResponseMoveFunds that Runner::finish() would have produced after giving up, without creating a Runner, calling getroutes, or fanning out the split-retry cascade. This is the lever the classic rebalancer was missing. Over a week of production (2026-06-18 onward) only 78 FundsMover deliveries succeeded, and their effective feerate has a hard floor around 50 ppm: only ~8% cleared below 40 ppm, median about 150 ppm. Meanwhile the two highest-traffic drains (LNBiG, LQWD-England) get budgeted at ~32-39 ppm, below that floor, so they delivered nothing while firing ~1500 doomed JIT rebalances a day, each fanning out into hundreds of fast-fail / 204 / 205 sub-attempts that pinned askrene at 4/4 for hours. The gate refuses those moves at the source. The option is dynamic (setconfig-tunable) so the floor can be swept at runtime, modeled on clboss-classic-layer-age-secs. Default 50; set to 0 to disable and attempt every requested move. This supersedes the two earlier storm band-aids (the per-(source,sink) Unaffordable cache and the maxfee=0 fast-fail), which tried to make the doomed storm cheaper instead of stopping it: the cache suppressed only ~22% of moves (blind to the 204 failure mode, and its amount/ppm domination did not generalize across the varying request stream), and the fast-fail only trimmed the LNBiG maxfee=0 splits. The gate subsumes both by stopping sub-floor moves before they start. --- Boss/Mod/FundsMover/Main.cpp | 114 +++++++++++++++++++++++++++++++++++ README.md | 17 ++++++ 2 files changed, 131 insertions(+) diff --git a/Boss/Mod/FundsMover/Main.cpp b/Boss/Mod/FundsMover/Main.cpp index bb1c581..20817eb 100644 --- a/Boss/Mod/FundsMover/Main.cpp +++ b/Boss/Mod/FundsMover/Main.cpp @@ -12,6 +12,7 @@ #include"Boss/Msg/OptionType.hpp" #include"Boss/Msg/ProvideDeletablePaymentLabelFilter.hpp" #include"Boss/Msg/RequestMoveFunds.hpp" +#include"Boss/Msg/ResponseMoveFunds.hpp" #include"Boss/Msg/SolicitDeletablePaymentLabelFilter.hpp" #include"Boss/Msg/TimerRandomHourly.hpp" #include"Boss/concurrent.hpp" @@ -55,6 +56,16 @@ private: * age_clboss_layer() for the aging mechanics and rationale. */ std::uint64_t aging_window_secs = std::uint64_t(21600); + /* Minimum fee budget, as ppm of the moved amount, worth attempting + * a rebalance at. A requested move whose fee_budget/amount is below + * this is declined up front -- no Runner, no getroutes, no split- + * retry storm -- because classic rebalances essentially never clear + * below ~50 ppm: the high-traffic drains get budgeted near 35 ppm and + * deliver nothing while saturating askrene. Dynamic via + * clboss-min-rebalance-ppm; default 50. Set to 0 to disable the gate + * and attempt every requested move. */ + std::uint64_t min_rebalance_ppm = std::uint64_t(50); + void start() { bus.subscribe([this](Msg::Init const& init) { rpc = &init.rpc; @@ -81,6 +92,22 @@ private: "layer has the analogous " "clboss-xrebalance-age-secs.", /* dynamic = */ true + }) + + bus.raise(Msg::ManifestOption{ + "clboss-min-rebalance-ppm", + Msg::OptionType_Int, + Json::Out::direct(min_rebalance_ppm), + "Minimum fee budget, in ppm of the moved amount, " + "worth attempting a rebalance at. A move whose " + "requested fee_budget/amount is below this is " + "declined immediately -- no route solve, no " + "split-retry storm -- because classic rebalances " + "essentially never succeed below this rate. " + "Dynamic: settable at runtime via `lightning-cli " + "setconfig clboss-min-rebalance-ppm `. " + "Default 50; set to 0 to disable (attempt every " + "requested move).", + /* dynamic = */ true }); }); bus.subscribe([this](Msg::Option const& o) { + if (o.name != "clboss-min-rebalance-ppm") + return Ev::lift(); + /* Number at startup, string via setconfig -- the same + * dual encoding clboss-classic-layer-age-secs handles. + * Signed so a negative value is rejected below rather + * than wrapping to a huge unsigned. */ + long long ppm = 0; + try { + if (o.value.is_number()) { + ppm = static_cast(double(o.value)); + } else if (o.value.is_string()) { + ppm = std::stoll(std::string(o.value)); + } else { + return Boss::log( bus, Warn + , "FundsMover: clboss-min-" + "rebalance-ppm: unsupported " + "value type; keeping %" + PRIu64 "." + , min_rebalance_ppm + ); + } + } catch (std::exception const& e) { + return Boss::log( bus, Warn + , "FundsMover: clboss-min-rebalance-" + "ppm: parse error '%s'; keeping %" + PRIu64 "." + , e.what() + , min_rebalance_ppm + ); + } + if (ppm < 0) { + return Boss::log( bus, Warn + , "FundsMover: clboss-min-rebalance-" + "ppm: must be >= 0; keeping %" + PRIu64 "." + , min_rebalance_ppm + ); + } + min_rebalance_ppm = std::uint64_t(ppm); + return Boss::log( bus, Info + , "FundsMover: min rebalance budget = %" + PRIu64 " ppm" + , min_rebalance_ppm + ); + }); bus.subscribe([this](Msg::RequestMoveFunds const& m) { auto msg = std::make_shared(m); @@ -166,6 +240,46 @@ private: "Contact " PACKAGE_BUGREPORT ); } + /* Decline a rebalance whose fee budget is below + * clboss-min-rebalance-ppm: classic rebalances + * essentially never clear below this rate, so skip + * the whole Runner / getroutes / split-retry + * machinery and emit the zero ResponseMoveFunds + * that Runner::finish() would have produced after + * giving up. Cross-multiplied to avoid a divide: + * fee_budget/amount < min_ppm/1e6 iff + * fee_budget*1e6 < min_ppm*amount. min_ppm == 0 + * disables the gate (the test is never true). */ + if ( min_rebalance_ppm > 0 + && double(msg->fee_budget.to_msat()) * 1000000.0 + < double(min_rebalance_ppm) + * double(msg->amount.to_msat()) + ) { + auto src_pfx = + std::string(msg->source).substr(0, 8); + auto dst_pfx = + std::string(msg->destination).substr(0, 8); + return Boss::log( bus, Debug + , "FundsMover: not moving %s " + "from %s... to %s... -- fee " + "budget %s is below clboss-min-" + "rebalance-ppm=%" PRIu64 "; " + "never clears this cheap." + , std::string(msg->amount).c_str() + , src_pfx.c_str() + , dst_pfx.c_str() + , std::string(msg->fee_budget) + .c_str() + , min_rebalance_ppm + ) + + bus.raise(Msg::ResponseMoveFunds{ + msg->requester, + Ln::Amount::sat(0), + Ln::Amount::sat(0), + msg->source, + msg->destination + }); + } auto runner = Runner::create( bus , *rpc , self_id diff --git a/README.md b/README.md index 6db331d..5d2847a 100644 --- a/README.md +++ b/README.md @@ -599,6 +599,23 @@ startup default, or at runtime with lightning-cli setconfig clboss-classic-layer-age-secs +### `--clboss-min-rebalance-ppm=` + +Sets the minimum fee budget, expressed as parts-per-million of the amount +being moved, at which the classic rebalancer will bother attempting a +rebalance. When a requested move's budget (`fee_budget / amount`) is below +this, FundsMover declines it immediately — no route solve, no split-retry — +rather than spending effort on a move that almost never succeeds at that +rate. + +The default is `50`. Set it to `0` to disable the gate and attempt every +requested move. + +This is a *dynamic* option: set it in the `lightningd` config for the +startup default, or at runtime with + + lightning-cli setconfig clboss-min-rebalance-ppm + ### `--clboss-min-nodes-to-process=` Sets the minimum number of nodes that CLBOSS must know about before it