Commit graph

457 commits

Author SHA1 Message Date
Ken Sedgwick
f6f7070da8
AmountSettingsHandler: enforce the Planner's channel size precondition (#147)
Some checks failed
Code Base Sanity Check / tests (push) Has been cancelled
Code Base Sanity Check / coverage (push) Has been cancelled
Code Base Sanity Check / build-clang (push) Has been cancelled
The ChannelCreator Planner asserts
min_channel + min_remaining <= max_channel at construction, where
min_remaining = 2 * min_channel + 20000 sats.  Option validation
only enforced max_channel >= 2 * min_channel, so a pair such as
min-channel=1000000 max-channel=2000000 passed startup and aborted
the plugin on the first channel-creation run (#147).  The old 2x
clamp never prevented the crash: any pair it adjusted still
violated the planner precondition.

AmountSettingsHandler now enforces the precondition directly.  On
conflict it keeps max-channel, which sets typical open size, and
lowers min-channel to the largest fitting value, logging a warning.
A max-channel too low for even the smallest permitted min-channel
is raised.

ChannelCreator::Manager also re-checks both planner preconditions
before constructing the Planner and skips the creation cycle with a
log line instead of aborting.  This covers the sibling assert
min_amount * 2 <= total, which fails when onchain funds change
between the decider's trigger and the creator's run (#137).
2026-08-11 10:48:26 -07:00
Ken Sedgwick
250fc3afaa
XRebalancer: drop the closest-failure note when parts delivered
Some checks are pending
Code Base Sanity Check / tests (push) Waiting to run
Code Base Sanity Check / coverage (push) Waiting to run
Code Base Sanity Check / build-clang (push) Waiting to run
The transfer-done line carried the closest-failure chokepoint even
when parts completed.  A completed part IS the frontier, so on any
success the near-miss is noise; the note now appears only on
transfer-failed lines.
2026-08-08 17:31:39 -07:00
Ken Sedgwick
794629bda3
XRebalancer: print cycle request amounts in msat
Some checks failed
Code Base Sanity Check / tests (push) Has been cancelled
Code Base Sanity Check / coverage (push) Has been cancelled
Code Base Sanity Check / build-clang (push) Has been cancelled
The [matched] and [demand] cycle lines printed request= in sat
while the transfer summary and per-part lines print msat, so
reading one cycle meant switching units mid-story.  Print the
request in msat too.  Display-only: the requested value stays
sat-denominated internally.
2026-08-08 16:07:11 -07:00
Ken Sedgwick
0428dbedb4
XRebalancer: note active gain/grant on cycle lines
Cycle lines print NetPpm values with gain and grant already folded
in, so a reader cannot tell measured-strict numbers from adjusted
ones.  Append the active benders to the matched and demand cycle
lines -- ", grant 1000, gain 1.2" -- shown only when non-neutral,
so strict operation keeps the plain format.
2026-08-05 19:56:06 -07:00
Ken Sedgwick
16f0786058
XRebalancer: summarize rounds, parts, elapsed, and probe rate per transfer
Some checks failed
Code Base Sanity Check / tests (push) Has been cancelled
Code Base Sanity Check / coverage (push) Has been cancelled
Code Base Sanity Check / build-clang (push) Has been cancelled
The transfer done/failed line reported the top-level parts array,
which multi-round plugin responses do not have -- every multi-round
transfer read "0 part(s)" no matter how many probes it sent.
Census the per-round parts arrays instead (the chokepoint scan now
spans the whole run), and add the response's rounds_run and
stop_reason plus the elapsed time measured around the RPC call and
the derived probe rate (parts/s, the ceiling on the learning rate:
fresh-frontier parts add constraint dirs, re-probes only refresh).

A failed transfer now reads like:
  transfer failed [req x]: 214 part(s) in 50 round(s) over 612s
  (0.35 parts/s); stop: no further routes: ...; closest failure: ...
which makes attempt effort, learning volume, and slot occupancy
visible from the one summary line.
2026-08-05 19:24:28 -07:00
Ken Sedgwick
0abfb24f7d
XRebalancer: sweep the demand cycle's offered-pool rung
Pricing the demand cycle at the offered pool's minimum NetPpm let
one outlier cap every budget: a peer whose past refill
expenditures ate its inbound earnings sat at near-zero net and
priced all cycles, while the pool median was an order of
magnitude higher.  Spending depresses a source's net rate, the
depressed rate becomes the minimum, and the minimum strangles the
next budget.

Instead, each demand cycle draws a rung p from {20, 50, 80},
offers only the top p% of the source pool by NetPpm, and prices
at the cheapest source actually offered.  The invariant is
unchanged at every rung: every sat moved earns at least the
target's side plus at least its source's side.  A narrow rung
offers premium sources at a rich budget; a wide rung offers most
of the pool at a lean one.  Demand re-triggering redraws the
rung, so which rung delivers is learned from live traffic -- the
same sweep methodology as the auto floor ladder.  The drawn rung
is logged in the [demand] cycle line.
2026-08-05 12:48:42 -07:00
Ken Sedgwick
a612d759dd
DemandTracker: trigger demand rebalance cycles from observed forwards
Some checks are pending
Code Base Sanity Check / tests (push) Waiting to run
Code Base Sanity Check / coverage (push) Waiting to run
Code Base Sanity Check / build-clang (push) Waiting to run
New module DemandTracker registers an htlc_accepted deferrer that
never holds an HTLC: for each forward it raises DemandObserved
naming the outgoing channel, then immediately declines.  The
message deliberately carries no amount: unforwardable probe HTLCs
cost an attacker nothing, so sizing from a demanded amount would
be a free lever over our spend.

XRebalancer consumes the message.  A trigger arriving while any
cycle runs is discarded (traffic recurrence re-arms real demand);
otherwise it runs a demand cycle: the same fetch/join pipeline as
a matched cycle, targeting the peer whose channel the forward
exited through.  Fill-pool membership is the entire criterion --
demand controls when we rebalance, never who qualifies or how
much.  The request restores the peer to the fill edge, priced at
target NetPpm plus the minimum offered NetPpm, executed through
the existing executor with cycle tag [demand].

The new in_flight flag serializes matched and demand cycles; both
paths clear it behind a catch-all so an exception cannot wedge
it.  The catch-all around the matched tick also keeps the Poisson
loop alive on RPC errors, which previously terminated it with
only a stderr notice.
2026-08-05 10:59:08 -07:00
Ken Sedgwick
f84c967769
XRebalancer: remove focused cycles
The background rebalancer now runs matched-pool cycles only.
Focused cycles were random-census discovery; the demand tracker
replaces them with the same cycle shape (whole opposite pool ->
one target, priced at target NetPpm + minimum offered NetPpm)
triggered by observed forwards instead of a random draw.

Removes the style draw, plan_focused, and the
clboss-xrebalance-focused-frac option.  Scrub that option from
setconfig files before deploying this build, or lightningd will
refuse to start.
2026-08-05 10:22:07 -07:00
Ken Sedgwick
166b4efbaf
XRebalancer: request the full matched volume
Some checks are pending
Code Base Sanity Check / tests (push) Waiting to run
Code Base Sanity Check / coverage (push) Waiting to run
Code Base Sanity Check / build-clang (push) Waiting to run
Remove clboss-xrebalance-size-factor and its per-cycle lo:hi random
sweep.  The matched-pool cycle now requests the full matched volume
that cleared the floor.  Both purposes in the option's help text no
longer apply: the per-channel band-edge caps in the request lists
bound what each channel absorbs, and per-request askrene layers
removed the accumulated route state the size sweep kept from staling.
The focused cycle was already sized flat (target deficit).  The
clboss-xrebalance-view emulation drops --size-factor to match.

Configs that set the option must drop it before starting this build
(an unknown plugin option fails lightningd startup).
2026-08-04 12:39:51 -07:00
Ken Sedgwick
f1b37c5da5
ChannelCreator: prefer spliceable peers within the no-record tier
The track-record pass orders open proposals keepers, no-record,
underperformers.  The no-record tier carries no earnings evidence,
so order it by a capability prior: nodes whose node_announcement
advertises splicing (BOLT 9 option_splice, bits 62/63) come first,
since their channels can later be resized without a close and
reopen.  Keepers and underperformers keep their earnings-based
order, and nothing moves across a tier boundary.

New Ln::feature_bit tests a bit in a BOLT 9 hex bitfield (bit 0 is
the least-significant bit of the last byte), with a unit test.
Spliceability is looked up per candidate via listnodes; an RPC
failure or absent features field counts as not spliceable, so the
lookup cannot block channel creation.  The Track records report
marks spliceable no-record nodes with an (S) suffix.
2026-08-04 11:02:22 -07:00
Ken Sedgwick
732528ea24
XRebalancer: cap each request channel at its band-edge share
Pass per-scid caps to the xrebalance plugin (object-form request
entries with max_msat), so however the MCF concentrates the flow,
no peer is pushed past its band edge in one cycle.

Each pool peer's band-edge deficit (tgt_fill / tgt_drain) is
distributed over its channels as caps, weighted by each channel's
own headroom past the edge: the sum of a peer's caps never exceeds
its deficit -- candidacy, deficits, and progress stay at peer
granularity per the non-strict-forwarding doctrine -- while the
weighting points flow at the peer's skewed channels.  Peer now
carries its Chan rows so per-channel balances survive to
request-building.

Caps under 1000 sat are dropped along with their channel: below
askrene's ~1000-sat single-path threshold a cap smaller than the
amount excludes the channel from the solve anyway.  Consequence:
peers whose entire deficit is under ~1000 sat are no longer cycle
candidates (previously they were admitted and churned).

The clboss-xmovefunds path (mode xrebalance) has no cap concept
and still receives bare scids.  The debug request line now shows
scid:cap_sat per entry, and the plugin's effective_amount_msat is
surfaced as "(ask N capped to M msat)" when the request clamps.

Requested amounts are unchanged; the plugin clamps to the cap sums
(less fee headroom).  Strategy knobs (size_factor, per_hour,
focused fraction) are deliberately untouched -- simplifying them
now that per-channel overshoot is impossible is a follow-up.

Full test suite passes (88/88).
2026-08-04 11:02:21 -07:00
Ken Sedgwick
dd35b3c6a7
XRebalancer: aggregate candidacy and deficits per peer
Non-strict forwarding (BOLT 4) lets a peer land an incoming HTLC on
any parallel channel to us, so a per-channel fill deficit against a
multi-channel peer can never be settled: observed live on lab0-a,
where three completed fills explicitly targeting 307026x63x0 all
landed on its 2M sibling 310533x17x0 and the planner relooped the
same 56_673 sat request indefinitely, paying real fees for zero
progress.

Candidacy, deficits, and progress now live at peer granularity --
the granularity NetPpm, the EarningsTracker, and the network's
delivery guarantee already share.  Channels stay in the mechanics:
a picked peer contributes all its scids to the request lists
(sources we control exactly; destinations the peer resolves anyway).
A peer with one full and one empty channel nets out balanced and is
left alone, which is honest: intra-peer skew is exactly what
non-strict forwarding puts beyond our control.

The view mirrors the aggregation end to end: tier membership and
TgtFill/TgtDrain are peer aggregates (repeated on each row of a
multi-channel peer, deduplicated in totals), pools hold one entry
per peer, and bold/emitted commands expand to all of a picked
peer's channels.
2026-08-04 11:02:20 -07:00
Ken Sedgwick
ec9eb0ae45
XRebalancePartMonitor: parse first_hop/return_hop as scidds
Found live via a probe subscriber: the plugin reports first_hop and
return_hop as SCIDDs ("305293x6x2/1"), but the monitor fed them to
Ln::Scid, which throws BacktraceException<std::invalid_argument> --
and the handler's catch(std::runtime_error) does not cover
logic_error, so every completed part died silently between the amount
parse and the attribution: no Error line, no Warn, no accounting.
The unit test's hand-written payloads used plain scids and kept
passing.

Strip the direction suffix before the Ln::Scid parse (the mapper keys
on the channel alone), widen the catch to std::exception so a future
payload surprise logs instead of vanishing, and reshape the test
payloads to the live-captured form, direction suffixes included.
2026-08-04 11:02:19 -07:00
Ken Sedgwick
9f28c169d0
XRebalancer: fill/drain targets follow the band options
The deficits aimed at compile-time 25%/75% targets while the
fill-loc/drain-loc options only moved the admission bands, so any
band set past its target was a dead knob: channels between band and
target had zero deficit and never became candidates -- observed live
on lab0-a, where a converged system (every fill channel parked at
exactly 25%) could not be reawakened by widening the bands.

The band edge is now also the target: it admits a channel AND
defines where its rebalancing stops.  Bands set to overlap would let
one channel qualify for both pools; fill wins so a channel is never
picked against itself.

The view mirrors all of it: TgtFill/TgtDrain and the JIT fill target
derive from the live band values, the drain pool excludes fill-pool
members, and the empty-pool footer diagnostics now name the actual
cause (the old NO_FILL_CANDIDATES text blamed OutNetPpm even when
every band channel had positive rates and the pool was empty for
lack of deficit).
2026-08-04 11:02:19 -07:00
Ken Sedgwick
fc65da730f
XRebalancePartMonitor: read the part payload directly from params
Custom notifications are relayed by lightningd with the sender's
payload verbatim as params (origin rides as a sibling field, outside
params).  The topic-key nesting the monitor descended through --
params["xrebalance_part"] -- is a built-in-topic convention
(forward_event et al) that custom topics do not get, so every part
notification missed the has() guards and was silently dropped: the
first live delivery (req d7371cfd, 224_294 sat at 1_009 ppm) went
unattributed.  Read params as the payload; the unit test now feeds
the true delivery shape (verified against lightningd's
plugin_notification_handle, which json_add_tok's the sender's params
straight through).
2026-08-04 11:02:18 -07:00
Ken Sedgwick
346181eace
XRebalancer: grant and gain options bend planner strictness
Signet corridors price at thousands of ppm while windowed earnings
rates are hundreds, so the strict planner (correctly) never fires
there.  Two neutral-by-default dynamic options let an operator bend
it:

clboss-xrebalance-grant (default 0): credit every channeled peer an
assumed prior of grant ppm on one capacity-turn of volume, both
sides: (e - x + cap*grant/1e6) / (f + cap).  A peer with no record
reads exactly grant, so cold channels become candidates;
expenditures spend the credit down, bounding the subsidy per peer
at grant x capacity plus real earnings; real volume dilutes the
prior toward the measured rate.

clboss-xrebalance-gain (default 1): multiply the joined NetPpm on
both sides before candidacy, floor, and maxfee pricing, accepting
routes that cost up to gain x the measured rate.

Defaults preserve the strict behavior exactly.
2026-08-04 11:02:16 -07:00
Ken Sedgwick
189fef36eb
XRebalancer: xrebalance2 mode drives the external xrebalance plugin
A third clboss-rebalance-mode value, xrebalance2, runs the same
XRebalancer planner but executes cycles through the external
xrebalance plugin's RPC instead of the in-clboss clboss-xmovefunds
executor.  The plugin does the layer splitting on stock askrene and
owns constraint knowledge and failure feedback, so the in-clboss
layer machinery (including the predictor) stays idle in this mode.

The new XRebalancePartMonitor subscribes to the plugin's
xrebalance_part notifications and raises Msg::XRebalanceAttribution
for each completed part, so EarningsTracker accounts plugin-moved
funds regardless of which client initiated the transfer.
Attribution is notification-only: a part that reaches terminal
state while clboss is down goes unaccounted.

Subscribing to the topic is safe without the plugin loaded
(lightningd only warns about unknown notification topics), and a
cycle fired with the plugin missing logs one line and retries next
cycle.
2026-08-04 11:02:15 -07:00
Ken Sedgwick
0aecb8bcc3
ChannelCreator: fund candidates in track-record tier order
Some checks failed
Code Base Sanity Check / tests (push) Has been cancelled
Code Base Sanity Check / coverage (push) Has been cancelled
Code Base Sanity Check / build-clang (push) Has been cancelled
After the size rearranger and the IP-binning reprioritizer, partition
the proposal list by track-record verdict: keepers first, then
candidates with no record, then underperformers.  Within a tier the
earlier stages' order is preserved.  Because the Planner consumes
proposals in order until funds run out, placing a tier last implements
'only used if no better candidate can absorb the funds' without an
outright veto.  The partition runs last so the earlier perturbation
stages cannot promote a candidate across a tier boundary.

Logs one Info line per creation request with the per-tier membership
and each judged candidate's TRAL and observed days.
2026-08-04 11:02:15 -07:00
Ken Sedgwick
a83489ab03
PeerTrackRecord: judge channel candidates by past earnings
New module that judges the earnings track record of nodes we had
channels with before, so channel-open candidate selection can prefer
proven earners.  The metric is TRAL (annualized net return on
liquidity, in basis points), the same metric as
contrib/clboss-forwarding-stats: net earnings from the EarningsTracker
daily buckets, divided by the average balance and the observed
operational days from the FeeMonitor records, annualized.  Both
sources persist after a channel closes, which is exactly the case this
serves: candidates whose previous channel with us is gone.

Operational days count only in-window records (roughly one per hour
while a channel exists), so a channel that overlapped the window
partially is annualized over its actual operating time.  Averaging the
balance per-record rather than sample-and-hold over wall-clock time
keeps a mid-window close from diluting the average.

Verdicts: keeper (TRAL at or above threshold), no-record (no or
insufficient history), underperformer (history below threshold).

Three dynamic options (runtime-settable via setconfig):
clboss-candidate-record-window-days (180),
clboss-candidate-keeper-tral-bps (50),
clboss-candidate-min-record-days (7).

A clboss-track-record nodeid command shows the verdict and its inputs
for one node, plus the current option values, for tuning at runtime.
2026-08-04 11:02:14 -07:00
Ken Sedgwick
80dc923233
ChannelCandidateInvestigator: fix dead fallback in get_for_channeling
The fallback path (taken when no candidate has a positive score)
iterated the already-consumed res1 result set instead of res2, so it
always returned an empty list and the fallback never produced any
proposals.  Iterate res2.  Adds a regression test exercising the
zero/negative-score fallback.
2026-08-04 11:02:13 -07:00
Ken Sedgwick
39ac3a7dc6
XMoveFunds: take the return channel from the translated askrene response
Some checks failed
Code Base Sanity Check / tests (push) Has been cancelled
Code Base Sanity Check / coverage (push) Has been cancelled
Code Base Sanity Check / build-clang (push) Has been cancelled
The reviewed askrene circular patch translates the final hop of every
circular route back to real terms before returning: node_id_out is
our own id and short_channel_id_dir names the actual return channel
the route was priced against.  With parallel channels to the same
fill peer, only askrene knows which one it chose -- matching by peer
could pick the wrong parallel channel, mis-keying the plan and the
per-channel bookkeeping.

Trust the response instead of reconstructing it: delete
find_fill_scid (guess-by-peer) and build_sendpay_route's final-hop
rewrite, so every hop maps 1:1 into sendpay format.  In their place,
validate the final hop before sending: node_id_out must be our own
id (an untranslated hop means this clboss is paired with a CLN
lineage older than the reviewed patch) and the return channel must
be among dest_scids (structurally guaranteed by the transient masks;
refusing catches the impossible).  The channels listing is no longer
needed at execute time, so do_execute drops the parameter.
2026-08-04 11:02:12 -07:00
Ken Sedgwick
8202b65321
XMoveFunds: request circular routes via the auto.allow_circular layer
The upstream review of the circular askrene patch replaced the
allow_circular parameter with an auto.allow_circular layer, so the
opt-in now travels in the layers array like the other automatic
layers.  All our deployments move to the reviewed lineage together,
so there is no compatibility shim: the parameter is gone from the
call and the layer is always sent.

This clboss therefore requires a CLN whose circular patch is at the
reviewed interface; older lineages reject the unknown layer name.
CLN and clboss upgrade together on each node (clboss restarts with
lightningd anyway), so the pairing is enforced by deployment.
2026-08-04 11:02:12 -07:00
Ken Sedgwick
c8fdd5ca20
XMoveFunds: exclude the final hop from success feedback
The middle-hop extraction that feeds inform-unconstrained after a
part settles filters hops by membership in our_scids only.  The
final hop of a circular askrene route is named by the fake mirror
scid the patched askrene synthesizes, which our_scids cannot catch,
so every settled part wrote an unconstrained entry for a channel
that does not exist into the persistent xrebalance layer.

Skip the final hop explicitly: it is the cycle's return into our
own fill channel, and auto.localchans owns local capacity truth,
so there is nothing to reinforce there regardless of how the hop
is named.  This also makes the extraction match its comment's
stated intent (keep the path through to the last forwarder).
2026-08-04 11:02:11 -07:00
Ken Sedgwick
d74c4467e7
XMoveFunds: pass allow_circular=true to getroutes
The reworked circular-askrene patch (80032d5c9, now deployed on lab0
and lab0-a as cln-v26.06-87-ga28740314) made circular routing an
explicit opt-in: getroutes rejects source == destination unless the
caller passes allow_circular=true.  XMoveFunds's getroutes call is
always circular (source = destination = self), so every xrebalance
delivery attempt on the new CLN failed with "source and destination
must be different".

Pass allow_circular=true unconditionally in call_getroutes.
2026-08-04 11:02:10 -07:00
Ken Sedgwick
47a5a1c830
Ln/OnionError: shared onion-failure parser; XMoveFunds adopts the hardened version
XMoveFunds/Main.cpp carried a duplicate of FundsMover/Attempter.cpp's
parse_chan_update that had drifted behind the PR320 deep-review
hardening: it lacked the fee_proportional_millionths > 1e6 reject, its
TLV bounds check (tpos + tlen > cu_size) could wrap on an
attacker-supplied bigsize length, and it accepted inbound-fee TLVs of
any length >= 8 instead of exactly 8.

Extract the parser into a new shared module, Ln/OnionError, carrying
the hardened semantics:

  - failcode_name(): BOLT 04 failcode -> grep-friendly name
    (previously Track-A-only).
  - ChanUpdate: the parsed channel_update policy subset askrene
    consumes, plus the bLIP-18 inbound-fee TLV fields, and an
    operator== that deliberately compares only the askrene-visible
    policy (the repeat-update detection use case).
  - parse_chan_update(): extract the embedded BOLT 07 channel_update
    from a sendpay 204 raw_message, with the prop clamp doubling as
    an overflow guarantee for callers' ceil(amt * prop / 1e6) math.

XMoveFunds switches to the shared module via using-declarations, so
call sites are unchanged; its three 204 log lines now print the
failcode name next to the hex.  Also correct a stale comment above
failure_summary() claiming the channel_update-refresh branch is
skipped -- it has existed since the FEE_INSUFFICIENT dissection work;
only the in-call retry is (deliberately) absent.

FundsMover/Attempter.cpp intentionally keeps its own copy: Track A is
being treated as done, and switching it to Ln/OnionError is deferred
to the next time Track A is touched for its own reasons.

New unit test tests/ln/test_onionerror covers the onion wire format
for all carrying failcodes, both type-prefix forms, the prop clamp
boundary (1e6 accepted, 1e6+1 rejected), signed inbound-fee values,
exact-8 TLV length enforcement, and the bigsize-wrap attack that the
old Track B bounds check would have mishandled.  86/86 tests pass.
2026-08-04 11:02:09 -07:00
Ken Sedgwick
19a9b2e848
xrebalance options: report setconfig rejections; harden the parses
Adopt the Msg::Option::reject() contract in the Track B dynamic-option
handlers, which predate it.  Without this, a rejected setconfig for an
xrebalance-family option was still acknowledged as success, so
lightningd persisted the never-applied value to config.setconfig --
including the junk-string case that then fails lightningd's own option
parse on the NEXT start.  Every reject branch in XRebalancer,
XRebalancePredictor (via its bad_option choke point, which now names
the specific constraint instead of 'could not parse value'),
XRebalanceHistory and XMoveFunds now reports a quote-free reason.

While in the handlers, close the same parse holes Track A's review
fixed plus a NaN family specific to these stod-based knobs:

- XRebalanceHistory and XRebalancePredictor::parse_u64 parsed via
  std::stoull, which accepts a leading minus and wraps modulo 2^64:
  setconfig -1 became ~1.8e19.  Signed parse + negative reject,
  matching the FundsMover/AskreneUpdates handlers.

- std::stod accepts 'nan' and 'inf'.  A NaN slips through every
  range/clamping comparison (all false) straight into the stored
  setting -- a NaN per_hour breaks the cycle timing, a NaN or inf
  part-wait-secs breaks the part wait, and casting non-finite to
  uint32 (maxparts) is undefined.  parse_double and all direct stod
  sites now require finite values, and maxparts clamps to
  [1, 1000000] before its uint32 cast.
2026-08-04 11:02:09 -07:00
Ken Sedgwick
758d1fe605
XMoveFunds: project learned updates into a private per-request layer
Rewire the xrebalance engine onto AskreneUpdates, mirroring the classic path.
XMoveFunds holds its own ReqResp; do_plan provisions a private uuid-named layer
(seeded from the still-fresh persisted updates) alongside its existing
self-mask transient -- ordered before it so the self-mask still wins -- and
removes both when the plan finishes. The three failure sites that recorded
node disables and channel_update overrides into the persistent
clboss-xrebalance layer -- where askrene-age never removes them -- now record
them via AskreneUpdates instead; do_plan routes once, so no in-plan layer
write is needed.
2026-08-04 11:02:08 -07:00
Ken Sedgwick
34589d8b1d
XMoveFunds: bound the synchronous part wait; settle stragglers in the background
A part whose HTLC is held in flight by some downstream node used to
freeze the whole xrebalance executor: do_execute waited on every part's
waitsendpay with no timeout, the XRebalancer driver awaits the cycle,
and the driver runs one cycle at a time -- so a single stuck HTLC paused
all cycles and all learning until it resolved (observed: a 633-sat part
held 40+ minutes).  There is no protocol way to cancel an in-flight
HTLC; it resolves on its own (settle, or fail at its CLTV, up to ~100s
of blocks away).

Bound the wait instead.  do_execute now waits synchronously for parts
only up to clboss-xrebalance-part-wait-secs (new dynamic option, default
180) from the start of the wait phase, via CLN's native waitsendpay
timeout.  A part still in flight at the deadline comes back as error 200;
rather than treating that as a routing failure (which would write a
bogus capacity constraint and delpay a live part), we DETACH it: a
background greenthread (background_wait_part via Boss::concurrent)
re-waits it with no timeout and applies the SAME askrene learning and
earnings attribution the foreground would have.  Because the Claimer
keeps each self-payment's claim entry for 24h, the detached part still
settles and is accounted -- no accounting gap, nothing discarded -- while
the driver loop is freed to start the next cycle.

Why bounded-synchronous rather than fully async: the synchronous phase
is what lets the next cycle re-plan on mostly-settled balances (it
re-fetches listpeerchannels each cycle) and what produces the
clboss-xmovefunds summary (delivered / fee / parts_complete) the manual
RPC and the cycle log report.  Attribution, askrene learning, delpay,
and the transient mask-layer removal do not require the wait, so
detaching a rare straggler costs only that one part's contribution to
the next re-plan -- self-correcting, and reserve-protected against
double-spend.

The reply gains a parts_detached count; the XRebalancer cycle log notes
"(N detached, settling in background)" so detached parts do not read as
failures.

Track A (FundsMover) is unaffected: it runs each attempt in its own
Boss::concurrent Runner, so a stuck attempt blocks only that Runner,
never the whole rebalancer, and its attribution already fires in the
background when the part settles.
2026-08-04 11:02:07 -07:00
Ken Sedgwick
d9f7b6a8a6
XMoveFunds: transit observations from the forwarded hops of failed parts
When a part fails at hop k, hops 0..k-1 provably forwarded the
HTLC -- a lower bound on each hop's liquidity exactly as strong
as a settled part's evidence.  Previously only the erring hop
produced any feedback and that forwarding proof was dropped.

Now every hop strictly before the failure point gets an
inform-unconstrained on the clboss-xrebalance layer plus a
history observation of a new kind, transit.  Transit is kept
distinct from success because the two decay differently: a
settled part consumes the liquidity it proves, while a failed
part unwinds and puts it back.  The predictor treats both as
floor-side lower bounds; the distinction exists so the planned
floor-factor calibration can measure proven-and-restored
separately from proven-and-consumed.

Details:
- own channels filtered out (auto.localchans owns their truth)
- amount_out_msat, matching the settled-part success informs
- for 0x100c the incoming hop route[k-1] may itself be blamed
  (inbound-fee PolicyFail), so transit stops one hop short on
  all 0x100c: never assert carried-fine and policy-excluded
  about the same hop from the same part
- failcode/erring_node are NULL for transit, as for success

Tests: kind_is_bound maps transit as a non-fail bound; a plan()
case where the floor bound comes from a transit record (larger
than the settled success beside it) proves transit feeds the
floor side.
2026-08-04 11:02:04 -07:00
Ken Sedgwick
c981d67cb6
XRebalancePredictor: the live persistence forecaster (off by default)
Phase 2 of the history+prediction design: the module that closes the
loop from observation to synthetic re-assertion.  After each hourly
askrene-age pass over the clboss-xrebalance layer, XMoveFunds now
raises Msg::XRebalanceLayerAged carrying the aging cutoff (on the
failure path too -- a skipped trim only leaves stale entries, which
is safe).  The new Boss::Mod::XRebalancePredictor subscribes, reads
the XRebalanceHistory observation store, runs the pure regime-walk
algorithm per channel direction, and re-asserts the surviving
walls/floors into the routed layer via askrene-inform-channel --
but only for directions whose newest real observation predates the
cutoff: directions with live evidence need no synthesis.  Synthetic
assertions are never recorded back into the observation store (no
self-confirmation).

OFF BY DEFAULT.  The master switch is the dynamic option
clboss-xrebalance-predict-horizon-max-secs (0 = disabled, the
default; 86400 is the intended enabled value -- and since an
asserted wall is never contradicted by routing, this cap IS the
wall re-test schedule).  The other constants are dynamic options
mirroring the read-only spot-check parameters: -horizon-frac (2.0),
-min-samples (2), -wall-margin (1.0), and -floor-factor, which
defaults to 0 = walls-only operation (floors are the riskier half:
a too-high floor attracts flow and costs a failed part to
self-correct).  Note the live floor default deliberately differs
from the spot-check commands' 0.9.  Also dormant unless
clboss-rebalance-mode is xrebalance.

The per-cycle decision is a pure static XRebalancePredictor::plan
(group directions, candidacy gate, predict, collect asserting
sides, skip amount-0 degenerates), unit-tested directly; the module
shell only reads the table, executes the plan, logs one Info
summary per asserting cycle, and reports an xrebalance_predictor
section (params + last-cycle counts) in clboss-status.
kind_is_bound (stored TEXT kind to bound side) is promoted into
XRebalancePredict and shared with XRebalanceHistory.
2026-08-04 11:02:03 -07:00
Ken Sedgwick
df0865f163
XRebalancePredict: pure persistence forecaster + read-only spot-check commands
The phase-2 prediction algorithm as a pure function
(Boss/Mod/XRebalancePredict): no I/O, no bus, no clock -- bounds in,
verdict out -- so unit tests are exact and the same code backs the
live predictor module (next commits).

Model: observations are interval bounds (success at m: liquidity >= m;
failure at attempted A: liquidity < A).  Walking records newest to
oldest and intersecting until contradiction yields the current regime
[B_lo, B_hi) -- the longest recent run consistent with one static
liquidity.  horizon = min(horizon_max_secs, horizon_frac x regime
evidence span), where the span grows only with actual observations,
never with wall-clock passage.  A side asserts while data_age <=
horizon and the regime holds min_samples records of its kind: the wall
is B_hi scaled by wall_margin (>= 1 biases errors high, which
self-corrects; too-low walls are sticky), the floor is B_lo scaled by
floor_factor (<= 1 conservative; 0 disables).  Defaults: frac 2.0,
cap 24h, min_samples 2 per side, margin 1.0, factor 0.9.  (With the
store recording ATTEMPTED amounts for failures, the wall re-assert
amount is B_hi directly -- inform constrained B_hi stores max =
B_hi - 1.)

Two read-only spot-check commands, so the constants are playable
against live production data with no setconfig, restart, or writes
(nothing is asserted into any layer):
  - clboss-xrebalance-history <scid> appends a predictions block:
    per direction, the regime stats (records, span, start, truncated),
    data age, horizon, and each side's would_assert / amount /
    decline_reason.
  - clboss-xrebalance-predictions [kind] runs the forecaster over
    EVERY channel direction in the store; kind selects walls / floors /
    asserting (default) / all.  Per-direction emission and override
    parsing are shared helpers.
Both accept keyword overrides horizon_frac, horizon_max_secs,
min_samples, wall_margin, floor_factor per query.

Zero-span declines explain themselves: several MPP parts of one flow
failing on the same hop in the same second are many bounds but a
single temporal sample (span 0, horizon 0, declines) -- correct
(correlated samples must not fake durability; min_samples gates count,
span gates duration), but the bare "stale ... 0s" message was
confusing.  Now: "zero evidence span (all N records simultaneous);
nothing to extrapolate until the channel is observed again later"
(and the singular "a single observation has no time span; ..." at
min_samples=1).

Unit tests cover the calibration examples (2 points/1h -> 2h,
3 points/2h -> 4h, 24h cap), contradiction truncation, per-side
min_samples, margin/factor scaling, policy zero-walls, the zero-span
shapes, and the command integration.
2026-08-04 11:02:02 -07:00
Ken Sedgwick
04a9cbb370
XRebalanceHistory: record lossless xrebalance liquidity observations
Phase 1 of the history + persistence-forecasting design: a long-lived,
lossless store of everything the xrebalance executor learns, kept in
clboss's own sqlite rather than an askrene layer because layer records
are askrene's lossy (scid, time, min/max) projection -- they cannot
distinguish a 0x100c inbound-fee policy exclusion from a liquidity
wall, and the planned chokepoint/node-bias reliability statistics
need the same event table.

XMoveFunds now raises a Msg::XRebalanceObservation adjacent to every
askrene feedback write on the clboss-xrebalance layer: one per middle
hop on part success (kind success), and one at each failure feedback
site (kinds liquidity_fail, policy_fail for the 0x100c exclusion,
node_fail), carrying the full event context: amount, failcode, erring
node. Observations mirror the informs exactly: local channels and the
self-node guard produce no observation, and update_channel policy
refreshes are not observations.

The new Boss::Mod::XRebalanceHistory module records these in the
XRebalanceHistory table (time, scid, dir, kind, amount_msat,
failcode, erring_node; NULL failcode/erring_node for success),
indexed on (scid, dir, time) and (time). Rows older than the new
dynamic option clboss-xrebalance-history-age-secs (default 604800,
one week) are trimmed once per TimerRandomHourly tick. A read-only
clboss-xrebalance-history command ([scid] [hours] filters, positional
or keyword) reports the series oldest-first per channel direction,
and clboss-status gains an xrebalance_history section (row count,
distinct channel directions, oldest/newest time).

No behavior change to routing or feedback writes; this only adds the
evidence base. The phase-2 persistence forecaster (regime detection
over interval bounds, horizon = min(cap, frac x evidence span)) will
consume this table and re-assert walls/floors for stable channels
into the short-term layer.

Unit test covers record/report/filtering/status/trim and the dynamic
retention option; suite passes 82/82 under valgrind.
2026-08-04 11:02:02 -07:00
Ken Sedgwick
ffab6edd0b
XRebalancer: focused target cycles, 50/50 alongside matched
Add a second cycle style to the xrebalance driver: a focused cycle
picks one channel uniformly at random (90% from the fill pool, 10%
from the drain pool), offers the entire opposite pool as
counterparty, sizes the transfer to the target's own deficit, and
prices maxfee at the target's NetPpm plus the minimum NetPpm of the
offered pool.

The matched-pool cut prices maxfee at the marginal joint, so the
worst admitted channel on each side drags down what we will pay to
reach the best dest; matching pool volumes is also fake precision,
since MCF delivers whatever the network allows regardless.  A
focused cycle prices each transfer off the target's own economics
(JIT-like, in the old-track sense), so high-demand dests buy
budgets sized to their demand.

Focused cycles skip the curve/ladder/floor and the size-factor
sweep: the budget is the target's own economics, and the variety
the sweep manufactured comes free from drawing a different
target/amount/pool every cycle.  The uniform pick is deliberate:
discovery without starvation traps, plus an unbiased per-target
census, same methodology as the floor-ladder sweep.

New dynamic option clboss-xrebalance-focused-frac (0..1, default
0.5) sets the fraction of cycles run focused; the rest run matched,
the control arm with existing baselines.  Cycle log lines are
tagged [matched] vs [focused fill]/[focused drain] so the survey
tooling can split outcomes by style (nothing grepped the old
[xrebalance] tag).
2026-08-04 11:02:01 -07:00
Ken Sedgwick
10ffad5099
Util::Str::group_digits -- readable msat/sat amounts in the xrebalance logs
Large amounts in the XRebalancer / XMoveFunds log lines were
unbroken digit runs (delivered 18851040 msat); grep-heavy log
sessions kept miscounting them.  Add Util::Str::group_digits,
which renders an integer with an underscore every three digit
places (18_851_040), matching the digit grouping
clboss-xrebalance-view and the other contrib tools already
print.

Applied to the amounts that get large:
- XRebalancer cycle line (derived N, request)
- XRebalancer transfer-done line (delivered, fee)
- XMoveFunds planning line (amount, maxfee)
- XMoveFunds 204 summary and fee-picture diagnostics
  (alloc_fee, required_out)
- XMoveFunds per-part budget refusals (would deliver)
- format_route_fees per-hop table (in/out/fee)

Counts, ppm values, and cltv deltas stay plain.  Signed
overload handles the int64 call sites (INT64_MIN-safe).

New tests/util/test_str covers both overloads and the
boundary cases.
2026-08-04 11:02:00 -07:00
Ken Sedgwick
e418c210bd
XRebalancer: add the autonomous flow-mode rebalancer driver
XRebalancer is the autonomous driver of the xrebalance track. It runs
only when clboss-rebalance-mode is "xrebalance" (self-gating via
RebalanceModeProxy, the same way the classic rebalancers gate on
"classic"), and drives liquidity using the clboss-xmovefunds primitive.

Each cycle:
  - Fetch listpeerchannels live (not cached).
  - Classify channels into fill / drain tiers over dynamically
    configurable balance bands.
  - Derive the cycle's transfers from the joint-flow curve and a derived
    geometric route-cost-floor ladder (the same algorithm the
    clboss-xrebalance-view tool visualizes; the view is the reference,
    the driver ports it).
  - Execute each transfer by calling clboss-xmovefunds, then log a
    per-transfer summary plus one line per part (delivered vs failed,
    with the closest-to-delivery part as the failure reason).

Dynamic options, runtime-tunable via setconfig (on the dynamic-option
infra):
  - clboss-xrebalance-size-factor: scales the requested transfer size;
    >1 deliberately over-fills (recoverable), <=0 rejected.  Also accepts
    a "lo:hi" range (e.g. 0.5:3.0): in range mode each cycle draws a fresh
    uniform-random multiplier in [lo,hi], so the request size sweeps
    continuously instead of sitting at one value -- a fixed factor
    eventually stales askrene's route state into repeated 205/206
    refusals, which the sweep relieves.  The cycle line logs the drawn
    value and active range.
  - clboss-xrebalance-maxparts: the MCF flow cap (raise alongside
    size-factor so big requests are not rejected with 205).
  - route-cost-floor sweep: floor=auto picks a random rung over the
    derived ladder each cycle, logged per cycle.

Like the underlying clboss-xmovefunds, this requires patched CLN
(circular askrene) at runtime. It is deliberate / non-JIT.
2026-08-04 11:01:59 -07:00
Ken Sedgwick
55c8b047d1
EarningsTracker: attribute clboss-xrebalance per-part fees and amounts
EarningsTracker now subscribes to Msg::XRebalanceAttribution (emitted by
XMoveFunds once per delivered part) and records the rebalance fee and
amount against the same earnings accounting the classic FundsMover path
feeds via response_move_funds.  This is the XMoveFunds-side analog of
that attribution, so clboss-xrebalance deliveries show up in earnings
and clboss-status just like classic rebalances rather than being
invisible spend.
2026-08-04 11:01:59 -07:00
Ken Sedgwick
a9fff587ec
XMoveFunds: add the clboss-xmovefunds circular-rebalance primitive
XMoveFunds is the manual building block of the xrebalance track ("xpay,
for rebalancing"): a single clboss-xmovefunds RPC that moves liquidity
in a circle from our node back to our node over an operator-chosen set
of source and destination channels.

Requires patched CLN: the plan step calls getroutes with
source = destination = self, which the circular-askrene branch of
ksedgwic/lightning interprets as circular self-rebalance routing; stock
CLN aborts there.  clboss-xmovefunds is a manual trigger only -- no
autonomous code path exercises circular routing until the XRebalancer
driver lands (next commit).

Per request:
  - Parse params; require an explicit maxfee_msat and/or maxfee_ppm.
  - Create a transient askrene layer holding per-direction masks (every
    us->peer not in source_scid disabled, every peer->us not in dest_scid
    disabled), call getroutes circularly with the persistent
    clboss-xrebalance layer + the transient mask, sendpay the parts, then
    remove the transient layer.
  - Refuse parts whose fee/delivered exceeds the budget before sending
    (per-part gate), and skip parts whose route exceeds 20 hops: a long
    onion crashes CLN's sphinx serialization (the identical guard already
    lives in Track A's FundsMover/Attempter).  An over-long part is marked
    skipped -- never sent, never waited on -- and counted in parts_skipped;
    dropping one long part and sending the rest beats failing the whole
    payment, since xmovefunds parts are independent self-pays.  delpay
    failed parts; reply with per-part summary stats and a per-hop fee
    breakdown on 204s.

Layer learning + maintenance:
  - Feed sendpay outcomes into the persistent clboss-xrebalance layer
    (capacity constraints on failure).
  - On policy/cltv/htlc-bound failures refresh the channel policy from the
    embedded channel_update; detect bLIP-18 positive inbound fees and
    exclude such channels (askrene cannot price them); on FEE_INSUFFICIENT
    exclude the incoming channel; never disable our own node.
  - Age the clboss-xrebalance layer hourly, tunable at runtime via the
    clboss-xrebalance-age-secs dynamic option (default 3600s).

Earnings:
  - Emit Msg::XRebalanceAttribution per delivered part so EarningsTracker
    can attribute the fee and amount (consumed in the next commit).

Claimer handles the sendpay / wait execution path.
2026-08-04 11:01:58 -07:00
Ken Sedgwick
41f2bbcc5d
Add the xrebalance track to the rebalance-mode and askrene-layer infra
Re-introduces the second rebalancing track (Track B, "xrebalance": the
circular askrene min-cost-flow rebalancer) into the shared selection and
layer infrastructure, so it can coexist with the classic track in one
binary and be chosen at runtime.

  - Boss::RebalanceMode gains the xrebalance value (+ to_string /
    from_string arms and the doc bullet), selectable via
    clboss-rebalance-mode alongside classic and off.
  - Boss::Mod::AskreneLayer gains xrebalance_layer_name
    ("clboss-xrebalance"), the persistent askrene layer the xrebalance
    code paths accumulate probe knowledge into.  Kept distinct from the
    "clboss" layer so the two tracks' learning does not commingle while
    both run side by side.
  - clboss-rebalance-mode help text now lists xrebalance.

This is only the seam: nothing drives xrebalance yet.  The XMoveFunds
primitive and the XRebalancer driver land in following commits.  The
classic rebalancers already gate on mode == classic, so selecting
xrebalance simply quiesces them (like off) until the driver arrives.
2026-08-04 11:01:57 -07:00
Ken Sedgwick
6af2e39752
Require the CLN v26.06 getroutes fields; refuse to start on older CLN
Some checks failed
Code Base Sanity Check / tests (push) Has been cancelled
Code Base Sanity Check / coverage (push) Has been cancelled
Code Base Sanity Check / build-clang (push) Has been cancelled
The pre-v26.06 getroutes compatibility fallback read the deprecated
per-hop fields (next_node_id / amount_msat / delay), whose values CLN
defines as the in-side of each hop -- one hop shifted from the
out-side values (node_id_out / amount_out_msat / cltv_out, shipped in
v26.06) that sendpay routes must be built from.  On stock CLN
v24.11..v26.04 the fallback therefore built mispriced routes: small
overpays on the routes that survived, spurious FEE_INSUFFICIENT /
INCORRECT_CLTV_EXPIRY failures on the rest, and -- worst -- those
failures hard-excluded healthy channels in the persistent
failure-learning layer for hours, compounding across restarts.

Drop the fallback entirely and enforce the requirement twice:

- Initiator gains a CLN version gate, ordered before the database and
  signer steps so a refusal leaves no on-disk trace: a getinfo
  version older than v26.06 logs a detailed Error and aborts.  The
  new clboss-skip-cln-version-check flag bypasses the gate for
  operators whose older CLN carries a backport of the v26.06
  getroutes fields (the version string alone cannot show that); an
  unparseable version string warns and proceeds rather than locking
  out custom builds.

- The three getroutes parse sites (FundsMover/Attempter,
  ActiveProber, ChannelCandidateMatchmaker) verify the new fields on
  every response and fail the attempt with a clear Error if absent,
  so a mistakenly bypassed gate degrades into loud per-attempt
  failures rather than shifted routes.

CHANGELOG.md gains a prominent BREAKING entry; README documents the
requirement and the escape hatch.  Users on older CLN releases stay
on CLBOSS 0.16.x, which uses the legacy getroute/pay APIs those
versions still provide.
2026-08-04 11:01:56 -07:00
Ken Sedgwick
c7fa9bd662
SetConfigHandler: fail setconfig when the owner rejects the value
setconfig was acknowledged with unconditional success after
broadcasting Msg::Option, while owning modules reject bad values by
log-and-keep.  lightningd persists a setconfig value (configvar_save)
only on a success response, so the blanket ack recorded values clboss
never applied: listconfigs and config.setconfig diverged from the
running configuration, and a non-numeric value persisted for an
int-typed option fails lightningd's own option parse on the next
start -- lightningd refuses to boot.

Add a rejection back-channel to Msg::Option: SetConfigHandler
allocates a shared reject_reason (null on init-time raises, so
aggregate initialization at existing sites is unaffected), the owning
subscriber reports rejection via the new Msg::Option::reject() helper
(a no-op at init time, where quietly keeping the default is right),
and SetConfigHandler -- whose bus.raise() returns only after all
subscribers ran -- fails the command with invalid-params when a
reason was set.  All dynamic-option owners in this tree report their
rejections: RebalanceModeManager (unrecognized mode), FundsMover's
three numeric handlers, and AskreneUpdates' shared age/retain
handler.
2026-08-04 11:01:56 -07:00
Ken Sedgwick
a2296177ba
Dowser, ActiveProber: restore self-exclusion via a shared clboss-self layer
The getroute -> getroutes migrations dropped the legacy
exclude=[self_id] argument in the two probing modules whose askrene
source is a remote node.  Nothing replaced it: askrene's gossmap
includes our public channels like anyone else's, and an empty layers
array applies no exclusions.  Consequences:

- Dowser capacity probes could route part of the candidate->patron
  flow through our own node, counting our own liquidity toward a
  candidate's capacity -- retaining weak candidates and over-sizing
  the channels ChannelCreator opens.

- ActiveProber probes could pick path[0] = peer->us, degenerating
  into a circular us->peer->us payment that measures our own shared
  channel's peer->us balance instead of the peer's outward reach,
  with SendpayResultMonitor crediting the peer destination_reached
  for it.

Introduce AskreneLayer::self_layer_name ("clboss-self"): a tiny
persistent layer whose only content is our node in disabled_nodes,
maintained by AskreneLayer::ensure_self_layer() (idempotent create,
deduped disable).  Both modules resolve it before probing and name it
in their getroutes layers array; when askrene is unavailable they
probe without it, as before.  Kept separate from the clboss layer --
whose disabled_nodes also carries self -- because that layer's
learned constraints would bias what the probes measure.
2026-08-04 11:01:55 -07:00
Ken Sedgwick
f7b81c6397
AskreneUpdates: clboss-status block + clboss-askrene-updates command
Surface the learned-updates store for observation. Add an askrene_updates
clboss-status block with row counts, distinct nodes / channel-dirs, and the
counts currently within the projection windows (what a getroutes gets now),
plus the oldest/newest timestamps and the retention horizon.

Add a read-only clboss-askrene-updates command: with no argument it lists the
updates being applied now -- node disables grouped per node and channel-update
overrides grouped per (scid, dir) with the latest policy -- each with its age,
occurrence count and a projected flag. An optional hours argument widens the
view to the last N hours of the retained log so aged-out rows appear too
(projected=false), for mining which nodes churn and which channels re-price.

Read-only; no writes to any layer or table.
2026-08-04 11:01:54 -07:00
Ken Sedgwick
ce560254da
FundsMover: project learned updates into a private per-attempt layer
Rewire the classic rebalancer onto AskreneUpdates. Main holds the shared
ReqResp; each attempt provisions its own uuid-named, non-persistent askrene
layer (seeded from the still-fresh persisted updates) in the Runner, passes its
name to the Attempter, and removes it when the attempt finishes. The Attempter
names that private layer in its getroutes layers array, writes the node
disables and channel-update overrides it learns mid-attempt into it so its
retries route around them, and also records them via AskreneUpdates for future
attempts.

Previously those discoveries were written into the persistent clboss layer,
which askrene-age never removes them from, so a disabled peer stayed
unroutable for the life of the layer. Now they heal per-entry via the
projection windows. And because the private layer is named only by this
attempt's getroutes and removed after they complete, it can never be absent
while a getroutes references it -- askrene aborts (taking lightningd down,
since it is an important plugin) on a getroutes that names a missing layer,
which is what rules out healing a shared layer by wiping it.
2026-08-04 11:01:53 -07:00
Ken Sedgwick
feec8ddcb5
AskreneUpdates: SQL-backed store + per-request layer projection
Add AskreneUpdates, a standalone module both rebalancers will use to hold the
node disables and channel_update overrides they learn from routing failures.
askrene never ages these (they carry no timestamp, unlike the inform-channel
constraints), so instead of accumulating them in a shared askrene layer they
live in two append-only sqlite tables here and are projected, still-fresh, into
a private per-request layer for each getroutes.

Records arrive as AskreneNodeDisableUpdate / AskreneChannelUpdate. A ReqResp
(Request/ResponseAskreneUpdates) returns the distinct nodes disabled within
clboss-node-disable-age-secs and the latest override per channel direction
within clboss-channel-update-age-secs. Static open_layer/close_layer build and
tear down a uuid-named, non-persistent layer from a response (open_layer returns
an empty name if askrene is absent, so a caller never names a missing layer).
Rows are pruned only at clboss-update-retain-secs (default 30d), so the log
survives long enough to mine. Nothing calls the module yet.

Also add an AskreneLayer header comment pointing at this store for where
the learned node disables and channel_update overrides live.
2026-08-04 11:01:52 -07:00
Ken Sedgwick
f48c7e9669
AskreneLayer: coalesce redundant inform-channel writes per channel-direction
askrene's get_constraints folds every constraint for a (layer, scid-dir) down
to a single tightest [min, max] at query time, so the per-HTLC stream of
inform-channel writes only bloats the layer. Hot rebalance corridors accreted
hundreds-to-thousands of dominated min_msat copies -- one survey found a single
channel-direction holding 5026 constraints -- every one of which askrene must
re-fold on each getroutes through that dir.

Coalesce at the inform_channel chokepoint: per (layer, scid-dir, inform-kind)
keep the tightest bound emitted in the current time bucket and write through
only on a new bucket (a keep-alive against the layer aging) or a tightening.
Dropping a dominated write is lossless -- it is exactly the entry
get_constraints discards when it folds. On the surveyed hot set this is a ~42x
depth reduction.

The bucket length is a fixed fraction (1/12) of the layer aging window
(clboss-classic-layer-age-secs), so it always stays well under the aging window
-- the once-per-bucket keep-alive refreshes a constraint before it can age out
-- and the aging window is then always exactly 12 buckets whatever its value,
making the prune and the depth floor scale-invariant. 30 minutes at the default
6h aging. FundsMover feeds the live value through set_aging_window_secs from its
clboss-classic-layer-age-secs handler, so a setconfig retunes the bucket
immediately.

A small amortized prune drops cache entries idle past the aging window so the
cache does not grow with the set of channel-directions seen over the process
lifetime. inform_coalesce_emit is factored out as a pure decision; the test
covers the dominance and oscillation cases plus a behavioural drop test.
2026-08-04 11:01:50 -07:00
Ken Sedgwick
63ddc6a881
FundsMover: gate sending on askrene route probability
After getroutes returns a route, if its probability_ppm is below the new
clboss-min-rebalance-prob-ppm floor, do not send it: fail the attempt so the
Runner splits to a smaller, more-probable amount instead.

Why: askrene returns a route with probability_ppm ~= 0 when an affordable path
exists but the channels' known/estimated liquidity makes delivery almost
certain to fail. clboss currently sends those anyway, which yields a sendpay
204, a max_msat feedback write, a re-pathfind, and a retry -- a sustained
treadmill that pins a CPU and accretes per-attempt state, all for routes
askrene already scored as dead. A survey of the gate-era run found 83% of every
accepted route scored probability_ppm = 0. The gate stops paying (with a doomed
payment) for what askrene told us, in the same reply, will not work.

Splitting is preserved and is the point: a too-improbable route is not
dead-ended -- the move is halved and re-probed, and smaller amounts have a
higher per-hop probability (1 - sent/capacity), which is exactly where partial
delivery comes from. Only the send is gated; the split decision is unchanged
(including 205 splitting, left as-is in this commit).

The option is dynamic so the floor can be swept at runtime; default 0 disables
the gate (send every route askrene returns -- the prior behaviour). 1 refuses
only routes scored at exactly 0; a larger value such as 100000 (10%) refuses
anything below that probability. The value is snapshotted Main -> Runner ->
Attempter alongside orig_budget/orig_amount.
2026-08-04 11:01:49 -07:00
Ken Sedgwick
e92b5c6a0f
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.

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.
2026-08-04 11:01:49 -07:00
Ken Sedgwick
0121eb7a3c
EarningsTracker: attribute rebalances from the move response, not a requester-keyed map
Completed rebalances were correlated back to their (source,
destination) through an in-memory map keyed only by the
RequestMoveFunds requester pointer. When one rebalancer issues several
moves at once they share a single requester (EarningsRebalancer uses
its module pointer; JitRebalancer uses nullptr), so each request
overwrote the previous map entry. As the moves completed, the first
response consumed and erased the shared entry -- booking against
whatever pair happened to be written last -- and every later response,
including the actually-successful one, hit the not-in-our-table path
and was silently dropped.

Effect: rebalance spend and volume were under-counted and sometimes
mis-attributed to the wrong peer. That also starved the
EarningsRebalancer refusing-to-throw-good-money-after-bad guard of
accurate expenditure data. JIT-dominated periods looked fine only
because those moves complete one at a time and rarely overlap; an
EarningsRebalancer-dominated period booked almost nothing.

Fix: ResponseMoveFunds now carries source and destination
directly. FundsMover/Runner populates them and EarningsTracker books
the move straight from the response. The requester-keyed pendings map,
the RequestMoveFunds subscription, and request_move_funds are
removed. This mirrors the existing XRebalanceAttribution path, which
already passes source/destination in its message.

Tests: ResponseMoveFunds constructions updated across the rebalancer
and earnings tests; test_earningstracker now issues two moves under
the same requester and asserts both are booked and correctly
attributed -- a regression guard for the collision. Full suite 85/85.
2026-08-04 11:01:47 -07:00
Ken Sedgwick
e00758aac3
Add rebalancer mode selector (classic/off) as a dynamic option
Introduces a single source of truth for which rebalancing track is
active.  Boss::Mod::RebalanceModeManager owns the mode in memory (no
sqlite, so a restart reverts to the configured default, giving a
known-good baseline on every boot) and registers clboss-rebalance-mode
as a dynamic option: the config file sets the startup default and
`setconfig clboss-rebalance-mode <mode>` switches it at runtime without
a restart.  It answers RequestRebalanceMode queries and reports the mode
under clboss-status.

Modes are "classic" (run the rebalancer) and "off" (a real quiesce,
also the supported way to disable rebalancing entirely).  This is the
seam that later lets a second rebalancing track coexist and be toggled
without a restart.

The classic-track rebalancers self-gate on the mode at their existing
decision points, modeled on RebalanceUnmanager: EarningsRebalancer gates
its trigger, InitialRebalancer gates its run, and JitRebalancer gates
the top of htlc_accepted so that in off mode it does not defer the HTLC
and adds no forwarding latency.  A header-only Boss::ModG::
RebalanceModeProxy provides get_mode for the gate sites.  off composes
with the existing per-peer unmanage balance tag: off wins globally,
otherwise the per-peer tag still excludes specific peers.

The three rebalancers' unit tests now install a RebalanceModeManager on
the test bus so the self-gate query is answered (default classic, so
they behave as before).  Without a responder the RequestRebalanceMode
ReqResp is never satisfied and leaks, which the valgrind-checked tests
flag as a failure.

New files: Boss/RebalanceMode.hpp, Boss/Msg/RequestRebalanceMode.hpp,
Boss/Msg/ResponseRebalanceMode.hpp, Boss/ModG/RebalanceModeProxy.hpp,
Boss/Mod/RebalanceModeManager.{hpp,cpp}.
2026-08-04 11:01:46 -07:00
Ken Sedgwick
30316f4f73
Add dynamic (setconfig-tunable) plugin option infrastructure
Introduces the mechanism for runtime-mutable plugin options: an option
marked dynamic can be changed via `lightning-cli setconfig <name> <val>`
without restarting clboss or lightningd.  No option opts in yet -- this
is the foundation (the rebalancer mode selector is the first consumer).

  - Boss::Msg::ManifestOption gains a bool dynamic field (default false,
    preserving the existing startup-only contract).
  - Boss::Mod::Manifester emits the per-option dynamic flag in the
    getmanifest response, so lightningd knows to forward setconfig for
    that option.
  - New Boss::Mod::SetConfigHandler module records (name -> dynamic)
    from Msg::ManifestOption events, then handles incoming setconfig
    CommandRequests: it validates the named option is registered and
    dynamic, and re-raises a fresh Msg::Option on the bus, so existing
    option handlers re-apply the new value transparently.

Because Msg::Option is now re-emitted at runtime (not only during init),
subscribers must filter by name and tolerate post-init arrival.
AmountSettingsHandler gains an `if (!settings) return` guard: it moves
`settings` away at EndOfOptions, so a later Msg::Option for an unrelated
name must be dropped -- this also fixes a latent assert(settings) crash
that any post-EndOfOptions Msg::Option would have tripped.

Contract documented in SetConfigHandler.hpp: lightningd delivers
Int/Bool/Flag option values as JSON primitives at startup but as JSON
strings at setconfig time, so dynamic-option handlers must accept both
Jsmn shapes.
2026-08-04 11:01:46 -07:00