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).
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.
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.
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.
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.
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.
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.
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.
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).
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.
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).
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.
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.
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).
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).
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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}.
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.