clboss/Boss/Mod/XMoveFunds/Main.cpp

2166 lines
72 KiB
C++
Raw Normal View History

XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
#include"Boss/Mod/AskreneLayer.hpp"
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
#include"Boss/Mod/Rpc.hpp"
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
#include"Boss/Mod/XMoveFunds/Claimer.hpp"
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
#include"Boss/Mod/XMoveFunds/Main.hpp"
#include"Boss/Msg/CommandFail.hpp"
#include"Boss/Msg/CommandRequest.hpp"
#include"Boss/Msg/CommandResponse.hpp"
#include"Boss/Msg/Init.hpp"
#include"Boss/Msg/ManifestCommand.hpp"
XMoveFunds: age the clboss-xrebalance layer, runtime-tunable via setconfig Adds periodic askrene-age against the persistent clboss-xrebalance layer so capacity constraints written by inform_channel_constrained do not accumulate forever. Mirrors FundsMover's age_clboss_layer: fires on Msg::TimerRandomHourly, logs num_removed at Debug, and catches RpcError with -32601 stayed at Debug (graceful degradation on CLN without askrene-age) while other codes promote to Warn so sustained aging failure is visible. The aging cutoff is controlled by a new plugin option, clboss-xrebalance-age-secs, default 3600 (1h) to match FundsMover's production value. Operators on networks with slower flows (signet) are expected to widen this; the right value is empirical and will be tuned after observation. The option is registered dynamic=true so the window is mutable at runtime via: lightning-cli setconfig clboss-xrebalance-age-secs <secs> No clboss / lightningd restart required. To support that, three small infrastructure pieces: * Boss::Msg::ManifestOption gains a bool dynamic field (default false; preserves existing behavior). * 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 flag) from Msg::ManifestOption events, then handles incoming Msg::CommandRequest where command == "setconfig" by validating the named option is registered + dynamic and re-raising a fresh Msg::Option on the bus. Existing option handlers re-apply the new value transparently. Contract for any future opt-in to dynamic: at startup lightningd encodes Int / Bool / Flag option values as JSON primitives, but at setconfig time it encodes them as JSON strings. Handlers for dynamic options must accept both Jsmn shapes. The XMoveFunds option handler does this; the contract is documented in SetConfigHandler's header comment so future modules can opt in safely.
2026-06-01 09:31:50 -07:00
#include"Boss/Msg/ManifestOption.hpp"
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
#include"Boss/Msg/Manifestation.hpp"
XMoveFunds: age the clboss-xrebalance layer, runtime-tunable via setconfig Adds periodic askrene-age against the persistent clboss-xrebalance layer so capacity constraints written by inform_channel_constrained do not accumulate forever. Mirrors FundsMover's age_clboss_layer: fires on Msg::TimerRandomHourly, logs num_removed at Debug, and catches RpcError with -32601 stayed at Debug (graceful degradation on CLN without askrene-age) while other codes promote to Warn so sustained aging failure is visible. The aging cutoff is controlled by a new plugin option, clboss-xrebalance-age-secs, default 3600 (1h) to match FundsMover's production value. Operators on networks with slower flows (signet) are expected to widen this; the right value is empirical and will be tuned after observation. The option is registered dynamic=true so the window is mutable at runtime via: lightning-cli setconfig clboss-xrebalance-age-secs <secs> No clboss / lightningd restart required. To support that, three small infrastructure pieces: * Boss::Msg::ManifestOption gains a bool dynamic field (default false; preserves existing behavior). * 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 flag) from Msg::ManifestOption events, then handles incoming Msg::CommandRequest where command == "setconfig" by validating the named option is registered + dynamic and re-raising a fresh Msg::Option on the bus. Existing option handlers re-apply the new value transparently. Contract for any future opt-in to dynamic: at startup lightningd encodes Int / Bool / Flag option values as JSON primitives, but at setconfig time it encodes them as JSON strings. Handlers for dynamic options must accept both Jsmn shapes. The XMoveFunds option handler does this; the contract is documented in SetConfigHandler's header comment so future modules can opt in safely.
2026-06-01 09:31:50 -07:00
#include"Boss/Msg/Option.hpp"
#include"Boss/Msg/TimerRandomHourly.hpp"
EarningsTracker: attribute clboss-xrebalance per-part fees + amounts XMoveFunds successful rebalances were moving real sats but reporting nothing to clboss's earnings stats -- they did not show up in clboss-status, in the fee model, or anywhere else operators could see them. This wires per-part attribution end-to-end via a new bus message. New message: Boss::Msg::XRebalanceAttribution { source, destination, amount_moved, fee_spent } Emission (XMoveFunds): Inside the existing waitsendpay-success branch, after pushing the result and accumulating positive-reinforcement feedback for the middle hops, parse the askrene path for this part: - source peer = path[0]["node_id_out"] -- the peer we forwarded to on the first hop, i.e. the far end of the source channel for this part. - dest peer = path[last]["node_id_in"] -- the peer that forwarded back to us on the closing hop (a.k.a. fill_peer). - amount_moved = waitsendpay.amount_msat (delivered to us). - fee_spent = amount_sent_msat - amount_msat. Defensive on malformed JSON: missing fields, non-array path, or amount_sent < amount_moved all skip the raise rather than crash the success handler. An MPP-split clboss-xmovefunds invocation that lands different parts on different (source, dest) pairs (the common case once source_scid / dest_scid are sets) gets one attribution per part, with the actual pair each part used -- finer-grained than FundsMover's per-Runner attribution. Subscription (EarningsTracker): Subscribe alongside the existing Msg::ResponseMoveFunds handler and run the same symmetric DB update -- source peer gets in_expenditures += fee and in_rebalanced += amount, destination peer gets out_expenditures += fee and out_rebalanced += amount. No pendings lookup: XMoveFunds already identified both peers before raising, so the new path skips the requester -> Pending map entirely. Symmetry sanity check while wiring: The DEVSTATE/REBALANCER-REDESIGN-2026-05-28.org "Earnings attribution symmetry" note flagged that the FundsMover path might debit only one side. Verified against the current code: response_move_funds already updates both source's in_* and destination's out_* (EarningsTracker.cpp around lines 430 and 447) -- no fix needed there. The doc concern was correct at the time but has been resolved since.
2026-06-01 10:50:34 -07:00
#include"Boss/Msg/XRebalanceAttribution.hpp"
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
#include"Boss/concurrent.hpp"
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
#include"Boss/log.hpp"
#include"Ev/Io.hpp"
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
#include"Ev/yield.hpp"
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
#include"Jsmn/Object.hpp"
#include"Json/Out.hpp"
#include"Ln/Amount.hpp"
#include"Ln/CommandId.hpp"
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
#include"Ln/NodeId.hpp"
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
#include"Ln/Preimage.hpp"
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
#include"Ln/Scid.hpp"
#include"S/Bus.hpp"
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
#include"Sha256/Hash.hpp"
XMoveFunds: refresh channel policy on fee/cltv/htlc-bound failures Repeated lab0 runs eventually hit WIRE_FEE_INSUFFICIENT (failcode 0x100c) at the closing hop of the circular payment. The forwarder (our fill peer) was reporting "your fee math used stale gossip; here is my actual current channel_update". Before this commit the handler did the wrong thing for that response: - The failing channel (our fill scid) is in `our_scids`, so the existing inform_channel_constrained fallback was correctly skipped. - But nothing was written in its place: the policy-refresh branch did not exist. - Result: every subsequent getroutes call reused the same stale gossip fee, computed the same wrong closing-hop fee math, and the same forwarder rejected with the same failcode. No learning, no progress, stuck loop. This commit adds the FundsMover/Attempter-style channel_update refresh branch: - For failcodes 0x100b / 0x100c / 0x100d / 0x100e (the four policy-carrying onion-error codes) that include a parseable raw_message channel_update, write an askrene-update-channel against erring_channel/erring_direction with the refreshed enabled flag, cltv_expiry_delta, htlc_minimum_msat, htlc_maximum_msat, fee_base_msat, and fee_proportional_millionths. - The write applies REGARDLESS of whether erring_channel is one of our local-channel scids. The failing direction is always the FORWARDER's outbound direction (their forward to the next hop, or to us at the closing hop), which is gossip-derived even on our own channels. auto.localchans only authoritatively covers our own outbound direction; the peer's direction is what gets stale. The original `our_scids` filter still gates the inform-constrained fallback (capacity-bound writes against our outbound make no sense), but not the policy refresh. - If parse_chan_update fails (malformed payload, truncated, unknown failcode header), fall through to the existing inform_channel_constrained path so we still record SOME signal, just less precise. - All other failcode handling unchanged: NODE-level (failcode & 0x2000) still disables the node; non-policy-carrying channel-level (notably 0x1007 TCF) still goes through inform_channel_constrained with capacity-aware amount. parse_chan_update implementation - ChanUpdate struct + read_be helper + parse_chan_update function copied verbatim from Boss/Mod/FundsMover/Attempter.cpp into the local anonymous namespace at the top of XMoveFunds/Main.cpp. ~80 lines. - Yes, this duplicates FundsMover's copy of the same parser. Extracting to a shared module (Util/, Ln/, or new Boss/Mod/ChanUpdate) is a separate cleanup commit tracked apart from xrebalance work. Going with duplication here to keep the change isolated to XMoveFunds while we are actively iterating on the algorithm. - The parser handles the BOLT 04 onion failure wire layout (failcode + per-failcode header + channel_update length + channel_update bytes) and the optional 2-byte 0x0102 type prefix that CLN-issued channel_updates carry but LND-pre-v0.18 ones do not.
2026-05-31 19:35:15 -07:00
#include"Util/Str.hpp"
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
#include"Util/make_unique.hpp"
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
#include"Util/stringify.hpp"
#include"Uuid.hpp"
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
#include<algorithm>
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
#include<chrono>
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
#include<cinttypes>
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
#include<ctime>
#include<limits>
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
#include<memory>
#include<optional>
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
#include<random>
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
#include<set>
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
#include<sstream>
#include<vector>
namespace {
/* JSON-RPC error code we use for malformed parameters. Matches
* the JSONRPC2 invalid-params constant used elsewhere in clboss
* (e.g. Dowser, MoveFundsCommand). */
constexpr int RPC_INVALID_PARAMS = -32602;
XMoveFunds: refresh channel policy on fee/cltv/htlc-bound failures Repeated lab0 runs eventually hit WIRE_FEE_INSUFFICIENT (failcode 0x100c) at the closing hop of the circular payment. The forwarder (our fill peer) was reporting "your fee math used stale gossip; here is my actual current channel_update". Before this commit the handler did the wrong thing for that response: - The failing channel (our fill scid) is in `our_scids`, so the existing inform_channel_constrained fallback was correctly skipped. - But nothing was written in its place: the policy-refresh branch did not exist. - Result: every subsequent getroutes call reused the same stale gossip fee, computed the same wrong closing-hop fee math, and the same forwarder rejected with the same failcode. No learning, no progress, stuck loop. This commit adds the FundsMover/Attempter-style channel_update refresh branch: - For failcodes 0x100b / 0x100c / 0x100d / 0x100e (the four policy-carrying onion-error codes) that include a parseable raw_message channel_update, write an askrene-update-channel against erring_channel/erring_direction with the refreshed enabled flag, cltv_expiry_delta, htlc_minimum_msat, htlc_maximum_msat, fee_base_msat, and fee_proportional_millionths. - The write applies REGARDLESS of whether erring_channel is one of our local-channel scids. The failing direction is always the FORWARDER's outbound direction (their forward to the next hop, or to us at the closing hop), which is gossip-derived even on our own channels. auto.localchans only authoritatively covers our own outbound direction; the peer's direction is what gets stale. The original `our_scids` filter still gates the inform-constrained fallback (capacity-bound writes against our outbound make no sense), but not the policy refresh. - If parse_chan_update fails (malformed payload, truncated, unknown failcode header), fall through to the existing inform_channel_constrained path so we still record SOME signal, just less precise. - All other failcode handling unchanged: NODE-level (failcode & 0x2000) still disables the node; non-policy-carrying channel-level (notably 0x1007 TCF) still goes through inform_channel_constrained with capacity-aware amount. parse_chan_update implementation - ChanUpdate struct + read_be helper + parse_chan_update function copied verbatim from Boss/Mod/FundsMover/Attempter.cpp into the local anonymous namespace at the top of XMoveFunds/Main.cpp. ~80 lines. - Yes, this duplicates FundsMover's copy of the same parser. Extracting to a shared module (Util/, Ln/, or new Boss/Mod/ChanUpdate) is a separate cleanup commit tracked apart from xrebalance work. Going with duplication here to keep the change isolated to XMoveFunds while we are actively iterating on the algorithm. - The parser handles the BOLT 04 onion failure wire layout (failcode + per-failcode header + channel_update length + channel_update bytes) and the optional 2-byte 0x0102 type prefix that CLN-issued channel_updates carry but LND-pre-v0.18 ones do not.
2026-05-31 19:35:15 -07:00
/* Parsed channel_update fields fed back into askrene via
* AskreneLayer::update_channel after a sendpay 204 with an
* onion-error failcode that carries a channel_update payload.
* Mirrors the subset of BOLT 07 channel_update fields askrene-
* update-channel accepts.
*
* Duplicated from FundsMover/Attempter.cpp for now -- both
* sites parse the same wire format with the same field set.
* Pulling the parser into a shared module (Util/, Ln/, or a
* new Boss/Mod/ChanUpdate) is a separate cleanup tracked
* apart from xrebalance work. */
struct ChanUpdate {
bool enabled;
std::uint16_t cltv_expiry_delta;
std::uint64_t htlc_minimum_msat;
std::uint32_t fee_base_msat;
std::uint32_t fee_proportional_millionths;
std::uint64_t htlc_maximum_msat;
/* bLIP-18 inbound fees (TLV 55555), signed. has_inbound_fee
* is false when the channel_update carries no such TLV. */
bool has_inbound_fee = false;
std::int32_t inbound_fee_base_msat = 0;
std::int32_t inbound_fee_proportional_millionths = 0;
XMoveFunds: refresh channel policy on fee/cltv/htlc-bound failures Repeated lab0 runs eventually hit WIRE_FEE_INSUFFICIENT (failcode 0x100c) at the closing hop of the circular payment. The forwarder (our fill peer) was reporting "your fee math used stale gossip; here is my actual current channel_update". Before this commit the handler did the wrong thing for that response: - The failing channel (our fill scid) is in `our_scids`, so the existing inform_channel_constrained fallback was correctly skipped. - But nothing was written in its place: the policy-refresh branch did not exist. - Result: every subsequent getroutes call reused the same stale gossip fee, computed the same wrong closing-hop fee math, and the same forwarder rejected with the same failcode. No learning, no progress, stuck loop. This commit adds the FundsMover/Attempter-style channel_update refresh branch: - For failcodes 0x100b / 0x100c / 0x100d / 0x100e (the four policy-carrying onion-error codes) that include a parseable raw_message channel_update, write an askrene-update-channel against erring_channel/erring_direction with the refreshed enabled flag, cltv_expiry_delta, htlc_minimum_msat, htlc_maximum_msat, fee_base_msat, and fee_proportional_millionths. - The write applies REGARDLESS of whether erring_channel is one of our local-channel scids. The failing direction is always the FORWARDER's outbound direction (their forward to the next hop, or to us at the closing hop), which is gossip-derived even on our own channels. auto.localchans only authoritatively covers our own outbound direction; the peer's direction is what gets stale. The original `our_scids` filter still gates the inform-constrained fallback (capacity-bound writes against our outbound make no sense), but not the policy refresh. - If parse_chan_update fails (malformed payload, truncated, unknown failcode header), fall through to the existing inform_channel_constrained path so we still record SOME signal, just less precise. - All other failcode handling unchanged: NODE-level (failcode & 0x2000) still disables the node; non-policy-carrying channel-level (notably 0x1007 TCF) still goes through inform_channel_constrained with capacity-aware amount. parse_chan_update implementation - ChanUpdate struct + read_be helper + parse_chan_update function copied verbatim from Boss/Mod/FundsMover/Attempter.cpp into the local anonymous namespace at the top of XMoveFunds/Main.cpp. ~80 lines. - Yes, this duplicates FundsMover's copy of the same parser. Extracting to a shared module (Util/, Ln/, or new Boss/Mod/ChanUpdate) is a separate cleanup commit tracked apart from xrebalance work. Going with duplication here to keep the change isolated to XMoveFunds while we are actively iterating on the algorithm. - The parser handles the BOLT 04 onion failure wire layout (failcode + per-failcode header + channel_update length + channel_update bytes) and the optional 2-byte 0x0102 type prefix that CLN-issued channel_updates carry but LND-pre-v0.18 ones do not.
2026-05-31 19:35:15 -07:00
};
/* Read a big-endian unsigned integer of 1..8 bytes from `data`
* starting at `offset`. Caller ensures the read is in-bounds.
*/
std::uint64_t read_be( std::uint8_t const* data
, std::size_t offset
, std::size_t nbytes
) {
auto v = std::uint64_t(0);
for (auto i = std::size_t(0); i < nbytes; ++i)
v = (v << 8) | std::uint64_t(data[offset + i]);
return v;
}
/* Read a BOLT 01 BigSize at `pos` in `data` (size `size`), advancing
* `pos` past it. Returns false if truncated. */
bool read_bigsize( std::uint8_t const* data
, std::size_t size
, std::size_t& pos
, std::uint64_t& out
) {
if (pos >= size)
return false;
auto first = data[pos];
auto nbytes = std::size_t( first < 0xfd ? 0
: first == 0xfd ? 2
: first == 0xfe ? 4
: 8 );
if (nbytes == 0) {
out = first;
pos += 1;
return true;
}
if (pos + 1 + nbytes > size)
return false;
out = read_be(data, pos + 1, nbytes);
pos += 1 + nbytes;
return true;
}
XMoveFunds: refresh channel policy on fee/cltv/htlc-bound failures Repeated lab0 runs eventually hit WIRE_FEE_INSUFFICIENT (failcode 0x100c) at the closing hop of the circular payment. The forwarder (our fill peer) was reporting "your fee math used stale gossip; here is my actual current channel_update". Before this commit the handler did the wrong thing for that response: - The failing channel (our fill scid) is in `our_scids`, so the existing inform_channel_constrained fallback was correctly skipped. - But nothing was written in its place: the policy-refresh branch did not exist. - Result: every subsequent getroutes call reused the same stale gossip fee, computed the same wrong closing-hop fee math, and the same forwarder rejected with the same failcode. No learning, no progress, stuck loop. This commit adds the FundsMover/Attempter-style channel_update refresh branch: - For failcodes 0x100b / 0x100c / 0x100d / 0x100e (the four policy-carrying onion-error codes) that include a parseable raw_message channel_update, write an askrene-update-channel against erring_channel/erring_direction with the refreshed enabled flag, cltv_expiry_delta, htlc_minimum_msat, htlc_maximum_msat, fee_base_msat, and fee_proportional_millionths. - The write applies REGARDLESS of whether erring_channel is one of our local-channel scids. The failing direction is always the FORWARDER's outbound direction (their forward to the next hop, or to us at the closing hop), which is gossip-derived even on our own channels. auto.localchans only authoritatively covers our own outbound direction; the peer's direction is what gets stale. The original `our_scids` filter still gates the inform-constrained fallback (capacity-bound writes against our outbound make no sense), but not the policy refresh. - If parse_chan_update fails (malformed payload, truncated, unknown failcode header), fall through to the existing inform_channel_constrained path so we still record SOME signal, just less precise. - All other failcode handling unchanged: NODE-level (failcode & 0x2000) still disables the node; non-policy-carrying channel-level (notably 0x1007 TCF) still goes through inform_channel_constrained with capacity-aware amount. parse_chan_update implementation - ChanUpdate struct + read_be helper + parse_chan_update function copied verbatim from Boss/Mod/FundsMover/Attempter.cpp into the local anonymous namespace at the top of XMoveFunds/Main.cpp. ~80 lines. - Yes, this duplicates FundsMover's copy of the same parser. Extracting to a shared module (Util/, Ln/, or new Boss/Mod/ChanUpdate) is a separate cleanup commit tracked apart from xrebalance work. Going with duplication here to keep the change isolated to XMoveFunds while we are actively iterating on the algorithm. - The parser handles the BOLT 04 onion failure wire layout (failcode + per-failcode header + channel_update length + channel_update bytes) and the optional 2-byte 0x0102 type prefix that CLN-issued channel_updates carry but LND-pre-v0.18 ones do not.
2026-05-31 19:35:15 -07:00
/* Parse a BOLT 04 onion failure payload (the `raw_message` hex
* from sendpay_failure data) and extract the embedded BOLT 07
* channel_update fields. Returns true on success and writes the
* parsed values into `out`; returns false if the hex is malformed,
* the failcode does not carry a channel_update, or the payload is
* truncated.
*
* Wire layout of the onion failure for the relevant failcodes:
*
* 2 failcode
* X variable per-failcode header:
* 0x1007 / 0x100e: 0 bytes
* 0x100b / 0x100c (amount): 8 bytes htlc_msat
* 0x100d (cltv): 4 bytes cltv_expiry
* 2 channel_update length (big-endian)
* N channel_update bytes
*
* channel_update wire layout (BOLT 07), 128 bytes after the
* optional 2-byte 0x0102 type prefix. We only need the policy
* fields (offset 109 onwards in the body), so we skip past
* signature (64), chain_hash (32), short_channel_id (8),
* timestamp (4), and message_flags (1). The 2-byte type prefix
* is present in CLN-issued channel_updates and absent in
* LND-pre-v0.18 ones; detect by sniffing the first two bytes.
*/
bool parse_chan_update( std::string const& raw_message_hex
, ChanUpdate& out
) {
std::vector<std::uint8_t> bytes;
try {
bytes = Util::Str::hexread(raw_message_hex);
} catch (std::exception const&) {
return false;
}
if (bytes.size() < 4)
return false;
auto failcode = std::uint16_t((bytes[0] << 8) | bytes[1]);
auto header = std::size_t(0);
switch (failcode) {
case 0x1007: case 0x100e: header = 0; break;
case 0x100b: case 0x100c: header = 8; break;
case 0x100d: header = 4; break;
default: return false;
}
auto pos = std::size_t(2) + header;
if (bytes.size() < pos + 2)
return false;
auto cu_len = std::size_t((bytes[pos] << 8) | bytes[pos + 1]);
pos += 2;
if (cu_len == 0 || bytes.size() < pos + cu_len)
return false;
auto cu = bytes.data() + pos;
auto cu_size = cu_len;
/* Skip the optional 2-byte type prefix 0x0102 if present. */
if (cu_size >= 2 && cu[0] == 0x01 && cu[1] == 0x02) {
cu += 2;
cu_size -= 2;
}
if (cu_size < 136)
return false;
auto channel_flags = cu[109];
out.enabled = !(channel_flags & 0x02);
out.cltv_expiry_delta = std::uint16_t(read_be(cu, 110, 2));
out.htlc_minimum_msat = read_be(cu, 112, 8);
out.fee_base_msat = std::uint32_t(read_be(cu, 120, 4));
out.fee_proportional_millionths = std::uint32_t(read_be(cu, 124, 4));
out.htlc_maximum_msat = read_be(cu, 128, 8);
/* Scan the trailing TLV stream for bLIP-18 inbound fees
* (type 55555): value is [i32 base][i32 prop], both signed. */
out.has_inbound_fee = false;
out.inbound_fee_base_msat = 0;
out.inbound_fee_proportional_millionths = 0;
auto tpos = std::size_t(136);
while (tpos < cu_size) {
auto ttype = std::uint64_t(0);
auto tlen = std::uint64_t(0);
if (!read_bigsize(cu, cu_size, tpos, ttype))
break;
if (!read_bigsize(cu, cu_size, tpos, tlen))
break;
if (tpos + tlen > cu_size)
break;
if (ttype == 55555 && tlen >= 8) {
out.has_inbound_fee = true;
out.inbound_fee_base_msat =
std::int32_t(std::uint32_t(read_be(cu, tpos, 4)));
out.inbound_fee_proportional_millionths =
std::int32_t(std::uint32_t(read_be(cu, tpos + 4, 4)));
}
tpos += tlen;
}
XMoveFunds: refresh channel policy on fee/cltv/htlc-bound failures Repeated lab0 runs eventually hit WIRE_FEE_INSUFFICIENT (failcode 0x100c) at the closing hop of the circular payment. The forwarder (our fill peer) was reporting "your fee math used stale gossip; here is my actual current channel_update". Before this commit the handler did the wrong thing for that response: - The failing channel (our fill scid) is in `our_scids`, so the existing inform_channel_constrained fallback was correctly skipped. - But nothing was written in its place: the policy-refresh branch did not exist. - Result: every subsequent getroutes call reused the same stale gossip fee, computed the same wrong closing-hop fee math, and the same forwarder rejected with the same failcode. No learning, no progress, stuck loop. This commit adds the FundsMover/Attempter-style channel_update refresh branch: - For failcodes 0x100b / 0x100c / 0x100d / 0x100e (the four policy-carrying onion-error codes) that include a parseable raw_message channel_update, write an askrene-update-channel against erring_channel/erring_direction with the refreshed enabled flag, cltv_expiry_delta, htlc_minimum_msat, htlc_maximum_msat, fee_base_msat, and fee_proportional_millionths. - The write applies REGARDLESS of whether erring_channel is one of our local-channel scids. The failing direction is always the FORWARDER's outbound direction (their forward to the next hop, or to us at the closing hop), which is gossip-derived even on our own channels. auto.localchans only authoritatively covers our own outbound direction; the peer's direction is what gets stale. The original `our_scids` filter still gates the inform-constrained fallback (capacity-bound writes against our outbound make no sense), but not the policy refresh. - If parse_chan_update fails (malformed payload, truncated, unknown failcode header), fall through to the existing inform_channel_constrained path so we still record SOME signal, just less precise. - All other failcode handling unchanged: NODE-level (failcode & 0x2000) still disables the node; non-policy-carrying channel-level (notably 0x1007 TCF) still goes through inform_channel_constrained with capacity-aware amount. parse_chan_update implementation - ChanUpdate struct + read_be helper + parse_chan_update function copied verbatim from Boss/Mod/FundsMover/Attempter.cpp into the local anonymous namespace at the top of XMoveFunds/Main.cpp. ~80 lines. - Yes, this duplicates FundsMover's copy of the same parser. Extracting to a shared module (Util/, Ln/, or new Boss/Mod/ChanUpdate) is a separate cleanup commit tracked apart from xrebalance work. Going with duplication here to keep the change isolated to XMoveFunds while we are actively iterating on the algorithm. - The parser handles the BOLT 04 onion failure wire layout (failcode + per-failcode header + channel_update length + channel_update bytes) and the optional 2-byte 0x0102 type prefix that CLN-issued channel_updates carry but LND-pre-v0.18 ones do not.
2026-05-31 19:35:15 -07:00
return true;
}
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
/* Decode either a single scid string or an array of scid strings
* from a JSON value into a vector. Throws on type/format error
* with a message suitable for surfacing in the RPC reply. */
std::vector<Ln::Scid>
parse_scid_list(Jsmn::Object const& j, char const* fieldname) {
std::vector<Ln::Scid> out;
auto push_one = [&out, fieldname](Jsmn::Object const& s) {
if (!s.is_string())
throw std::runtime_error(
std::string(fieldname)
+ " must be a scid string or array of "
"scid strings");
out.emplace_back(std::string(s));
};
if (j.is_string()) {
push_one(j);
} else if (j.is_array()) {
for (auto i = std::size_t(0); i < j.size(); ++i)
push_one(j[i]);
} else {
throw std::runtime_error(
std::string(fieldname)
+ " must be a scid string or array of scid "
"strings");
}
if (out.empty())
throw std::runtime_error(
std::string(fieldname)
+ " must be non-empty");
return out;
}
std::string
join_scids(std::vector<Ln::Scid> const& v) {
auto os = std::ostringstream();
auto first = true;
for (auto const& s : v) {
if (!first) os << ",";
os << std::string(s);
first = false;
}
return os.str();
}
/* Render a per-hop amount/fee/cltv breakdown of an askrene route path,
* one line per hop, for diagnostic logging. fee = amount_in - amount_out
* is what WE allocated to that hop; the implied ppm is taken over
* amount_out. Lets us see the full fee picture along a route and, on a
* FEE_INSUFFICIENT, tell an under-allocation (our/askrene bug) apart from
* an inbound surcharge (the node's, not in this payload). */
std::string
format_route_fees(Jsmn::Object const& path) {
auto os = std::ostringstream();
for (auto i = std::size_t(0); i < path.size(); ++i) {
auto hop = path[i];
auto scidd = hop.has("short_channel_id_dir")
? std::string(hop["short_channel_id_dir"])
: std::string("?");
auto ain = hop.has("amount_in_msat")
? Ln::Amount::object(hop["amount_in_msat"]).to_msat()
: std::uint64_t(0);
auto aout = hop.has("amount_out_msat")
? Ln::Amount::object(hop["amount_out_msat"]).to_msat()
: std::uint64_t(0);
auto fee = (ain >= aout) ? (ain - aout) : std::uint64_t(0);
auto ppm = (aout > 0)
? (unsigned long)(double(fee) * 1e6 / double(aout) + 0.5)
: 0ul;
auto cin = hop.has("cltv_in")
? std::uint32_t(double(hop["cltv_in"])) : 0u;
auto cout = hop.has("cltv_out")
? std::uint32_t(double(hop["cltv_out"])) : 0u;
os << " [" << i << "] " << scidd
<< " in=" << ain << " out=" << aout
<< " fee=" << fee << "msat(" << ppm << "ppm)"
<< " cltv " << cin << "->" << cout
<< " d=" << (cin >= cout ? cin - cout : 0u) << "\n";
}
return os.str();
}
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
/* Parse a JSON value as a u32, accepting either a JSON number or a
* numeric string. lightning-cli encodes unquoted CLI values as JSON
* numbers (so `maxparts=10` arrives as the number 10), while object-
* form RPC calls sometimes pass them as strings. Mirrors the
* permissive shape of Ln::Amount::object. */
std::uint32_t
parse_u32(Jsmn::Object const& o, char const* fieldname) {
if (o.is_number()) {
return std::uint32_t(double(o));
}
if (o.is_string()) {
try {
return std::uint32_t(
std::stoul(std::string(o)));
} catch (std::exception const&) {
throw std::runtime_error(
std::string(fieldname)
+ " must be an integer");
}
}
throw std::runtime_error(
std::string(fieldname) + " must be an integer");
}
}
namespace Boss { namespace Mod { namespace XMoveFunds {
class Main::Impl {
private:
S::Bus& bus;
Boss::Mod::Rpc* rpc;
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
Ln::NodeId self_id;
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
Claimer claimer;
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
/* True once create_xrebalance_layer() has resolved (either by
* successfully creating/finding the persistent layer, or by
* logging a non-fatal RpcError on older CLN). Gated on by
* wait_for_ready() so that command handling never tries to
* use the layer before askrene is told about it. */
bool layer_ready;
XMoveFunds: age the clboss-xrebalance layer, runtime-tunable via setconfig Adds periodic askrene-age against the persistent clboss-xrebalance layer so capacity constraints written by inform_channel_constrained do not accumulate forever. Mirrors FundsMover's age_clboss_layer: fires on Msg::TimerRandomHourly, logs num_removed at Debug, and catches RpcError with -32601 stayed at Debug (graceful degradation on CLN without askrene-age) while other codes promote to Warn so sustained aging failure is visible. The aging cutoff is controlled by a new plugin option, clboss-xrebalance-age-secs, default 3600 (1h) to match FundsMover's production value. Operators on networks with slower flows (signet) are expected to widen this; the right value is empirical and will be tuned after observation. The option is registered dynamic=true so the window is mutable at runtime via: lightning-cli setconfig clboss-xrebalance-age-secs <secs> No clboss / lightningd restart required. To support that, three small infrastructure pieces: * Boss::Msg::ManifestOption gains a bool dynamic field (default false; preserves existing behavior). * 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 flag) from Msg::ManifestOption events, then handles incoming Msg::CommandRequest where command == "setconfig" by validating the named option is registered + dynamic and re-raising a fresh Msg::Option on the bus. Existing option handlers re-apply the new value transparently. Contract for any future opt-in to dynamic: at startup lightningd encodes Int / Bool / Flag option values as JSON primitives, but at setconfig time it encodes them as JSON strings. Handlers for dynamic options must accept both Jsmn shapes. The XMoveFunds option handler does this; the contract is documented in SetConfigHandler's header comment so future modules can opt in safely.
2026-06-01 09:31:50 -07:00
/* Window for periodic askrene-age on the xrebalance layer.
* Tunable via the `clboss-xrebalance-age-secs` option (dynamic
* -- runtime mutable via `lightning-cli setconfig`). Default
* 3600 mirrors FundsMover's production value. Operators on
* networks with slower flows (signet) typically widen this
* via setconfig to keep accumulated capacity knowledge longer
* before constraints expire. */
std::uint64_t aging_window_secs;
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
/* For generating MPP groupids -- a u64 random value shared
* across all parts of one xmovefunds invocation. */
std::mt19937_64 rng;
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
struct Params {
std::vector<Ln::Scid> source_scids;
std::vector<Ln::Scid> dest_scids;
Ln::Amount amount;
/* Raw user-supplied caps. At least one must be present;
* `maxfee` below is the binding cap actually passed to
* askrene. Echoed back in the reply when present so the
* caller can see exactly what they asked for vs what
* ended up binding. */
std::optional<Ln::Amount> maxfee_msat_in;
std::optional<std::uint32_t> maxfee_ppm_in;
/* Effective binding cap: the smaller of
* (maxfee_msat_in, amount_msat * maxfee_ppm_in / 1e6)
* when both are set, the single value when only one is
* set. This is what gets passed to
* askrene-getroutes. */
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
Ln::Amount maxfee;
std::uint32_t maxparts;
bool execute;
};
/* Parse the JSON params object. Throws on bad input. */
Params parse_params(Jsmn::Object const& params) {
auto p = Params();
p.maxparts = 10;
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
/* execute=true is the default on signet per
* DEVSTATE/XREBALANCE-PLAN-2026-05-30.org
* "Manual command actually executes (signet)".
* Caller passes execute=false to get the
* plan-only response (predict-and-compare mode). */
p.execute = true;
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
if (!params.is_object())
throw std::runtime_error(
"params must be an object "
"(named-parameter form required)");
if (!params.has("source_scid"))
throw std::runtime_error("source_scid required");
p.source_scids =
parse_scid_list(params["source_scid"], "source_scid");
if (!params.has("dest_scid"))
throw std::runtime_error("dest_scid required");
p.dest_scids =
parse_scid_list(params["dest_scid"], "dest_scid");
if (!params.has("amount_msat"))
throw std::runtime_error("amount_msat required");
try {
p.amount = Ln::Amount::object(params["amount_msat"]);
} catch (std::exception const&) {
throw std::runtime_error(
"amount_msat must be an integer number of "
"msat (as a JSON number or string)");
}
auto amount_msat = std::uint64_t(p.amount.to_msat());
if (amount_msat == 0)
throw std::runtime_error("amount_msat must be > 0");
/* Fee cap: caller must explicitly specify at least one
* of `maxfee_msat` (absolute cap) or `maxfee_ppm`
* (relative cap as parts-per-million of amount). No
* default -- a silent default would be a real-funds
* foot-gun on mainnet and is the wrong primitive
* contract for the Layer 2 / Layer 3 callers that will
* build on top. See "Reconsider default maxfee_msat"
* in DEVSTATE/XREBALANCE-PLAN-2026-05-30.org for the
* reasoning. */
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
if (params.has("maxfee_msat")) {
try {
p.maxfee_msat_in = Ln::Amount::object(
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
params["maxfee_msat"]);
} catch (std::exception const&) {
throw std::runtime_error(
"maxfee_msat must be an integer "
"number of msat");
}
}
if (params.has("maxfee_ppm")) {
p.maxfee_ppm_in = parse_u32( params["maxfee_ppm"]
, "maxfee_ppm"
);
}
if (!p.maxfee_msat_in && !p.maxfee_ppm_in) {
throw std::runtime_error(
"at least one of maxfee_msat or "
"maxfee_ppm must be specified");
}
/* Effective binding cap: take the more restrictive of
* the two when both are given. Computed in msat so the
* comparison is direct. Note this can legitimately
* round to 0 for small `amount` * small `ppm`; we let
* askrene surface "no usable paths" in that case rather
* than silently raise the cap -- the explicit-cap
* contract above is the whole point. */
auto cap_msat = std::numeric_limits<std::uint64_t>::max();
if (p.maxfee_msat_in) {
cap_msat = std::min(
cap_msat,
std::uint64_t(p.maxfee_msat_in->to_msat()));
}
if (p.maxfee_ppm_in) {
auto from_ppm = amount_msat
* std::uint64_t(*p.maxfee_ppm_in)
/ std::uint64_t(1000000);
cap_msat = std::min(cap_msat, from_ppm);
}
p.maxfee = Ln::Amount::msat(cap_msat);
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
if (params.has("maxparts")) {
p.maxparts = parse_u32(params["maxparts"],
"maxparts");
if (p.maxparts == 0)
throw std::runtime_error(
"maxparts must be > 0");
}
if (params.has("execute")) {
auto e = params["execute"];
if (e.is_boolean()) {
p.execute = bool(e);
} else if (e.is_string()) {
auto s = std::string(e);
if (s == "true") p.execute = true;
else if (s == "false") p.execute = false;
else throw std::runtime_error(
"execute must be a boolean");
} else {
throw std::runtime_error(
"execute must be a boolean");
}
}
return p;
}
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
/* Ensure the persistent xrebalance askrene layer exists.
* Called once at startup, fire-and-forget. Idempotent: when
* persistent is true, askrene-create-layer succeeds even if
* the layer already exists. Failures (e.g. CLN < v24.11
* where the RPC does not exist, or stock CLN that lacks the
* circular-routing patch) are logged but non-fatal --
* subsequent xmovefunds calls will surface the underlying
* crash if the caller invokes them. */
Ev::Io<void> create_xrebalance_layer() {
return Ev::lift().then([this]() {
auto parms = Json::Out()
.start_object()
.field("layer",
Boss::Mod::AskreneLayer::
xrebalance_layer_name)
.field("persistent", true)
.end_object()
;
return rpc->command( "askrene-create-layer"
, std::move(parms)
);
}).then([this](Jsmn::Object _) {
layer_ready = true;
return Boss::log( bus, Debug
, "XMoveFunds: persistent "
"askrene layer '%s' ready"
, Boss::Mod::AskreneLayer::
xrebalance_layer_name
.c_str()
);
}).catching<RpcError>([this](RpcError const& e) {
/* Mark ready even on failure: degraded mode
* must still allow plan calls to proceed
* (their getroutes call will surface a clearer
* error than us deadlocking on
* wait_for_ready). */
layer_ready = true;
return Boss::log( bus, Error
, "XMoveFunds: askrene-create-"
"layer (%s) failed: %s; will "
"proceed in degraded mode "
"(no persistent learning "
"layer)."
, Boss::Mod::AskreneLayer::
xrebalance_layer_name
.c_str()
, Util::stringify(e.error).c_str()
);
});
}
EarningsTracker: attribute clboss-xrebalance per-part fees + amounts XMoveFunds successful rebalances were moving real sats but reporting nothing to clboss's earnings stats -- they did not show up in clboss-status, in the fee model, or anywhere else operators could see them. This wires per-part attribution end-to-end via a new bus message. New message: Boss::Msg::XRebalanceAttribution { source, destination, amount_moved, fee_spent } Emission (XMoveFunds): Inside the existing waitsendpay-success branch, after pushing the result and accumulating positive-reinforcement feedback for the middle hops, parse the askrene path for this part: - source peer = path[0]["node_id_out"] -- the peer we forwarded to on the first hop, i.e. the far end of the source channel for this part. - dest peer = path[last]["node_id_in"] -- the peer that forwarded back to us on the closing hop (a.k.a. fill_peer). - amount_moved = waitsendpay.amount_msat (delivered to us). - fee_spent = amount_sent_msat - amount_msat. Defensive on malformed JSON: missing fields, non-array path, or amount_sent < amount_moved all skip the raise rather than crash the success handler. An MPP-split clboss-xmovefunds invocation that lands different parts on different (source, dest) pairs (the common case once source_scid / dest_scid are sets) gets one attribution per part, with the actual pair each part used -- finer-grained than FundsMover's per-Runner attribution. Subscription (EarningsTracker): Subscribe alongside the existing Msg::ResponseMoveFunds handler and run the same symmetric DB update -- source peer gets in_expenditures += fee and in_rebalanced += amount, destination peer gets out_expenditures += fee and out_rebalanced += amount. No pendings lookup: XMoveFunds already identified both peers before raising, so the new path skips the requester -> Pending map entirely. Symmetry sanity check while wiring: The DEVSTATE/REBALANCER-REDESIGN-2026-05-28.org "Earnings attribution symmetry" note flagged that the FundsMover path might debit only one side. Verified against the current code: response_move_funds already updates both source's in_* and destination's out_* (EarningsTracker.cpp around lines 430 and 447) -- no fix needed there. The doc concern was correct at the time but has been resolved since.
2026-06-01 10:50:34 -07:00
/* Extract per-part earnings attribution from one successful
* waitsendpay result and the matching askrene path, then
* raise Msg::XRebalanceAttribution for EarningsTracker.
*
* Source peer: path[0]["node_id_out"] -- the peer we forwarded
* to on the first real hop, i.e. the far end of the source
* channel for this part. Dest peer: path[N-1]["node_id_in"]
* (a.k.a. fill_peer) -- the peer that forwarded back to us on
* the closing hop.
*
* Amount moved: waitsendpay.amount_msat (delivered = amount
* that returned to us via the closing hop). Fee spent:
* waitsendpay.amount_sent_msat - amount_msat (total paid
* across middle hops for this part).
*
* Defensive on malformed inputs: any missing field skips the
* raise (returns Ev::lift()). In practice the path and
* waitsendpay result will both be well-formed on the success
* branch; the guard exists so a future shape change does not
* crash the success handler. */
Ev::Io<void>
raise_attribution(Jsmn::Object askrene_path,
Jsmn::Object waitsendpay_result) {
try {
if (!askrene_path.is_array()
|| askrene_path.size() == 0)
return Ev::lift();
auto first = askrene_path[std::size_t(0)];
auto last =
askrene_path[askrene_path.size() - 1];
if (!first.is_object() || !first.has("node_id_out")
|| !last.is_object() || !last.has("node_id_in"))
return Ev::lift();
if (!waitsendpay_result.is_object()
|| !waitsendpay_result.has("amount_msat")
|| !waitsendpay_result.has("amount_sent_msat"))
return Ev::lift();
auto src = Ln::NodeId(
std::string(first["node_id_out"]));
auto dst = Ln::NodeId(
std::string(last["node_id_in"]));
auto amount_moved = Ln::Amount::object(
waitsendpay_result["amount_msat"]);
auto amount_sent = Ln::Amount::object(
waitsendpay_result["amount_sent_msat"]);
if (amount_sent < amount_moved)
return Ev::lift();
auto fee_spent = amount_sent - amount_moved;
return bus.raise(Msg::XRebalanceAttribution{
src, dst, amount_moved, fee_spent
});
} catch (std::exception const&) {
return Ev::lift();
}
}
XMoveFunds: age the clboss-xrebalance layer, runtime-tunable via setconfig Adds periodic askrene-age against the persistent clboss-xrebalance layer so capacity constraints written by inform_channel_constrained do not accumulate forever. Mirrors FundsMover's age_clboss_layer: fires on Msg::TimerRandomHourly, logs num_removed at Debug, and catches RpcError with -32601 stayed at Debug (graceful degradation on CLN without askrene-age) while other codes promote to Warn so sustained aging failure is visible. The aging cutoff is controlled by a new plugin option, clboss-xrebalance-age-secs, default 3600 (1h) to match FundsMover's production value. Operators on networks with slower flows (signet) are expected to widen this; the right value is empirical and will be tuned after observation. The option is registered dynamic=true so the window is mutable at runtime via: lightning-cli setconfig clboss-xrebalance-age-secs <secs> No clboss / lightningd restart required. To support that, three small infrastructure pieces: * Boss::Msg::ManifestOption gains a bool dynamic field (default false; preserves existing behavior). * 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 flag) from Msg::ManifestOption events, then handles incoming Msg::CommandRequest where command == "setconfig" by validating the named option is registered + dynamic and re-raising a fresh Msg::Option on the bus. Existing option handlers re-apply the new value transparently. Contract for any future opt-in to dynamic: at startup lightningd encodes Int / Bool / Flag option values as JSON primitives, but at setconfig time it encodes them as JSON strings. Handlers for dynamic options must accept both Jsmn shapes. The XMoveFunds option handler does this; the contract is documented in SetConfigHandler's header comment so future modules can opt in safely.
2026-06-01 09:31:50 -07:00
/* Trim xrebalance-layer constraints older than
* aging_window_secs. Modeled on FundsMover's age_clboss_layer
* (Boss/Mod/FundsMover/Main.cpp). No self-loop guard refresh
* here -- xrebalance layer does not carry a self disable_node
* entry (its ephemeral per-request masking already excludes
* non-source/non-dest us-channels).
*
* RpcError taxonomy matches FundsMover: JSON-RPC -32601
* (method not found) stays Debug for graceful degradation on
* CLN < v24.11 where askrene-age is absent; any other code is
* promoted to Warn since a sustained aging failure lets stale
* pessimism accumulate.
*
* channel_updates left to refresh-on-failure overwrite: askrene-
* age intentionally skips layer->local_updates, so this RPC
* only trims `constraints` written by inform_channel_*. Policy
* overrides written via askrene-update-channel refresh
* themselves whenever a fresh failure carries a new
* channel_update payload (gossmap_local_updatechan merges).
*/
Ev::Io<void> age_xrebalance_layer() {
return Ev::lift().then([this]() {
auto cutoff = std::uint64_t(std::time(nullptr))
- aging_window_secs;
auto parms = Json::Out()
.start_object()
.field( "layer"
, Boss::Mod::AskreneLayer::
xrebalance_layer_name
)
.field("cutoff", cutoff)
.end_object()
;
return rpc->command( "askrene-age"
, std::move(parms)
);
}).then([this](Jsmn::Object res) {
auto removed = std::uint64_t(0);
if (res.has("num_removed")
&& res["num_removed"].is_number())
removed = std::uint64_t(double(res["num_removed"]));
return Boss::log( bus, Debug
, "XMoveFunds: askrene-age (%s) "
"removed %" PRIu64 " stale entries."
, Boss::Mod::AskreneLayer::
xrebalance_layer_name
.c_str()
, removed
);
}).catching<RpcError>([this](RpcError const& e) {
auto code = int(0);
if (e.error.has("code") && e.error["code"].is_number())
code = int(double(e.error["code"]));
auto is_method_missing = (code == -32601);
return Boss::log( bus
, is_method_missing ? Debug : Warn
, "XMoveFunds: askrene-age (%s) "
"failed: %s%s"
, Boss::Mod::AskreneLayer::
xrebalance_layer_name
.c_str()
, Util::stringify(e.error).c_str()
, is_method_missing
? " (RPC missing; aging "
"unavailable on this CLN)."
: " (unexpected; stale "
"entries will accumulate "
"until next successful "
"aging pass)."
);
});
}
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
/* Gate command handling on startup completion: rpc must have
* arrived via Msg::Init, and create_xrebalance_layer() must
* have completed (either successfully or via the logged-
* RpcError graceful-degradation path). */
Ev::Io<void> wait_for_ready() {
return Ev::lift().then([this]() {
if (!rpc || !layer_ready)
return Ev::yield() + wait_for_ready();
return Ev::lift();
});
}
/* Fetch our channels via listpeerchannels. Returns the
* "channels" array. */
Ev::Io<Jsmn::Object> list_my_channels() {
auto parms = Json::Out()
.start_object()
.end_object()
;
return rpc->command( "listpeerchannels"
, std::move(parms)
).then([](Jsmn::Object res) {
return Ev::lift(res["channels"]);
});
}
/* Create a transient (persistent=false) askrene layer. Used
* for the per-request mask state. */
Ev::Io<void> create_transient_layer(std::string layer) {
auto parms = Json::Out()
.start_object()
.field("layer", layer)
.field("persistent", false)
.end_object()
;
return rpc->command( "askrene-create-layer"
, std::move(parms)
).then([](Jsmn::Object _) {
return Ev::lift();
});
}
/* Remove a transient askrene layer. Best-effort: any RpcError
* is logged but swallowed because we may be on a cleanup path
* after some other failure and the caller has already given
* up. */
Ev::Io<void> remove_layer(std::string layer) {
auto parms = Json::Out()
.start_object()
.field("layer", layer)
.end_object()
;
return rpc->command( "askrene-remove-layer"
, std::move(parms)
).then([](Jsmn::Object _) {
return Ev::lift();
}).catching<RpcError>([this, layer](RpcError const& e) {
return Boss::log( bus, Debug
, "XMoveFunds: askrene-remove-"
"layer (%s) failed: %s "
"(non-fatal, ignored)"
, layer.c_str()
, Util::stringify(e.error).c_str()
);
});
}
/* Compute the direction (0 or 1) corresponding to "us
* sending into this channel" per BOLT 7 canonical ordering:
* direction 0 is the lower-id node as sender, direction 1 is
* the higher-id node as sender. */
std::uint32_t us_to_peer_dir(Ln::NodeId const& peer) const {
return self_id < peer ? 0 : 1;
}
std::uint32_t peer_to_us_dir(Ln::NodeId const& peer) const {
return self_id < peer ? 1 : 0;
}
/* For each of our channels, decide whether each direction
* should be masked off in the transient layer, and return a
* chained Ev::Io<void> that writes all the masks
* sequentially. */
Ev::Io<void>
write_masks(std::string layer,
Jsmn::Object channels,
Params const& p) {
auto source_set = std::set<std::string>();
for (auto const& s : p.source_scids)
source_set.insert(std::string(s));
auto dest_set = std::set<std::string>();
for (auto const& s : p.dest_scids)
dest_set.insert(std::string(s));
auto chain = Ev::lift();
auto count = std::size_t(0);
for (auto i = std::size_t(0); i < channels.size(); ++i) {
auto ch = channels[i];
if (!ch.has("state")
|| std::string(ch["state"])
!= "CHANNELD_NORMAL")
continue;
if (!ch.has("short_channel_id")
|| !ch.has("peer_id"))
continue;
auto scid_str =
std::string(ch["short_channel_id"]);
auto scid = Ln::Scid(scid_str);
auto peer = Ln::NodeId(
std::string(ch["peer_id"]));
auto disable_dir =
[this, layer]
(Ln::Scid s, std::uint32_t dir) {
return Boss::Mod::AskreneLayer::
update_channel(
*rpc, layer, s, dir,
/* enabled = */ false,
Ln::Amount::msat(0),
Ln::Amount::msat(0),
Ln::Amount::msat(0),
/* fee_prop = */ 0,
/* cltv = */ 0);
};
if (!source_set.count(scid_str)) {
chain = std::move(chain)
+ disable_dir(scid,
us_to_peer_dir(peer));
++count;
}
if (!dest_set.count(scid_str)) {
chain = std::move(chain)
+ disable_dir(scid,
peer_to_us_dir(peer));
++count;
}
}
return std::move(chain)
+ Boss::log( bus, Debug
, "XMoveFunds: wrote %zu mask "
"entries to transient layer %s"
, count
, layer.c_str()
);
}
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
/* Given the askrene route's last visible hop (whose
* node_id_out is the fill peer the cycle terminates at),
* locate the dest_scid that connects us to that peer. Used
* to find the channel for the closing hop of the circular
* cycle. */
std::string
find_fill_scid(Jsmn::Object const& channels,
std::set<std::string> const& dest_set,
Ln::NodeId const& fill_peer) {
for (auto i = std::size_t(0); i < channels.size(); ++i) {
auto ch = channels[i];
if (!ch.has("short_channel_id")
|| !ch.has("peer_id"))
continue;
auto scid_str =
std::string(ch["short_channel_id"]);
if (!dest_set.count(scid_str))
continue;
auto peer = Ln::NodeId(
std::string(ch["peer_id"]));
if (peer == fill_peer)
return scid_str;
}
throw std::runtime_error(
"could not find a dest_scid matching the "
"askrene route's last fill peer "
+ std::string(fill_peer));
}
/* Convert an askrene path hop into a sendpay-format hop
* object. See Boss/Mod/FundsMover/Attempter.cpp::make_route
* for the field convention. */
Json::Out
askrene_hop_to_sendpay(Jsmn::Object const& hop) {
auto scidd = std::string(hop["short_channel_id_dir"]);
auto slash = scidd.find('/');
auto scid_str = scidd.substr(0, slash);
auto dir = std::uint32_t(
std::stoul(scidd.substr(slash + 1)));
auto amount_out =
Ln::Amount::object(hop["amount_out_msat"]);
auto cltv_out = std::uint32_t(
double(hop["cltv_out"]));
return Json::Out()
.start_object()
.field("id",
std::string(hop["node_id_out"]))
.field("channel", scid_str)
.field("direction", dir)
.field("amount_msat",
amount_out.to_msat())
.field("delay", cltv_out)
.field("style", std::string("tlv"))
.end_object()
;
}
/* Build the full sendpay route array for one askrene route.
*
* The patched askrene (branch circular-askrene4) splices a
* fake destination node (circular_fake_us_in_id) plus mirror
* channels onto it before running MCF, then returns the
* complete s -> t flow WITHOUT stripping the trailing fake
* mirror hop. The last hop of every circular-mode route is
* therefore the fake mirror: synthetic scid, node_id_out =
* the fake destination, but amount_in_msat /
* amount_out_msat / cltv_in / cltv_out all computed with
* fill_peer's actual policy (so amount_in - amount_out =
* fill_peer's real fee).
*
* For each real network hop in path[0..N-2] we just copy as
* sendpay format. For the last hop (path[N-1] = fake
* mirror) we REPLACE its identity fields with the caller's
* chosen real closing channel + our self_id, but KEEP the
* mirror's amount_msat and delay -- those values came out of
* MCF accounting for fill_peer's fee and CLTV delta and are
* exactly what CLN needs for the closing onion hop.
*
* The earlier strip-then-append design used route.amount_msat
* (= the pre-fee amount fill_peer received) as the closing
* hop amount_msat, which meant we offered 0 fee to fill_peer
* and got WIRE_FEE_INSUFFICIENT on every retry once fill_peer
* was charging anything. Replacing the mirror in-place fixes
* the math without requiring callers to know about the fake
* scid or to look up fill_peer's policy themselves. */
Json::Out
build_sendpay_route(Jsmn::Object const& askrene_route,
std::string const& fill_scid,
Ln::NodeId const& fill_peer) {
auto ret = Json::Out();
auto arr = ret.start_array();
auto path = askrene_route["path"];
auto last_idx = path.size() - 1;
for (auto i = std::size_t(0); i < path.size(); ++i) {
if (i == last_idx) {
/* Replace the fake mirror with the
* real closing hop, keeping the
* mirror's MCF-computed amounts and
* delays. */
auto hop = path[i];
auto amount_out = Ln::Amount::object(
hop["amount_out_msat"]);
auto cltv_out = std::uint32_t(
double(hop["cltv_out"]));
arr.start_object()
.field("id",
std::string(self_id))
.field("channel", fill_scid)
.field("direction",
peer_to_us_dir(
fill_peer))
.field("amount_msat",
amount_out.to_msat())
.field("delay", cltv_out)
.field("style",
std::string("tlv"))
.end_object();
} else {
arr.entry(askrene_hop_to_sendpay(path[i]));
}
}
arr.end_array();
return ret;
}
/* Issue one sendpay for one part of the (possibly MPP)
* payment. partid=0 indicates a non-MPP single-part call;
* partid>=1 indicates one part of an MPP group identified
* by groupid, with total_msat declaring the sum across all
* parts of the group. */
Ev::Io<Jsmn::Object>
sendpay_part(Sha256::Hash const& payment_hash,
Ln::Preimage const& payment_secret,
Json::Out route,
std::string const& label,
std::uint64_t groupid,
std::uint64_t partid,
Ln::Amount total_msat) {
auto parms = Json::Out();
auto obj = parms.start_object();
obj.field("route", std::move(route));
obj.field("payment_hash",
std::string(payment_hash));
obj.field("label", label);
obj.field("payment_secret",
std::string(payment_secret));
if (partid > 0) {
obj.field("partid", partid);
obj.field("groupid", groupid);
obj.field("amount_msat",
total_msat.to_msat());
}
obj.end_object();
return rpc->command("sendpay", std::move(parms));
}
/* Wait for one part to terminate (success or hard
* failure). Mirrors the partid/groupid distinction from
* sendpay_part. */
Ev::Io<Jsmn::Object>
waitsendpay_part(Sha256::Hash const& payment_hash,
std::uint64_t partid,
std::uint64_t groupid) {
auto parms = Json::Out();
auto obj = parms.start_object();
obj.field("payment_hash",
std::string(payment_hash));
if (partid > 0) {
obj.field("partid", partid);
obj.field("groupid", groupid);
}
obj.end_object();
return rpc->command("waitsendpay", std::move(parms));
}
XMoveFunds: delpay failed parts + summary stats in reply Failed-part cleanup: Failed waitsendpay parts previously lingered forever in CLN's listpays, which made a heavy testing session leave dozens of dead payment_hash entries behind. This adds a best-effort delpay_part(payment_hash, partid, groupid, status="failed") invoked from the per-part waitsendpay catching branch, modeled on FundsMover/Attempter::delpay (line ~1570 there). CLN's delpay schema requires "both partid and groupid, or neither" (cln/lightningd/pay.c:2344), so the helper sets both for MPP and neither for the single-part path -- letting CLN delete the only entry by payment_hash in the latter case. RpcError is swallowed. PAY_NO_SUCH_PAYMENT is expected for parts that never reached CLN (sendpay rejected up front), and the rebalance failure itself is already surfaced via err_msgs. Summary stats in the RPC reply: Adds four fields to the top-level response: parts_complete -- count of parts with status == "complete" delivered_msat -- sum of r.amount_msat over the same set fee_total_msat -- sum of (amount_sent_msat - amount_msat) fee_ppm -- fee_total_msat * 1e6 / delivered_msat, omitted when delivered_msat == 0 These are all derivable from the per-part results[] array, but putting them inline lets operators (and jq/awk scripts) read the outcome of a rebalance without post-processing -- and the numbers match what EarningsTracker records via the per-part Msg::XRebalanceAttribution path. Defensive: parts that lack status, amount_msat, or amount_sent_msat are skipped, as are parts where sent < msat (which would only happen on corrupt CLN output). Failed parts have a Jsmn::Object() placeholder in results so they naturally fall out of the filter.
2026-06-01 11:34:57 -07:00
/* Best-effort delpay for one failed sendpay part. Keeps CLN's
* payment store from accumulating dead payment_hash entries
* across heavy testing sessions -- mirrors FundsMover/
* Attempter::delpay (Boss/Mod/FundsMover/Attempter.cpp:1570).
*
* MPP: pass partid + groupid together so delpay targets just
* this part. Non-MPP (partid=0): pass neither and let CLN
* delete the only entry by payment_hash. Per CLN's delpay
* schema you must set both or neither -- mixing them returns
* "Must set both partid and groupid, or neither" from
* cln/lightningd/pay.c:2344.
*
* Swallow RpcError: delpay can legitimately fail with
* PAY_NO_SUCH_PAYMENT (the part never made it past sendpay so
* CLN never recorded it) or hit any transient error, neither
* of which we want surfaced -- the rebalance itself is
* already failed and reported via err_msgs. */
Ev::Io<void>
delpay_part(Sha256::Hash payment_hash,
std::uint64_t partid,
std::uint64_t groupid) {
auto parms = Json::Out();
auto obj = parms.start_object();
obj.field("payment_hash", std::string(payment_hash));
obj.field("status", std::string("failed"));
if (partid > 0) {
obj.field("partid", partid);
obj.field("groupid", groupid);
}
obj.end_object();
return rpc->command("delpay", std::move(parms)
).then([](Jsmn::Object _) {
return Ev::lift();
}).catching<RpcError>([](RpcError const&) {
return Ev::lift();
});
}
XMoveFunds: feed sendpay outcomes back into clboss-xrebalance layer Three consecutive lab0 runs hitting the identical broken route proved the obvious failure mode of the manual primitive: with no write-back to the persistent layer, askrene picks the same path every time, and a single flaky intermediate node makes the whole command useless on repeat. This commit closes that loop, in both directions: Failure (per part, in waitsendpay's .catching<RpcError>) - Parse the 204 error data (erring_index, erring_channel, erring_direction, erring_node, failcode). Non-204 errors and malformed payloads are silently skipped -- they have no actionable channel/node to record. - failcode & 0x2000 (NODE-level): append a disable_node call against the persistent xrebalance layer, taking the whole forwarder out of consideration for subsequent calls. - failcode without 0x2000 (channel-level): append an inform_channel_constrained call against erring_channel / erring_direction with the amount the failing hop was asked to push -- recovered from the askrene path by indexing path[erring_index].amount_in_msat (erring_index is the 0-based sendpay-route index, and the askrene path occupies the leading positions of that route). Per askrene-inform-channel semantics this sets max_msat just below the failing amount, so the channel remains usable for strictly smaller payments but is excluded for routes carrying the failing amount or more. Same gradient signal xpay and FundsMover/Attempter write -- it lets askrene's probability estimate distinguish "channel can't push 800m right now" from "channel is dead". Fallback to amount=1 (full exclusion) if the path lookup fails; a conservative signal beats no signal. - If erring_channel matches one of our local-channel scids (source_scids or dest_scids), the write is suppressed. Askrene's auto.localchans is authoritative for local- channel state; constraining it in our layer would only poison routing via the min-across-layers rule. - This MVP intentionally omits the FundsMover/Attempter channel_update-refresh branch (parse_chan_update + update_channel with refreshed policy from the embedded BOLT 07 raw_message). The manual xmovefunds primitive does not yet retry within a single invocation, so the inform-constrained path alone is sufficient to make the NEXT manual invocation pick a different route. The richer policy-refresh path is the obvious next step for the Layer 2+ algorithm. Success (per part, in waitsendpay's .then) - For each network middle hop on the part's askrene path, append an inform_channel_unconstrained call with the amount the hop actually carried (amount_out_msat). This is the standard "channel proved it can push at least X msat recently" lower-bound observation that FundsMover/ Attempter and xpay both write -- it keeps askrene's probability estimate from a monotonic darkening of failure-only writes. - "Network middle hops" = every askrene hop whose scid is NOT one of our local scids. For a circular self-pay this drops the head hop (us -> drain_peer); the appended closing hop (fill_peer -> us) is never an askrene-path hop and is therefore implicitly excluded. Mechanism - per_part_middle (shared) is populated during the sendpay build loop with each part's network middle hops. Indexed by part i so the success branch knows what to reinforce. - feedback_actions (shared) is the per-part accumulator; the .then/.catching lambdas push Ev::Io<void> entries into it. - After the waitsendpay loop a single deferred-eval phase (Ev::lift().then(...) reading feedback_actions at execute time) chains all the accumulated actions and runs them in sequence before assembling the reply. Running feedback after waitsendpay (rather than inline per-part) keeps the payment-critical path latency unaffected and avoids interleaving RPC traffic with sendpay/waitsendpay. - A debug log line reports the number of feedback entries written. Failure modes - inform_channel_* and disable_node already swallow RpcError (degraded-learning posture), so the feedback phase is structurally idempotent -- any partial failure leaves the layer in a coherent state. No new error-handling code needed at this caller level. - All feedback actions write to the same persistent layer (clboss-xrebalance). No aging is added in this commit -- FundsMover already ages the parallel clboss layer on a TimerRandomHourly with a 24h cutoff; extending that timer to also age clboss-xrebalance is the obvious next commit.
2026-05-31 19:06:28 -07:00
/* Inspect a waitsendpay RpcError and append the appropriate
* persistent-layer feedback action to `actions`. No-op if:
* - the error code is not 204 (only sendpay routing
* failures carry erring_* fields);
* - the embedded data is missing or malformed;
XMoveFunds: refresh channel policy on fee/cltv/htlc-bound failures Repeated lab0 runs eventually hit WIRE_FEE_INSUFFICIENT (failcode 0x100c) at the closing hop of the circular payment. The forwarder (our fill peer) was reporting "your fee math used stale gossip; here is my actual current channel_update". Before this commit the handler did the wrong thing for that response: - The failing channel (our fill scid) is in `our_scids`, so the existing inform_channel_constrained fallback was correctly skipped. - But nothing was written in its place: the policy-refresh branch did not exist. - Result: every subsequent getroutes call reused the same stale gossip fee, computed the same wrong closing-hop fee math, and the same forwarder rejected with the same failcode. No learning, no progress, stuck loop. This commit adds the FundsMover/Attempter-style channel_update refresh branch: - For failcodes 0x100b / 0x100c / 0x100d / 0x100e (the four policy-carrying onion-error codes) that include a parseable raw_message channel_update, write an askrene-update-channel against erring_channel/erring_direction with the refreshed enabled flag, cltv_expiry_delta, htlc_minimum_msat, htlc_maximum_msat, fee_base_msat, and fee_proportional_millionths. - The write applies REGARDLESS of whether erring_channel is one of our local-channel scids. The failing direction is always the FORWARDER's outbound direction (their forward to the next hop, or to us at the closing hop), which is gossip-derived even on our own channels. auto.localchans only authoritatively covers our own outbound direction; the peer's direction is what gets stale. The original `our_scids` filter still gates the inform-constrained fallback (capacity-bound writes against our outbound make no sense), but not the policy refresh. - If parse_chan_update fails (malformed payload, truncated, unknown failcode header), fall through to the existing inform_channel_constrained path so we still record SOME signal, just less precise. - All other failcode handling unchanged: NODE-level (failcode & 0x2000) still disables the node; non-policy-carrying channel-level (notably 0x1007 TCF) still goes through inform_channel_constrained with capacity-aware amount. parse_chan_update implementation - ChanUpdate struct + read_be helper + parse_chan_update function copied verbatim from Boss/Mod/FundsMover/Attempter.cpp into the local anonymous namespace at the top of XMoveFunds/Main.cpp. ~80 lines. - Yes, this duplicates FundsMover's copy of the same parser. Extracting to a shared module (Util/, Ln/, or new Boss/Mod/ChanUpdate) is a separate cleanup commit tracked apart from xrebalance work. Going with duplication here to keep the change isolated to XMoveFunds while we are actively iterating on the algorithm. - The parser handles the BOLT 04 onion failure wire layout (failcode + per-failcode header + channel_update length + channel_update bytes) and the optional 2-byte 0x0102 type prefix that CLN-issued channel_updates carry but LND-pre-v0.18 ones do not.
2026-05-31 19:35:15 -07:00
* - we fall through to inform_channel_constrained and
* erring_channel is one of our local-channel scids
* (auto.localchans is authoritative for capacity).
*
* Failcode dispatch:
XMoveFunds: feed sendpay outcomes back into clboss-xrebalance layer Three consecutive lab0 runs hitting the identical broken route proved the obvious failure mode of the manual primitive: with no write-back to the persistent layer, askrene picks the same path every time, and a single flaky intermediate node makes the whole command useless on repeat. This commit closes that loop, in both directions: Failure (per part, in waitsendpay's .catching<RpcError>) - Parse the 204 error data (erring_index, erring_channel, erring_direction, erring_node, failcode). Non-204 errors and malformed payloads are silently skipped -- they have no actionable channel/node to record. - failcode & 0x2000 (NODE-level): append a disable_node call against the persistent xrebalance layer, taking the whole forwarder out of consideration for subsequent calls. - failcode without 0x2000 (channel-level): append an inform_channel_constrained call against erring_channel / erring_direction with the amount the failing hop was asked to push -- recovered from the askrene path by indexing path[erring_index].amount_in_msat (erring_index is the 0-based sendpay-route index, and the askrene path occupies the leading positions of that route). Per askrene-inform-channel semantics this sets max_msat just below the failing amount, so the channel remains usable for strictly smaller payments but is excluded for routes carrying the failing amount or more. Same gradient signal xpay and FundsMover/Attempter write -- it lets askrene's probability estimate distinguish "channel can't push 800m right now" from "channel is dead". Fallback to amount=1 (full exclusion) if the path lookup fails; a conservative signal beats no signal. - If erring_channel matches one of our local-channel scids (source_scids or dest_scids), the write is suppressed. Askrene's auto.localchans is authoritative for local- channel state; constraining it in our layer would only poison routing via the min-across-layers rule. - This MVP intentionally omits the FundsMover/Attempter channel_update-refresh branch (parse_chan_update + update_channel with refreshed policy from the embedded BOLT 07 raw_message). The manual xmovefunds primitive does not yet retry within a single invocation, so the inform-constrained path alone is sufficient to make the NEXT manual invocation pick a different route. The richer policy-refresh path is the obvious next step for the Layer 2+ algorithm. Success (per part, in waitsendpay's .then) - For each network middle hop on the part's askrene path, append an inform_channel_unconstrained call with the amount the hop actually carried (amount_out_msat). This is the standard "channel proved it can push at least X msat recently" lower-bound observation that FundsMover/ Attempter and xpay both write -- it keeps askrene's probability estimate from a monotonic darkening of failure-only writes. - "Network middle hops" = every askrene hop whose scid is NOT one of our local scids. For a circular self-pay this drops the head hop (us -> drain_peer); the appended closing hop (fill_peer -> us) is never an askrene-path hop and is therefore implicitly excluded. Mechanism - per_part_middle (shared) is populated during the sendpay build loop with each part's network middle hops. Indexed by part i so the success branch knows what to reinforce. - feedback_actions (shared) is the per-part accumulator; the .then/.catching lambdas push Ev::Io<void> entries into it. - After the waitsendpay loop a single deferred-eval phase (Ev::lift().then(...) reading feedback_actions at execute time) chains all the accumulated actions and runs them in sequence before assembling the reply. Running feedback after waitsendpay (rather than inline per-part) keeps the payment-critical path latency unaffected and avoids interleaving RPC traffic with sendpay/waitsendpay. - A debug log line reports the number of feedback entries written. Failure modes - inform_channel_* and disable_node already swallow RpcError (degraded-learning posture), so the feedback phase is structurally idempotent -- any partial failure leaves the layer in a coherent state. No new error-handling code needed at this caller level. - All feedback actions write to the same persistent layer (clboss-xrebalance). No aging is added in this commit -- FundsMover already ages the parallel clboss layer on a TimerRandomHourly with a 24h cutoff; extending that timer to also age clboss-xrebalance is the obvious next commit.
2026-05-31 19:06:28 -07:00
* - failcode & 0x2000 (NODE-level): append a disable_node
XMoveFunds: refresh channel policy on fee/cltv/htlc-bound failures Repeated lab0 runs eventually hit WIRE_FEE_INSUFFICIENT (failcode 0x100c) at the closing hop of the circular payment. The forwarder (our fill peer) was reporting "your fee math used stale gossip; here is my actual current channel_update". Before this commit the handler did the wrong thing for that response: - The failing channel (our fill scid) is in `our_scids`, so the existing inform_channel_constrained fallback was correctly skipped. - But nothing was written in its place: the policy-refresh branch did not exist. - Result: every subsequent getroutes call reused the same stale gossip fee, computed the same wrong closing-hop fee math, and the same forwarder rejected with the same failcode. No learning, no progress, stuck loop. This commit adds the FundsMover/Attempter-style channel_update refresh branch: - For failcodes 0x100b / 0x100c / 0x100d / 0x100e (the four policy-carrying onion-error codes) that include a parseable raw_message channel_update, write an askrene-update-channel against erring_channel/erring_direction with the refreshed enabled flag, cltv_expiry_delta, htlc_minimum_msat, htlc_maximum_msat, fee_base_msat, and fee_proportional_millionths. - The write applies REGARDLESS of whether erring_channel is one of our local-channel scids. The failing direction is always the FORWARDER's outbound direction (their forward to the next hop, or to us at the closing hop), which is gossip-derived even on our own channels. auto.localchans only authoritatively covers our own outbound direction; the peer's direction is what gets stale. The original `our_scids` filter still gates the inform-constrained fallback (capacity-bound writes against our outbound make no sense), but not the policy refresh. - If parse_chan_update fails (malformed payload, truncated, unknown failcode header), fall through to the existing inform_channel_constrained path so we still record SOME signal, just less precise. - All other failcode handling unchanged: NODE-level (failcode & 0x2000) still disables the node; non-policy-carrying channel-level (notably 0x1007 TCF) still goes through inform_channel_constrained with capacity-aware amount. parse_chan_update implementation - ChanUpdate struct + read_be helper + parse_chan_update function copied verbatim from Boss/Mod/FundsMover/Attempter.cpp into the local anonymous namespace at the top of XMoveFunds/Main.cpp. ~80 lines. - Yes, this duplicates FundsMover's copy of the same parser. Extracting to a shared module (Util/, Ln/, or new Boss/Mod/ChanUpdate) is a separate cleanup commit tracked apart from xrebalance work. Going with duplication here to keep the change isolated to XMoveFunds while we are actively iterating on the algorithm. - The parser handles the BOLT 04 onion failure wire layout (failcode + per-failcode header + channel_update length + channel_update bytes) and the optional 2-byte 0x0102 type prefix that CLN-issued channel_updates carry but LND-pre-v0.18 ones do not.
2026-05-31 19:35:15 -07:00
* action on the persistent xrebalance layer.
* - failcode is one of 0x100b/0x100c/0x100d/0x100e and
* raw_message parses as a channel_update: append an
* update_channel action with the refreshed policy
* fields. This is the FundsMover/Attempter-style
* "channel_update refresh" branch. Critically, this
* write applies REGARDLESS of whether erring_channel is
* one of our local scids -- the failing direction is
* always the FORWARDER's direction (their outbound to
* the next hop, or to us at the closing hop), which is
* gossip-derived even on our own channels.
* auto.localchans only authoritatively covers our own
* outbound direction; the peer's direction comes from
* gossip and is what gets stale. Without this branch,
* a fee_insufficient at the closing hop of our circular
* payment loops forever with no learning -- exactly
* what we observed on lab0 the first time askrene chose
* a route through a forwarder whose published fee was
* out of date.
* - otherwise (channel-level, including 0x1007 TCF):
* append inform_channel_constrained with the
XMoveFunds: feed sendpay outcomes back into clboss-xrebalance layer Three consecutive lab0 runs hitting the identical broken route proved the obvious failure mode of the manual primitive: with no write-back to the persistent layer, askrene picks the same path every time, and a single flaky intermediate node makes the whole command useless on repeat. This commit closes that loop, in both directions: Failure (per part, in waitsendpay's .catching<RpcError>) - Parse the 204 error data (erring_index, erring_channel, erring_direction, erring_node, failcode). Non-204 errors and malformed payloads are silently skipped -- they have no actionable channel/node to record. - failcode & 0x2000 (NODE-level): append a disable_node call against the persistent xrebalance layer, taking the whole forwarder out of consideration for subsequent calls. - failcode without 0x2000 (channel-level): append an inform_channel_constrained call against erring_channel / erring_direction with the amount the failing hop was asked to push -- recovered from the askrene path by indexing path[erring_index].amount_in_msat (erring_index is the 0-based sendpay-route index, and the askrene path occupies the leading positions of that route). Per askrene-inform-channel semantics this sets max_msat just below the failing amount, so the channel remains usable for strictly smaller payments but is excluded for routes carrying the failing amount or more. Same gradient signal xpay and FundsMover/Attempter write -- it lets askrene's probability estimate distinguish "channel can't push 800m right now" from "channel is dead". Fallback to amount=1 (full exclusion) if the path lookup fails; a conservative signal beats no signal. - If erring_channel matches one of our local-channel scids (source_scids or dest_scids), the write is suppressed. Askrene's auto.localchans is authoritative for local- channel state; constraining it in our layer would only poison routing via the min-across-layers rule. - This MVP intentionally omits the FundsMover/Attempter channel_update-refresh branch (parse_chan_update + update_channel with refreshed policy from the embedded BOLT 07 raw_message). The manual xmovefunds primitive does not yet retry within a single invocation, so the inform-constrained path alone is sufficient to make the NEXT manual invocation pick a different route. The richer policy-refresh path is the obvious next step for the Layer 2+ algorithm. Success (per part, in waitsendpay's .then) - For each network middle hop on the part's askrene path, append an inform_channel_unconstrained call with the amount the hop actually carried (amount_out_msat). This is the standard "channel proved it can push at least X msat recently" lower-bound observation that FundsMover/ Attempter and xpay both write -- it keeps askrene's probability estimate from a monotonic darkening of failure-only writes. - "Network middle hops" = every askrene hop whose scid is NOT one of our local scids. For a circular self-pay this drops the head hop (us -> drain_peer); the appended closing hop (fill_peer -> us) is never an askrene-path hop and is therefore implicitly excluded. Mechanism - per_part_middle (shared) is populated during the sendpay build loop with each part's network middle hops. Indexed by part i so the success branch knows what to reinforce. - feedback_actions (shared) is the per-part accumulator; the .then/.catching lambdas push Ev::Io<void> entries into it. - After the waitsendpay loop a single deferred-eval phase (Ev::lift().then(...) reading feedback_actions at execute time) chains all the accumulated actions and runs them in sequence before assembling the reply. Running feedback after waitsendpay (rather than inline per-part) keeps the payment-critical path latency unaffected and avoids interleaving RPC traffic with sendpay/waitsendpay. - A debug log line reports the number of feedback entries written. Failure modes - inform_channel_* and disable_node already swallow RpcError (degraded-learning posture), so the feedback phase is structurally idempotent -- any partial failure leaves the layer in a coherent state. No new error-handling code needed at this caller level. - All feedback actions write to the same persistent layer (clboss-xrebalance). No aging is added in this commit -- FundsMover already ages the parallel clboss layer on a TimerRandomHourly with a 24h cutoff; extending that timer to also age clboss-xrebalance is the obvious next commit.
2026-05-31 19:06:28 -07:00
* amount the failing hop was being asked to push.
* Askrene stores this as max_msat = amount - 1, so the
* channel still appears usable for strictly smaller
* payments but is excluded for routes carrying the
* failing amount or more. This is the same gradient
* signal xpay and FundsMover/Attempter write -- it lets
* askrene's probability estimate distinguish "channel
* can't push 800m" from "channel is dead".
*
* The per-hop amount is recovered from the askrene path
* by indexing with erring_index. CLN's erring_index is
* the 0-based position in the sendpay route, and the
* askrene path occupies positions [0, askrene_path.
* size()) of the sendpay route (the trailing closing hop
* is appended by us, never an askrene hop). Position K
* in the askrene path is the K-th forwarding edge;
* amount_in_msat at that hop is what gets pushed INTO
* the failing channel.
*
* Fallback to amount=1 (full exclusion) if the path
* lookup fails -- a strictly safer signal than no
* feedback at all.
*
* Mirrors the simpler half of FundsMover/Attempter.cpp's
* 204 handling; we deliberately skip the
* channel_update-refresh branch (parse_chan_update +
* update_channel with policy fields) for now -- the manual
* xmovefunds primitive does not yet retry, so the inform-
* constrained path alone is sufficient to make the NEXT
* manual invocation pick a different route. */
void accumulate_failure_feedback(
RpcError const& e,
std::set<std::string> const& our_scids,
Jsmn::Object const& askrene_path,
std::vector<Ev::Io<void>>& actions) {
try {
auto const& error = e.error;
if (!error.has("code") || !error["code"].is_number())
return;
auto code = int(double(error["code"]));
if (code != 204)
return;
if (!error.has("data"))
return;
auto data = error["data"];
if (!data.has("erring_channel")
|| !data.has("erring_direction")
|| !data.has("erring_node")
|| !data.has("erring_index")
|| !data.has("failcode"))
return;
auto echan_str =
std::string(data["erring_channel"]);
auto edir = std::uint32_t(double(
data["erring_direction"]));
auto enode = Ln::NodeId(
std::string(data["erring_node"]));
auto eidx = std::size_t(double(
data["erring_index"]));
auto fail = std::uint16_t(double(
data["failcode"]));
/* Concise one-line Info summary per 204 (the full per-hop
* table below is logged at Debug to avoid flooding Info on
* a probe-heavy rebalance). */
{
auto alloc = std::uint64_t(0);
if (eidx < askrene_path.size()
&& askrene_path[eidx].has("amount_in_msat")
&& askrene_path[eidx].has("amount_out_msat")) {
auto in = Ln::Amount::object(
askrene_path[eidx]["amount_in_msat"]).to_msat();
auto out = Ln::Amount::object(
askrene_path[eidx]["amount_out_msat"]).to_msat();
alloc = (in >= out) ? in - out : std::uint64_t(0);
}
auto sum = std::ostringstream();
sum << "XMoveFunds: 204 failcode=0x" << std::hex << fail
<< std::dec << " erring=" << echan_str << "/" << edir
<< " node=" << std::string(enode)
<< " alloc_fee=" << alloc << "msat";
ChanUpdate scu;
if (data.has("raw_message")
&& eidx < askrene_path.size()
&& askrene_path[eidx].has("amount_out_msat")
&& parse_chan_update(
std::string(data["raw_message"]), scu)) {
auto out = Ln::Amount::object(
askrene_path[eidx]["amount_out_msat"]).to_msat();
auto req_out = std::uint64_t(scu.fee_base_msat)
+ std::uint64_t(scu.fee_proportional_millionths)
* out / 1000000;
sum << " required_out=" << req_out << "msat";
if (scu.has_inbound_fee)
sum << " inbound_ppm="
<< scu.inbound_fee_proportional_millionths;
}
actions.push_back(Boss::log( bus, Info
, "%s"
, sum.str().c_str()));
}
/* Diagnostic: dump the full per-hop fee picture so a
* FEE_INSUFFICIENT can be dissected -- allocated fee at
* the erring (outgoing) hop vs the required outbound
* fee from the embedded channel_update, plus a pointer
* at the PRECEDING/incoming hop where any inbound fee
* actually lives (it is NOT in this payload). */
{
auto os = std::ostringstream();
os << "XMoveFunds: 204 picture failcode=0x"
<< std::hex << fail << std::dec
<< " erring_index=" << eidx
<< " erring_channel=" << echan_str
<< "/" << edir
<< " erring_node=" << std::string(enode)
<< "\n" << format_route_fees(askrene_path);
if (eidx < askrene_path.size()) {
auto h = askrene_path[eidx];
auto out = h.has("amount_out_msat")
? Ln::Amount::object(
h["amount_out_msat"]).to_msat()
: std::uint64_t(0);
auto in = h.has("amount_in_msat")
? Ln::Amount::object(
h["amount_in_msat"]).to_msat()
: std::uint64_t(0);
auto alloc = (in >= out) ? in - out
: std::uint64_t(0);
os << " erring hop[" << eidx
<< "] allocated fee=" << alloc
<< "msat\n";
if (data.has("raw_message")) {
ChanUpdate dcu;
if (parse_chan_update(
std::string(
data["raw_message"]),
dcu)) {
auto req_out =
std::uint64_t(
dcu.fee_base_msat)
+ std::uint64_t(
dcu.fee_proportional_millionths)
* out / 1000000;
os << " erring-channel"
" policy: out_base="
<< dcu.fee_base_msat
<< " out_ppm="
<< dcu.fee_proportional_millionths
<< " -> required_out="
<< req_out << "msat";
if (dcu.has_inbound_fee)
os << "; inbound_base="
<< dcu.inbound_fee_base_msat
<< " inbound_ppm="
<< dcu.inbound_fee_proportional_millionths;
os << "\n";
}
}
}
if (eidx >= 1 && eidx - 1 < askrene_path.size()) {
auto pre = askrene_path[eidx - 1];
os << " preceding/incoming hop["
<< (eidx - 1) << "] "
<< (pre.has("short_channel_id_dir")
? std::string(
pre["short_channel_id_dir"])
: std::string("?"))
<< " (any inbound fee that bit us"
" lives HERE, not in the payload"
" above)\n";
}
actions.push_back(Boss::log( bus, Debug
, "%s"
, os.str().c_str()));
}
XMoveFunds: feed sendpay outcomes back into clboss-xrebalance layer Three consecutive lab0 runs hitting the identical broken route proved the obvious failure mode of the manual primitive: with no write-back to the persistent layer, askrene picks the same path every time, and a single flaky intermediate node makes the whole command useless on repeat. This commit closes that loop, in both directions: Failure (per part, in waitsendpay's .catching<RpcError>) - Parse the 204 error data (erring_index, erring_channel, erring_direction, erring_node, failcode). Non-204 errors and malformed payloads are silently skipped -- they have no actionable channel/node to record. - failcode & 0x2000 (NODE-level): append a disable_node call against the persistent xrebalance layer, taking the whole forwarder out of consideration for subsequent calls. - failcode without 0x2000 (channel-level): append an inform_channel_constrained call against erring_channel / erring_direction with the amount the failing hop was asked to push -- recovered from the askrene path by indexing path[erring_index].amount_in_msat (erring_index is the 0-based sendpay-route index, and the askrene path occupies the leading positions of that route). Per askrene-inform-channel semantics this sets max_msat just below the failing amount, so the channel remains usable for strictly smaller payments but is excluded for routes carrying the failing amount or more. Same gradient signal xpay and FundsMover/Attempter write -- it lets askrene's probability estimate distinguish "channel can't push 800m right now" from "channel is dead". Fallback to amount=1 (full exclusion) if the path lookup fails; a conservative signal beats no signal. - If erring_channel matches one of our local-channel scids (source_scids or dest_scids), the write is suppressed. Askrene's auto.localchans is authoritative for local- channel state; constraining it in our layer would only poison routing via the min-across-layers rule. - This MVP intentionally omits the FundsMover/Attempter channel_update-refresh branch (parse_chan_update + update_channel with refreshed policy from the embedded BOLT 07 raw_message). The manual xmovefunds primitive does not yet retry within a single invocation, so the inform-constrained path alone is sufficient to make the NEXT manual invocation pick a different route. The richer policy-refresh path is the obvious next step for the Layer 2+ algorithm. Success (per part, in waitsendpay's .then) - For each network middle hop on the part's askrene path, append an inform_channel_unconstrained call with the amount the hop actually carried (amount_out_msat). This is the standard "channel proved it can push at least X msat recently" lower-bound observation that FundsMover/ Attempter and xpay both write -- it keeps askrene's probability estimate from a monotonic darkening of failure-only writes. - "Network middle hops" = every askrene hop whose scid is NOT one of our local scids. For a circular self-pay this drops the head hop (us -> drain_peer); the appended closing hop (fill_peer -> us) is never an askrene-path hop and is therefore implicitly excluded. Mechanism - per_part_middle (shared) is populated during the sendpay build loop with each part's network middle hops. Indexed by part i so the success branch knows what to reinforce. - feedback_actions (shared) is the per-part accumulator; the .then/.catching lambdas push Ev::Io<void> entries into it. - After the waitsendpay loop a single deferred-eval phase (Ev::lift().then(...) reading feedback_actions at execute time) chains all the accumulated actions and runs them in sequence before assembling the reply. Running feedback after waitsendpay (rather than inline per-part) keeps the payment-critical path latency unaffected and avoids interleaving RPC traffic with sendpay/waitsendpay. - A debug log line reports the number of feedback entries written. Failure modes - inform_channel_* and disable_node already swallow RpcError (degraded-learning posture), so the feedback phase is structurally idempotent -- any partial failure leaves the layer in a coherent state. No new error-handling code needed at this caller level. - All feedback actions write to the same persistent layer (clboss-xrebalance). No aging is added in this commit -- FundsMover already ages the parallel clboss layer on a TimerRandomHourly with a 24h cutoff; extending that timer to also age clboss-xrebalance is the obvious next commit.
2026-05-31 19:06:28 -07:00
if (fail & 0x2000) {
/* NODE-level failure: take the whole forwarder
* out of consideration -- but NEVER our own node.
* In a circular rebalance the destination is us,
* so a node-level failure at the closing hop is
* attributed to self; disable_node(self) would
* disable ALL our channels as the source (askrene:
* "source has disabled N of N channels, capacity
* 0msat") and lock out every future rebalance --
* permanently, since disabled_nodes does not age.
* Mirrors FundsMover's self_id guard. */
if (std::string(enode) == std::string(self_id))
return;
XMoveFunds: feed sendpay outcomes back into clboss-xrebalance layer Three consecutive lab0 runs hitting the identical broken route proved the obvious failure mode of the manual primitive: with no write-back to the persistent layer, askrene picks the same path every time, and a single flaky intermediate node makes the whole command useless on repeat. This commit closes that loop, in both directions: Failure (per part, in waitsendpay's .catching<RpcError>) - Parse the 204 error data (erring_index, erring_channel, erring_direction, erring_node, failcode). Non-204 errors and malformed payloads are silently skipped -- they have no actionable channel/node to record. - failcode & 0x2000 (NODE-level): append a disable_node call against the persistent xrebalance layer, taking the whole forwarder out of consideration for subsequent calls. - failcode without 0x2000 (channel-level): append an inform_channel_constrained call against erring_channel / erring_direction with the amount the failing hop was asked to push -- recovered from the askrene path by indexing path[erring_index].amount_in_msat (erring_index is the 0-based sendpay-route index, and the askrene path occupies the leading positions of that route). Per askrene-inform-channel semantics this sets max_msat just below the failing amount, so the channel remains usable for strictly smaller payments but is excluded for routes carrying the failing amount or more. Same gradient signal xpay and FundsMover/Attempter write -- it lets askrene's probability estimate distinguish "channel can't push 800m right now" from "channel is dead". Fallback to amount=1 (full exclusion) if the path lookup fails; a conservative signal beats no signal. - If erring_channel matches one of our local-channel scids (source_scids or dest_scids), the write is suppressed. Askrene's auto.localchans is authoritative for local- channel state; constraining it in our layer would only poison routing via the min-across-layers rule. - This MVP intentionally omits the FundsMover/Attempter channel_update-refresh branch (parse_chan_update + update_channel with refreshed policy from the embedded BOLT 07 raw_message). The manual xmovefunds primitive does not yet retry within a single invocation, so the inform-constrained path alone is sufficient to make the NEXT manual invocation pick a different route. The richer policy-refresh path is the obvious next step for the Layer 2+ algorithm. Success (per part, in waitsendpay's .then) - For each network middle hop on the part's askrene path, append an inform_channel_unconstrained call with the amount the hop actually carried (amount_out_msat). This is the standard "channel proved it can push at least X msat recently" lower-bound observation that FundsMover/ Attempter and xpay both write -- it keeps askrene's probability estimate from a monotonic darkening of failure-only writes. - "Network middle hops" = every askrene hop whose scid is NOT one of our local scids. For a circular self-pay this drops the head hop (us -> drain_peer); the appended closing hop (fill_peer -> us) is never an askrene-path hop and is therefore implicitly excluded. Mechanism - per_part_middle (shared) is populated during the sendpay build loop with each part's network middle hops. Indexed by part i so the success branch knows what to reinforce. - feedback_actions (shared) is the per-part accumulator; the .then/.catching lambdas push Ev::Io<void> entries into it. - After the waitsendpay loop a single deferred-eval phase (Ev::lift().then(...) reading feedback_actions at execute time) chains all the accumulated actions and runs them in sequence before assembling the reply. Running feedback after waitsendpay (rather than inline per-part) keeps the payment-critical path latency unaffected and avoids interleaving RPC traffic with sendpay/waitsendpay. - A debug log line reports the number of feedback entries written. Failure modes - inform_channel_* and disable_node already swallow RpcError (degraded-learning posture), so the feedback phase is structurally idempotent -- any partial failure leaves the layer in a coherent state. No new error-handling code needed at this caller level. - All feedback actions write to the same persistent layer (clboss-xrebalance). No aging is added in this commit -- FundsMover already ages the parallel clboss layer on a TimerRandomHourly with a 24h cutoff; extending that timer to also age clboss-xrebalance is the obvious next commit.
2026-05-31 19:06:28 -07:00
actions.push_back(
Boss::Mod::AskreneLayer::disable_node(
*rpc,
Boss::Mod::AskreneLayer::
xrebalance_layer_name,
enode));
return;
}
XMoveFunds: on FEE_INSUFFICIENT, exclude the incoming channel, not the outgoing A FEE_INSUFFICIENT at a forwarding node means the node required more than we paid, where its required fee is outbound_fee(outgoing channel) + inbound_fee(incoming channel). The onion error names and carries the policy for the node's OUTGOING channel only; the inbound fee that actually causes the shortfall lives on the INCOMING channel (route[erring_index-1]) and is not in the payload. The previous code keyed off the outgoing channel's own inbound-fee TLV and excluded the outgoing channel -- the wrong edge -- and only worked by luck when the node set positive inbound fees broadly or when no channel_update was present. Replace that with discriminating logic for failcode 0x100c: - Compute the fee we allocated at the erring hop (amount_in - amount_out). - If the error carried a channel_update, compute the outgoing channel's required outbound fee (base + prop*amount_out/1e6). - If we already paid the outbound fee (allocated >= required_out) but still failed, the shortfall is the inbound fee on the incoming channel: hard-exclude route[erring_index-1] with inform_channel_constrained(amount=1) -> max_msat=0. This is a self-aging constraint, so it recovers automatically if the peer later drops the inbound fee. - If we underpaid the outbound fee (allocated < required_out), it is a stale outbound policy: refresh the outgoing channel from the channel_update. - If there is no channel_update to compare, treat it as the inbound case and exclude the incoming channel (a plain stale-outbound failure normally carries the update). This also covers the terminally-failed-MPP-part case where waitsendpay omits raw_message. - Never self-exclude one of our own channels; fall through if the incoming hop is local. Other policy-carrying failcodes (amount_below_minimum, incorrect_cltv_expiry, expiry_too_soon) still refresh the outgoing channel, since those genuinely concern the outgoing channel's published policy. Needs prod1 retest against the cyberdyne/Tachyon positive-inbound paths.
2026-06-04 11:14:43 -07:00
/* FEE_INSUFFICIENT (0x100c): the required fee at the
* erring node is outbound_fee(erring/OUTGOING channel) +
* inbound_fee(INCOMING channel). The error names and
* carries the policy for the OUTGOING channel only, so we
* must decide which side is actually at fault:
* - if we already paid the outgoing channel's advertised
* outbound fee (allocated >= required_out) yet still
* failed, the shortfall is the INBOUND fee on the
* incoming channel route[eidx-1] -> hard-exclude that
* channel (a max_msat=0 constraint that self-ages, so it
* recovers if the peer later drops the inbound fee);
* - if we underpaid the outbound fee (allocated <
* required_out), it is a stale outbound policy -> refresh
* the outgoing channel from the embedded channel_update;
* - no channel_update to compare -> treat as the inbound
* case (a plain stale-outbound failure normally carries
* the update) and exclude the incoming channel.
* NB: the OUTGOING channel's own inbound-fee TLV is NOT the
* fee that bit us (that is the incoming channel's), so we no
* longer key off it. */
if (fail == 0x100c) {
/* fee we allocated at the erring (outgoing) hop */
auto alloc = std::uint64_t(0);
if (eidx < askrene_path.size()
&& askrene_path[eidx].has("amount_in_msat")
&& askrene_path[eidx].has("amount_out_msat")) {
auto in = Ln::Amount::object(
askrene_path[eidx]["amount_in_msat"]).to_msat();
auto out = Ln::Amount::object(
askrene_path[eidx]["amount_out_msat"]).to_msat();
alloc = (in >= out) ? in - out : std::uint64_t(0);
}
XMoveFunds: refresh channel policy on fee/cltv/htlc-bound failures Repeated lab0 runs eventually hit WIRE_FEE_INSUFFICIENT (failcode 0x100c) at the closing hop of the circular payment. The forwarder (our fill peer) was reporting "your fee math used stale gossip; here is my actual current channel_update". Before this commit the handler did the wrong thing for that response: - The failing channel (our fill scid) is in `our_scids`, so the existing inform_channel_constrained fallback was correctly skipped. - But nothing was written in its place: the policy-refresh branch did not exist. - Result: every subsequent getroutes call reused the same stale gossip fee, computed the same wrong closing-hop fee math, and the same forwarder rejected with the same failcode. No learning, no progress, stuck loop. This commit adds the FundsMover/Attempter-style channel_update refresh branch: - For failcodes 0x100b / 0x100c / 0x100d / 0x100e (the four policy-carrying onion-error codes) that include a parseable raw_message channel_update, write an askrene-update-channel against erring_channel/erring_direction with the refreshed enabled flag, cltv_expiry_delta, htlc_minimum_msat, htlc_maximum_msat, fee_base_msat, and fee_proportional_millionths. - The write applies REGARDLESS of whether erring_channel is one of our local-channel scids. The failing direction is always the FORWARDER's outbound direction (their forward to the next hop, or to us at the closing hop), which is gossip-derived even on our own channels. auto.localchans only authoritatively covers our own outbound direction; the peer's direction is what gets stale. The original `our_scids` filter still gates the inform-constrained fallback (capacity-bound writes against our outbound make no sense), but not the policy refresh. - If parse_chan_update fails (malformed payload, truncated, unknown failcode header), fall through to the existing inform_channel_constrained path so we still record SOME signal, just less precise. - All other failcode handling unchanged: NODE-level (failcode & 0x2000) still disables the node; non-policy-carrying channel-level (notably 0x1007 TCF) still goes through inform_channel_constrained with capacity-aware amount. parse_chan_update implementation - ChanUpdate struct + read_be helper + parse_chan_update function copied verbatim from Boss/Mod/FundsMover/Attempter.cpp into the local anonymous namespace at the top of XMoveFunds/Main.cpp. ~80 lines. - Yes, this duplicates FundsMover's copy of the same parser. Extracting to a shared module (Util/, Ln/, or new Boss/Mod/ChanUpdate) is a separate cleanup commit tracked apart from xrebalance work. Going with duplication here to keep the change isolated to XMoveFunds while we are actively iterating on the algorithm. - The parser handles the BOLT 04 onion failure wire layout (failcode + per-failcode header + channel_update length + channel_update bytes) and the optional 2-byte 0x0102 type prefix that CLN-issued channel_updates carry but LND-pre-v0.18 ones do not.
2026-05-31 19:35:15 -07:00
ChanUpdate cu;
XMoveFunds: on FEE_INSUFFICIENT, exclude the incoming channel, not the outgoing A FEE_INSUFFICIENT at a forwarding node means the node required more than we paid, where its required fee is outbound_fee(outgoing channel) + inbound_fee(incoming channel). The onion error names and carries the policy for the node's OUTGOING channel only; the inbound fee that actually causes the shortfall lives on the INCOMING channel (route[erring_index-1]) and is not in the payload. The previous code keyed off the outgoing channel's own inbound-fee TLV and excluded the outgoing channel -- the wrong edge -- and only worked by luck when the node set positive inbound fees broadly or when no channel_update was present. Replace that with discriminating logic for failcode 0x100c: - Compute the fee we allocated at the erring hop (amount_in - amount_out). - If the error carried a channel_update, compute the outgoing channel's required outbound fee (base + prop*amount_out/1e6). - If we already paid the outbound fee (allocated >= required_out) but still failed, the shortfall is the inbound fee on the incoming channel: hard-exclude route[erring_index-1] with inform_channel_constrained(amount=1) -> max_msat=0. This is a self-aging constraint, so it recovers automatically if the peer later drops the inbound fee. - If we underpaid the outbound fee (allocated < required_out), it is a stale outbound policy: refresh the outgoing channel from the channel_update. - If there is no channel_update to compare, treat it as the inbound case and exclude the incoming channel (a plain stale-outbound failure normally carries the update). This also covers the terminally-failed-MPP-part case where waitsendpay omits raw_message. - Never self-exclude one of our own channels; fall through if the incoming hop is local. Other policy-carrying failcodes (amount_below_minimum, incorrect_cltv_expiry, expiry_too_soon) still refresh the outgoing channel, since those genuinely concern the outgoing channel's published policy. Needs prod1 retest against the cyberdyne/Tachyon positive-inbound paths.
2026-06-04 11:14:43 -07:00
auto have_cu = data.has("raw_message")
&& eidx < askrene_path.size()
&& askrene_path[eidx].has("amount_out_msat")
&& parse_chan_update(
std::string(data["raw_message"]), cu);
auto outbound_satisfied = true;
if (have_cu) {
auto out = Ln::Amount::object(
askrene_path[eidx]["amount_out_msat"]).to_msat();
auto required_out =
std::uint64_t(cu.fee_base_msat)
+ std::uint64_t(
cu.fee_proportional_millionths)
* out / 1000000;
outbound_satisfied = (alloc >= required_out);
}
/* Inbound-fee case: exclude the INCOMING channel
* route[eidx-1], unless it is one of our own channels
* (auto.localchans owns those; never self-exclude). */
if (outbound_satisfied && eidx >= 1
&& (eidx - 1) < askrene_path.size()
&& askrene_path[eidx - 1].has("short_channel_id_dir")) {
auto scidd = std::string(
askrene_path[eidx - 1]["short_channel_id_dir"]);
auto slash = scidd.find('/');
auto in_scid = scidd.substr(0, slash);
if (!our_scids.count(in_scid)) {
auto in_dir = std::uint32_t(std::stoul(
scidd.substr(slash + 1)));
actions.push_back(
Boss::Mod::AskreneLayer::
inform_channel_constrained(
*rpc,
Boss::Mod::AskreneLayer::
xrebalance_layer_name,
XMoveFunds: on FEE_INSUFFICIENT, exclude the incoming channel, not the outgoing A FEE_INSUFFICIENT at a forwarding node means the node required more than we paid, where its required fee is outbound_fee(outgoing channel) + inbound_fee(incoming channel). The onion error names and carries the policy for the node's OUTGOING channel only; the inbound fee that actually causes the shortfall lives on the INCOMING channel (route[erring_index-1]) and is not in the payload. The previous code keyed off the outgoing channel's own inbound-fee TLV and excluded the outgoing channel -- the wrong edge -- and only worked by luck when the node set positive inbound fees broadly or when no channel_update was present. Replace that with discriminating logic for failcode 0x100c: - Compute the fee we allocated at the erring hop (amount_in - amount_out). - If the error carried a channel_update, compute the outgoing channel's required outbound fee (base + prop*amount_out/1e6). - If we already paid the outbound fee (allocated >= required_out) but still failed, the shortfall is the inbound fee on the incoming channel: hard-exclude route[erring_index-1] with inform_channel_constrained(amount=1) -> max_msat=0. This is a self-aging constraint, so it recovers automatically if the peer later drops the inbound fee. - If we underpaid the outbound fee (allocated < required_out), it is a stale outbound policy: refresh the outgoing channel from the channel_update. - If there is no channel_update to compare, treat it as the inbound case and exclude the incoming channel (a plain stale-outbound failure normally carries the update). This also covers the terminally-failed-MPP-part case where waitsendpay omits raw_message. - Never self-exclude one of our own channels; fall through if the incoming hop is local. Other policy-carrying failcodes (amount_below_minimum, incorrect_cltv_expiry, expiry_too_soon) still refresh the outgoing channel, since those genuinely concern the outgoing channel's published policy. Needs prod1 retest against the cyberdyne/Tachyon positive-inbound paths.
2026-06-04 11:14:43 -07:00
Ln::Scid(in_scid), in_dir,
Ln::Amount::msat(1)));
return;
}
XMoveFunds: on FEE_INSUFFICIENT, exclude the incoming channel, not the outgoing A FEE_INSUFFICIENT at a forwarding node means the node required more than we paid, where its required fee is outbound_fee(outgoing channel) + inbound_fee(incoming channel). The onion error names and carries the policy for the node's OUTGOING channel only; the inbound fee that actually causes the shortfall lives on the INCOMING channel (route[erring_index-1]) and is not in the payload. The previous code keyed off the outgoing channel's own inbound-fee TLV and excluded the outgoing channel -- the wrong edge -- and only worked by luck when the node set positive inbound fees broadly or when no channel_update was present. Replace that with discriminating logic for failcode 0x100c: - Compute the fee we allocated at the erring hop (amount_in - amount_out). - If the error carried a channel_update, compute the outgoing channel's required outbound fee (base + prop*amount_out/1e6). - If we already paid the outbound fee (allocated >= required_out) but still failed, the shortfall is the inbound fee on the incoming channel: hard-exclude route[erring_index-1] with inform_channel_constrained(amount=1) -> max_msat=0. This is a self-aging constraint, so it recovers automatically if the peer later drops the inbound fee. - If we underpaid the outbound fee (allocated < required_out), it is a stale outbound policy: refresh the outgoing channel from the channel_update. - If there is no channel_update to compare, treat it as the inbound case and exclude the incoming channel (a plain stale-outbound failure normally carries the update). This also covers the terminally-failed-MPP-part case where waitsendpay omits raw_message. - Never self-exclude one of our own channels; fall through if the incoming hop is local. Other policy-carrying failcodes (amount_below_minimum, incorrect_cltv_expiry, expiry_too_soon) still refresh the outgoing channel, since those genuinely concern the outgoing channel's published policy. Needs prod1 retest against the cyberdyne/Tachyon positive-inbound paths.
2026-06-04 11:14:43 -07:00
}
/* Stale outbound fee: refresh the outgoing channel. */
if (have_cu) {
actions.push_back(
Boss::Mod::AskreneLayer::update_channel(
*rpc,
Boss::Mod::AskreneLayer::
xrebalance_layer_name,
Ln::Scid(echan_str), edir,
cu.enabled,
Ln::Amount::msat(cu.htlc_minimum_msat),
Ln::Amount::msat(cu.htlc_maximum_msat),
Ln::Amount::msat(cu.fee_base_msat),
cu.fee_proportional_millionths,
cu.cltv_expiry_delta));
return;
}
/* else: fall through to the capacity constraint. */
}
/* Other policy-carrying failcodes (amount_below_minimum,
* incorrect_cltv_expiry, expiry_too_soon) genuinely concern
* the OUTGOING channel's published policy -- refresh it from
* the embedded channel_update. */
auto policy_carrying =
fail == 0x100b
|| fail == 0x100d
|| fail == 0x100e;
if (policy_carrying && data.has("raw_message")) {
auto raw =
std::string(data["raw_message"]);
ChanUpdate cu;
if (parse_chan_update(raw, cu)) {
XMoveFunds: refresh channel policy on fee/cltv/htlc-bound failures Repeated lab0 runs eventually hit WIRE_FEE_INSUFFICIENT (failcode 0x100c) at the closing hop of the circular payment. The forwarder (our fill peer) was reporting "your fee math used stale gossip; here is my actual current channel_update". Before this commit the handler did the wrong thing for that response: - The failing channel (our fill scid) is in `our_scids`, so the existing inform_channel_constrained fallback was correctly skipped. - But nothing was written in its place: the policy-refresh branch did not exist. - Result: every subsequent getroutes call reused the same stale gossip fee, computed the same wrong closing-hop fee math, and the same forwarder rejected with the same failcode. No learning, no progress, stuck loop. This commit adds the FundsMover/Attempter-style channel_update refresh branch: - For failcodes 0x100b / 0x100c / 0x100d / 0x100e (the four policy-carrying onion-error codes) that include a parseable raw_message channel_update, write an askrene-update-channel against erring_channel/erring_direction with the refreshed enabled flag, cltv_expiry_delta, htlc_minimum_msat, htlc_maximum_msat, fee_base_msat, and fee_proportional_millionths. - The write applies REGARDLESS of whether erring_channel is one of our local-channel scids. The failing direction is always the FORWARDER's outbound direction (their forward to the next hop, or to us at the closing hop), which is gossip-derived even on our own channels. auto.localchans only authoritatively covers our own outbound direction; the peer's direction is what gets stale. The original `our_scids` filter still gates the inform-constrained fallback (capacity-bound writes against our outbound make no sense), but not the policy refresh. - If parse_chan_update fails (malformed payload, truncated, unknown failcode header), fall through to the existing inform_channel_constrained path so we still record SOME signal, just less precise. - All other failcode handling unchanged: NODE-level (failcode & 0x2000) still disables the node; non-policy-carrying channel-level (notably 0x1007 TCF) still goes through inform_channel_constrained with capacity-aware amount. parse_chan_update implementation - ChanUpdate struct + read_be helper + parse_chan_update function copied verbatim from Boss/Mod/FundsMover/Attempter.cpp into the local anonymous namespace at the top of XMoveFunds/Main.cpp. ~80 lines. - Yes, this duplicates FundsMover's copy of the same parser. Extracting to a shared module (Util/, Ln/, or new Boss/Mod/ChanUpdate) is a separate cleanup commit tracked apart from xrebalance work. Going with duplication here to keep the change isolated to XMoveFunds while we are actively iterating on the algorithm. - The parser handles the BOLT 04 onion failure wire layout (failcode + per-failcode header + channel_update length + channel_update bytes) and the optional 2-byte 0x0102 type prefix that CLN-issued channel_updates carry but LND-pre-v0.18 ones do not.
2026-05-31 19:35:15 -07:00
actions.push_back(
Boss::Mod::AskreneLayer::
update_channel(
*rpc,
Boss::Mod::AskreneLayer::
xrebalance_layer_name,
Ln::Scid(echan_str), edir,
cu.enabled,
Ln::Amount::msat(
cu.htlc_minimum_msat),
Ln::Amount::msat(
cu.htlc_maximum_msat),
Ln::Amount::msat(
cu.fee_base_msat),
cu.fee_proportional_millionths,
cu.cltv_expiry_delta));
return;
}
/* Fall through to inform-constrained
* fallback if parse failed. */
}
/* Channel-level failure (or unparseable
* policy-carrying failure): skip if it is one
* of our local channels. auto.localchans owns
* the capacity truth on our own outbound. */
XMoveFunds: feed sendpay outcomes back into clboss-xrebalance layer Three consecutive lab0 runs hitting the identical broken route proved the obvious failure mode of the manual primitive: with no write-back to the persistent layer, askrene picks the same path every time, and a single flaky intermediate node makes the whole command useless on repeat. This commit closes that loop, in both directions: Failure (per part, in waitsendpay's .catching<RpcError>) - Parse the 204 error data (erring_index, erring_channel, erring_direction, erring_node, failcode). Non-204 errors and malformed payloads are silently skipped -- they have no actionable channel/node to record. - failcode & 0x2000 (NODE-level): append a disable_node call against the persistent xrebalance layer, taking the whole forwarder out of consideration for subsequent calls. - failcode without 0x2000 (channel-level): append an inform_channel_constrained call against erring_channel / erring_direction with the amount the failing hop was asked to push -- recovered from the askrene path by indexing path[erring_index].amount_in_msat (erring_index is the 0-based sendpay-route index, and the askrene path occupies the leading positions of that route). Per askrene-inform-channel semantics this sets max_msat just below the failing amount, so the channel remains usable for strictly smaller payments but is excluded for routes carrying the failing amount or more. Same gradient signal xpay and FundsMover/Attempter write -- it lets askrene's probability estimate distinguish "channel can't push 800m right now" from "channel is dead". Fallback to amount=1 (full exclusion) if the path lookup fails; a conservative signal beats no signal. - If erring_channel matches one of our local-channel scids (source_scids or dest_scids), the write is suppressed. Askrene's auto.localchans is authoritative for local- channel state; constraining it in our layer would only poison routing via the min-across-layers rule. - This MVP intentionally omits the FundsMover/Attempter channel_update-refresh branch (parse_chan_update + update_channel with refreshed policy from the embedded BOLT 07 raw_message). The manual xmovefunds primitive does not yet retry within a single invocation, so the inform-constrained path alone is sufficient to make the NEXT manual invocation pick a different route. The richer policy-refresh path is the obvious next step for the Layer 2+ algorithm. Success (per part, in waitsendpay's .then) - For each network middle hop on the part's askrene path, append an inform_channel_unconstrained call with the amount the hop actually carried (amount_out_msat). This is the standard "channel proved it can push at least X msat recently" lower-bound observation that FundsMover/ Attempter and xpay both write -- it keeps askrene's probability estimate from a monotonic darkening of failure-only writes. - "Network middle hops" = every askrene hop whose scid is NOT one of our local scids. For a circular self-pay this drops the head hop (us -> drain_peer); the appended closing hop (fill_peer -> us) is never an askrene-path hop and is therefore implicitly excluded. Mechanism - per_part_middle (shared) is populated during the sendpay build loop with each part's network middle hops. Indexed by part i so the success branch knows what to reinforce. - feedback_actions (shared) is the per-part accumulator; the .then/.catching lambdas push Ev::Io<void> entries into it. - After the waitsendpay loop a single deferred-eval phase (Ev::lift().then(...) reading feedback_actions at execute time) chains all the accumulated actions and runs them in sequence before assembling the reply. Running feedback after waitsendpay (rather than inline per-part) keeps the payment-critical path latency unaffected and avoids interleaving RPC traffic with sendpay/waitsendpay. - A debug log line reports the number of feedback entries written. Failure modes - inform_channel_* and disable_node already swallow RpcError (degraded-learning posture), so the feedback phase is structurally idempotent -- any partial failure leaves the layer in a coherent state. No new error-handling code needed at this caller level. - All feedback actions write to the same persistent layer (clboss-xrebalance). No aging is added in this commit -- FundsMover already ages the parallel clboss layer on a TimerRandomHourly with a 24h cutoff; extending that timer to also age clboss-xrebalance is the obvious next commit.
2026-05-31 19:06:28 -07:00
if (our_scids.count(echan_str))
return;
/* Look up the per-hop amount entering the
* failing channel. Fall back to 1 msat (full
* exclusion) if anything is unparseable -- a
* conservative signal beats no signal. */
auto constraint_amount = Ln::Amount::msat(1);
if (eidx < askrene_path.size()
&& askrene_path[eidx].has("amount_in_msat")) {
constraint_amount = Ln::Amount::object(
askrene_path[eidx]["amount_in_msat"]);
}
actions.push_back(
Boss::Mod::AskreneLayer::
inform_channel_constrained(
*rpc,
Boss::Mod::AskreneLayer::
xrebalance_layer_name,
Ln::Scid(echan_str), edir,
constraint_amount));
} catch (std::exception const&) {
/* Best-effort: malformed payload just means
* no feedback for this part. */
}
}
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
/* Construct a sendpay/EarningsTracker label of the shape
* "clboss-xrebalance-<unix-ts>" so the tracker can
* disambiguate xrebalance-family payments from the older
* FundsMover ones (which already use a "clboss"-prefixed
* convention). See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org
* R4. */
std::string make_label() {
auto t = std::time(nullptr);
auto os = std::ostringstream();
os << "clboss-xrebalance-" << t;
return os.str();
}
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
/* Build and issue the askrene-getroutes call with
* source = destination = self_id (the patched askrene
* interprets this as circular self-rebalance routing).
* Includes auto.localchans, the persistent xrebalance
* layer, and the per-request transient layer. */
Ev::Io<Jsmn::Object>
call_getroutes(std::string transient, Params const& p) {
auto parms = Json::Out();
auto obj = parms.start_object();
obj.field("source", std::string(self_id));
obj.field("destination", std::string(self_id));
obj.field("amount_msat",
std::uint64_t(p.amount.to_msat()));
auto la = obj.start_array("layers");
la.entry(std::string("auto.localchans"));
la.entry(Boss::Mod::AskreneLayer::
xrebalance_layer_name);
la.entry(transient);
la.end_array();
obj.field("maxfee_msat",
std::uint64_t(p.maxfee.to_msat()));
obj.field("final_cltv", std::uint32_t(14));
obj.field("maxparts", p.maxparts);
obj.end_object();
return rpc->command("getroutes", std::move(parms));
}
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
/* Drive the sendpay + waitsendpay sequence for one or more
* askrene-returned routes (multi-part for MPP). All parts
* share payment_hash + payment_secret + groupid + label;
* each part gets a unique partid. Each route's closing hop
* (fill_peer -> self_id) is appended before sendpay is
* called. Returns a JSON object with per-part status. */
Ev::Io<Json::Out>
do_execute(std::shared_ptr<Params> p,
std::shared_ptr<Jsmn::Object> askrene_response,
std::shared_ptr<Jsmn::Object> channels) {
auto routes = (*askrene_response)["routes"];
auto num_parts = routes.size();
auto multi = num_parts > 1;
auto kp = claimer.generate();
auto preimage = std::make_shared<Ln::Preimage>(
std::move(kp.first));
auto payment_secret =
std::make_shared<Ln::Preimage>(std::move(kp.second));
auto payment_hash =
std::make_shared<Sha256::Hash>(preimage->sha256());
auto label =
std::make_shared<std::string>(make_label());
/* For multi-part MPP we must pass an explicit groupid to
* sendpay (CLN requires it for parts >= 1) and CLN echoes
* it back verbatim. For single-part we let CLN
* auto-assign one -- the value depends on what's already
* in the payment store for this payment_hash, so the only
* truthful answer is whatever sendpay returns. We seed
* groupid_actual with our planned value for multi (which
* CLN will honor) and update it from the sendpay response
* for single-part. */
auto groupid_planned = rng();
auto groupid_actual = std::make_shared<std::uint64_t>(
multi ? groupid_planned : 0);
auto results = std::make_shared<std::vector<Jsmn::Object>>();
auto err_msgs =
std::make_shared<std::vector<std::string>>();
auto dest_set = std::make_shared<std::set<std::string>>();
for (auto const& s : p->dest_scids)
dest_set->insert(std::string(s));
XMoveFunds: feed sendpay outcomes back into clboss-xrebalance layer Three consecutive lab0 runs hitting the identical broken route proved the obvious failure mode of the manual primitive: with no write-back to the persistent layer, askrene picks the same path every time, and a single flaky intermediate node makes the whole command useless on repeat. This commit closes that loop, in both directions: Failure (per part, in waitsendpay's .catching<RpcError>) - Parse the 204 error data (erring_index, erring_channel, erring_direction, erring_node, failcode). Non-204 errors and malformed payloads are silently skipped -- they have no actionable channel/node to record. - failcode & 0x2000 (NODE-level): append a disable_node call against the persistent xrebalance layer, taking the whole forwarder out of consideration for subsequent calls. - failcode without 0x2000 (channel-level): append an inform_channel_constrained call against erring_channel / erring_direction with the amount the failing hop was asked to push -- recovered from the askrene path by indexing path[erring_index].amount_in_msat (erring_index is the 0-based sendpay-route index, and the askrene path occupies the leading positions of that route). Per askrene-inform-channel semantics this sets max_msat just below the failing amount, so the channel remains usable for strictly smaller payments but is excluded for routes carrying the failing amount or more. Same gradient signal xpay and FundsMover/Attempter write -- it lets askrene's probability estimate distinguish "channel can't push 800m right now" from "channel is dead". Fallback to amount=1 (full exclusion) if the path lookup fails; a conservative signal beats no signal. - If erring_channel matches one of our local-channel scids (source_scids or dest_scids), the write is suppressed. Askrene's auto.localchans is authoritative for local- channel state; constraining it in our layer would only poison routing via the min-across-layers rule. - This MVP intentionally omits the FundsMover/Attempter channel_update-refresh branch (parse_chan_update + update_channel with refreshed policy from the embedded BOLT 07 raw_message). The manual xmovefunds primitive does not yet retry within a single invocation, so the inform-constrained path alone is sufficient to make the NEXT manual invocation pick a different route. The richer policy-refresh path is the obvious next step for the Layer 2+ algorithm. Success (per part, in waitsendpay's .then) - For each network middle hop on the part's askrene path, append an inform_channel_unconstrained call with the amount the hop actually carried (amount_out_msat). This is the standard "channel proved it can push at least X msat recently" lower-bound observation that FundsMover/ Attempter and xpay both write -- it keeps askrene's probability estimate from a monotonic darkening of failure-only writes. - "Network middle hops" = every askrene hop whose scid is NOT one of our local scids. For a circular self-pay this drops the head hop (us -> drain_peer); the appended closing hop (fill_peer -> us) is never an askrene-path hop and is therefore implicitly excluded. Mechanism - per_part_middle (shared) is populated during the sendpay build loop with each part's network middle hops. Indexed by part i so the success branch knows what to reinforce. - feedback_actions (shared) is the per-part accumulator; the .then/.catching lambdas push Ev::Io<void> entries into it. - After the waitsendpay loop a single deferred-eval phase (Ev::lift().then(...) reading feedback_actions at execute time) chains all the accumulated actions and runs them in sequence before assembling the reply. Running feedback after waitsendpay (rather than inline per-part) keeps the payment-critical path latency unaffected and avoids interleaving RPC traffic with sendpay/waitsendpay. - A debug log line reports the number of feedback entries written. Failure modes - inform_channel_* and disable_node already swallow RpcError (degraded-learning posture), so the feedback phase is structurally idempotent -- any partial failure leaves the layer in a coherent state. No new error-handling code needed at this caller level. - All feedback actions write to the same persistent layer (clboss-xrebalance). No aging is added in this commit -- FundsMover already ages the parallel clboss layer on a TimerRandomHourly with a 24h cutoff; extending that timer to also age clboss-xrebalance is the obvious next commit.
2026-05-31 19:06:28 -07:00
/* Set of our local-channel scids (sources + dests), used
* to suppress feedback writes against our own channels.
* Askrene's auto.localchans layer is authoritative for
* local-channel state, so constraining/disabling our own
* channels in the persistent xrebalance layer would only
* poison future routing (askrene min-across-layers
* semantic). Network hops are not in this set. */
auto our_scids = std::make_shared<std::set<std::string>>();
for (auto const& s : p->source_scids)
our_scids->insert(std::string(s));
for (auto const& s : p->dest_scids)
our_scids->insert(std::string(s));
/* Per-part askrene-path network middle hops, captured at
* sendpay-build time so the success branch knows which
* channels to positively reinforce (inform-unconstrained
* with their carried amount). Stored as
* (scid, direction, amount). Hops whose scid is in
* our_scids are filtered out -- that drops the local
* us->drain_peer hop at the head of every askrene path. */
auto per_part_middle = std::make_shared<
std::vector<std::vector<
std::tuple<Ln::Scid,
std::uint32_t,
Ln::Amount>>>>(num_parts);
/* Accumulator of Ev::Io<void> feedback actions
* (inform_channel_*, disable_node). Populated by the
* waitsendpay .then/.catching lambdas during execution
* and drained after the waitsendpay loop completes.
* Doing the writes after waitsendpay (rather than
* inline) keeps the per-part wait latency unaffected
* by the feedback RPCs and avoids interleaving
* RPC traffic with payment-critical sendpay/waitsendpay
* calls. */
auto feedback_actions = std::make_shared<
std::vector<Ev::Io<void>>>();
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
auto chain = Boss::log( bus, Info
, "XMoveFunds: executing %zu "
"part(s); payment_hash=%s "
"label=%s groupid_planned=%" PRIu64
, num_parts
, std::string(*payment_hash)
.c_str()
, label->c_str()
, groupid_planned
);
for (auto i = std::size_t(0); i < num_parts; ++i) {
auto route_obj = routes[i];
auto partid = multi ? (i + 1) : 0;
/* Build the sendpay route off the askrene
* route. The patched askrene leaves a fake
* mirror hop at path[N-1] -- its node_id_in is
* the real fill peer (= the last real
* forwarder), its node_id_out is the synthetic
* circular_fake_us_in_id. We look up the fill
* peer via node_id_in. */
auto path = route_obj["path"];
if (path.size() == 0) {
err_msgs->push_back(
"askrene returned an empty path");
continue;
}
auto last = path[path.size() - 1];
auto fill_peer = Ln::NodeId(
std::string(last["node_id_in"]));
std::string fill_scid;
try {
fill_scid = find_fill_scid(
*channels, *dest_set, fill_peer);
} catch (std::exception const& ex) {
err_msgs->push_back(ex.what());
continue;
}
/* build_sendpay_route reads the closing hop's
* amount_msat / delay from the fake mirror at
* path[N-1] (left in place by the patched
* askrene), so no extra params here. See the
* function's doc for why this is correct. */
auto sendpay_route = build_sendpay_route(
route_obj, fill_scid, fill_peer);
XMoveFunds: feed sendpay outcomes back into clboss-xrebalance layer Three consecutive lab0 runs hitting the identical broken route proved the obvious failure mode of the manual primitive: with no write-back to the persistent layer, askrene picks the same path every time, and a single flaky intermediate node makes the whole command useless on repeat. This commit closes that loop, in both directions: Failure (per part, in waitsendpay's .catching<RpcError>) - Parse the 204 error data (erring_index, erring_channel, erring_direction, erring_node, failcode). Non-204 errors and malformed payloads are silently skipped -- they have no actionable channel/node to record. - failcode & 0x2000 (NODE-level): append a disable_node call against the persistent xrebalance layer, taking the whole forwarder out of consideration for subsequent calls. - failcode without 0x2000 (channel-level): append an inform_channel_constrained call against erring_channel / erring_direction with the amount the failing hop was asked to push -- recovered from the askrene path by indexing path[erring_index].amount_in_msat (erring_index is the 0-based sendpay-route index, and the askrene path occupies the leading positions of that route). Per askrene-inform-channel semantics this sets max_msat just below the failing amount, so the channel remains usable for strictly smaller payments but is excluded for routes carrying the failing amount or more. Same gradient signal xpay and FundsMover/Attempter write -- it lets askrene's probability estimate distinguish "channel can't push 800m right now" from "channel is dead". Fallback to amount=1 (full exclusion) if the path lookup fails; a conservative signal beats no signal. - If erring_channel matches one of our local-channel scids (source_scids or dest_scids), the write is suppressed. Askrene's auto.localchans is authoritative for local- channel state; constraining it in our layer would only poison routing via the min-across-layers rule. - This MVP intentionally omits the FundsMover/Attempter channel_update-refresh branch (parse_chan_update + update_channel with refreshed policy from the embedded BOLT 07 raw_message). The manual xmovefunds primitive does not yet retry within a single invocation, so the inform-constrained path alone is sufficient to make the NEXT manual invocation pick a different route. The richer policy-refresh path is the obvious next step for the Layer 2+ algorithm. Success (per part, in waitsendpay's .then) - For each network middle hop on the part's askrene path, append an inform_channel_unconstrained call with the amount the hop actually carried (amount_out_msat). This is the standard "channel proved it can push at least X msat recently" lower-bound observation that FundsMover/ Attempter and xpay both write -- it keeps askrene's probability estimate from a monotonic darkening of failure-only writes. - "Network middle hops" = every askrene hop whose scid is NOT one of our local scids. For a circular self-pay this drops the head hop (us -> drain_peer); the appended closing hop (fill_peer -> us) is never an askrene-path hop and is therefore implicitly excluded. Mechanism - per_part_middle (shared) is populated during the sendpay build loop with each part's network middle hops. Indexed by part i so the success branch knows what to reinforce. - feedback_actions (shared) is the per-part accumulator; the .then/.catching lambdas push Ev::Io<void> entries into it. - After the waitsendpay loop a single deferred-eval phase (Ev::lift().then(...) reading feedback_actions at execute time) chains all the accumulated actions and runs them in sequence before assembling the reply. Running feedback after waitsendpay (rather than inline per-part) keeps the payment-critical path latency unaffected and avoids interleaving RPC traffic with sendpay/waitsendpay. - A debug log line reports the number of feedback entries written. Failure modes - inform_channel_* and disable_node already swallow RpcError (degraded-learning posture), so the feedback phase is structurally idempotent -- any partial failure leaves the layer in a coherent state. No new error-handling code needed at this caller level. - All feedback actions write to the same persistent layer (clboss-xrebalance). No aging is added in this commit -- FundsMover already ages the parallel clboss layer on a TimerRandomHourly with a 24h cutoff; extending that timer to also age clboss-xrebalance is the obvious next commit.
2026-05-31 19:06:28 -07:00
/* Extract this part's network middle hops for
* later positive reinforcement. Iterate the
* full askrene path and keep every hop whose
* scid is NOT one of our local channels. For
* the typical circular self-pay this drops the
* head hop (us->drain_peer) and keeps the rest
* of the path through to the last forwarder
* arriving at fill_peer. amount_out_msat is
* what each hop forwarded -- that is the
* lower-bound capacity claim. */
for (auto j = std::size_t(0); j < path.size(); ++j) {
auto hop_j = path[j];
auto scidd =
std::string(hop_j["short_channel_id_dir"]);
auto slash = scidd.find('/');
auto scid_str = scidd.substr(0, slash);
if (our_scids->count(scid_str))
continue;
auto dir = std::uint32_t(
std::stoul(scidd.substr(slash + 1)));
auto amt = Ln::Amount::object(
hop_j["amount_out_msat"]);
(*per_part_middle)[i].emplace_back(
Ln::Scid(scid_str), dir, amt);
}
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
chain = std::move(chain)
+ Boss::log( bus, Debug
, "XMoveFunds: part %zu sendpay "
"route fees:\n%s"
, i
, format_route_fees(path).c_str())
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
+ sendpay_part(
*payment_hash,
*payment_secret,
std::move(sendpay_route),
*label, groupid_planned, partid,
p->amount)
.then([groupid_actual, multi]
(Jsmn::Object resp) {
/* Single-part: capture CLN's
* auto-assigned groupid so the
* outer reply and waitsendpay
* use the truthful value. */
if (!multi
&& resp.has("groupid")
&& resp["groupid"].is_number())
*groupid_actual =
std::uint64_t(double(
resp["groupid"]));
return Ev::lift();
}).catching<RpcError>(
[err_msgs](RpcError const& e) {
err_msgs->push_back(
"sendpay: "
+ Util::stringify(
e.error));
return Ev::lift();
});
}
/* Wait for every part to terminate. We do this even
* if some sendpays failed up front -- the others may
* still be in flight and waiting cleans them up. The
* waitsendpay_part call is wrapped in Ev::lift().then(...)
* so *groupid_actual is read at execute time, after the
* preceding sendpay_part has had a chance to update it
* from CLN's response (single-part path). Multi-part
XMoveFunds: feed sendpay outcomes back into clboss-xrebalance layer Three consecutive lab0 runs hitting the identical broken route proved the obvious failure mode of the manual primitive: with no write-back to the persistent layer, askrene picks the same path every time, and a single flaky intermediate node makes the whole command useless on repeat. This commit closes that loop, in both directions: Failure (per part, in waitsendpay's .catching<RpcError>) - Parse the 204 error data (erring_index, erring_channel, erring_direction, erring_node, failcode). Non-204 errors and malformed payloads are silently skipped -- they have no actionable channel/node to record. - failcode & 0x2000 (NODE-level): append a disable_node call against the persistent xrebalance layer, taking the whole forwarder out of consideration for subsequent calls. - failcode without 0x2000 (channel-level): append an inform_channel_constrained call against erring_channel / erring_direction with the amount the failing hop was asked to push -- recovered from the askrene path by indexing path[erring_index].amount_in_msat (erring_index is the 0-based sendpay-route index, and the askrene path occupies the leading positions of that route). Per askrene-inform-channel semantics this sets max_msat just below the failing amount, so the channel remains usable for strictly smaller payments but is excluded for routes carrying the failing amount or more. Same gradient signal xpay and FundsMover/Attempter write -- it lets askrene's probability estimate distinguish "channel can't push 800m right now" from "channel is dead". Fallback to amount=1 (full exclusion) if the path lookup fails; a conservative signal beats no signal. - If erring_channel matches one of our local-channel scids (source_scids or dest_scids), the write is suppressed. Askrene's auto.localchans is authoritative for local- channel state; constraining it in our layer would only poison routing via the min-across-layers rule. - This MVP intentionally omits the FundsMover/Attempter channel_update-refresh branch (parse_chan_update + update_channel with refreshed policy from the embedded BOLT 07 raw_message). The manual xmovefunds primitive does not yet retry within a single invocation, so the inform-constrained path alone is sufficient to make the NEXT manual invocation pick a different route. The richer policy-refresh path is the obvious next step for the Layer 2+ algorithm. Success (per part, in waitsendpay's .then) - For each network middle hop on the part's askrene path, append an inform_channel_unconstrained call with the amount the hop actually carried (amount_out_msat). This is the standard "channel proved it can push at least X msat recently" lower-bound observation that FundsMover/ Attempter and xpay both write -- it keeps askrene's probability estimate from a monotonic darkening of failure-only writes. - "Network middle hops" = every askrene hop whose scid is NOT one of our local scids. For a circular self-pay this drops the head hop (us -> drain_peer); the appended closing hop (fill_peer -> us) is never an askrene-path hop and is therefore implicitly excluded. Mechanism - per_part_middle (shared) is populated during the sendpay build loop with each part's network middle hops. Indexed by part i so the success branch knows what to reinforce. - feedback_actions (shared) is the per-part accumulator; the .then/.catching lambdas push Ev::Io<void> entries into it. - After the waitsendpay loop a single deferred-eval phase (Ev::lift().then(...) reading feedback_actions at execute time) chains all the accumulated actions and runs them in sequence before assembling the reply. Running feedback after waitsendpay (rather than inline per-part) keeps the payment-critical path latency unaffected and avoids interleaving RPC traffic with sendpay/waitsendpay. - A debug log line reports the number of feedback entries written. Failure modes - inform_channel_* and disable_node already swallow RpcError (degraded-learning posture), so the feedback phase is structurally idempotent -- any partial failure leaves the layer in a coherent state. No new error-handling code needed at this caller level. - All feedback actions write to the same persistent layer (clboss-xrebalance). No aging is added in this commit -- FundsMover already ages the parallel clboss layer on a TimerRandomHourly with a 24h cutoff; extending that timer to also age clboss-xrebalance is the obvious next commit.
2026-05-31 19:06:28 -07:00
* just reads back the planned value we seeded.
*
* The .then (success) and .catching (failure) handlers
* accumulate inform_channel_* / disable_node actions
* into feedback_actions for the persistent xrebalance
* layer.
* They are run after all parts have terminated, so the
* payment-critical path is not slowed by feedback RPCs
* and to preserve a single coherent observation set
* across MPP parts. */
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
for (auto i = std::size_t(0); i < num_parts; ++i) {
auto partid = multi ? (i + 1) : 0;
chain = std::move(chain)
+ Ev::lift().then(
XMoveFunds: feed sendpay outcomes back into clboss-xrebalance layer Three consecutive lab0 runs hitting the identical broken route proved the obvious failure mode of the manual primitive: with no write-back to the persistent layer, askrene picks the same path every time, and a single flaky intermediate node makes the whole command useless on repeat. This commit closes that loop, in both directions: Failure (per part, in waitsendpay's .catching<RpcError>) - Parse the 204 error data (erring_index, erring_channel, erring_direction, erring_node, failcode). Non-204 errors and malformed payloads are silently skipped -- they have no actionable channel/node to record. - failcode & 0x2000 (NODE-level): append a disable_node call against the persistent xrebalance layer, taking the whole forwarder out of consideration for subsequent calls. - failcode without 0x2000 (channel-level): append an inform_channel_constrained call against erring_channel / erring_direction with the amount the failing hop was asked to push -- recovered from the askrene path by indexing path[erring_index].amount_in_msat (erring_index is the 0-based sendpay-route index, and the askrene path occupies the leading positions of that route). Per askrene-inform-channel semantics this sets max_msat just below the failing amount, so the channel remains usable for strictly smaller payments but is excluded for routes carrying the failing amount or more. Same gradient signal xpay and FundsMover/Attempter write -- it lets askrene's probability estimate distinguish "channel can't push 800m right now" from "channel is dead". Fallback to amount=1 (full exclusion) if the path lookup fails; a conservative signal beats no signal. - If erring_channel matches one of our local-channel scids (source_scids or dest_scids), the write is suppressed. Askrene's auto.localchans is authoritative for local- channel state; constraining it in our layer would only poison routing via the min-across-layers rule. - This MVP intentionally omits the FundsMover/Attempter channel_update-refresh branch (parse_chan_update + update_channel with refreshed policy from the embedded BOLT 07 raw_message). The manual xmovefunds primitive does not yet retry within a single invocation, so the inform-constrained path alone is sufficient to make the NEXT manual invocation pick a different route. The richer policy-refresh path is the obvious next step for the Layer 2+ algorithm. Success (per part, in waitsendpay's .then) - For each network middle hop on the part's askrene path, append an inform_channel_unconstrained call with the amount the hop actually carried (amount_out_msat). This is the standard "channel proved it can push at least X msat recently" lower-bound observation that FundsMover/ Attempter and xpay both write -- it keeps askrene's probability estimate from a monotonic darkening of failure-only writes. - "Network middle hops" = every askrene hop whose scid is NOT one of our local scids. For a circular self-pay this drops the head hop (us -> drain_peer); the appended closing hop (fill_peer -> us) is never an askrene-path hop and is therefore implicitly excluded. Mechanism - per_part_middle (shared) is populated during the sendpay build loop with each part's network middle hops. Indexed by part i so the success branch knows what to reinforce. - feedback_actions (shared) is the per-part accumulator; the .then/.catching lambdas push Ev::Io<void> entries into it. - After the waitsendpay loop a single deferred-eval phase (Ev::lift().then(...) reading feedback_actions at execute time) chains all the accumulated actions and runs them in sequence before assembling the reply. Running feedback after waitsendpay (rather than inline per-part) keeps the payment-critical path latency unaffected and avoids interleaving RPC traffic with sendpay/waitsendpay. - A debug log line reports the number of feedback entries written. Failure modes - inform_channel_* and disable_node already swallow RpcError (degraded-learning posture), so the feedback phase is structurally idempotent -- any partial failure leaves the layer in a coherent state. No new error-handling code needed at this caller level. - All feedback actions write to the same persistent layer (clboss-xrebalance). No aging is added in this commit -- FundsMover already ages the parallel clboss layer on a TimerRandomHourly with a 24h cutoff; extending that timer to also age clboss-xrebalance is the obvious next commit.
2026-05-31 19:06:28 -07:00
[this, payment_hash, partid, i,
groupid_actual, results, err_msgs,
per_part_middle, our_scids,
askrene_response,
feedback_actions]
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
() {
return waitsendpay_part(
*payment_hash, partid,
*groupid_actual)
XMoveFunds: feed sendpay outcomes back into clboss-xrebalance layer Three consecutive lab0 runs hitting the identical broken route proved the obvious failure mode of the manual primitive: with no write-back to the persistent layer, askrene picks the same path every time, and a single flaky intermediate node makes the whole command useless on repeat. This commit closes that loop, in both directions: Failure (per part, in waitsendpay's .catching<RpcError>) - Parse the 204 error data (erring_index, erring_channel, erring_direction, erring_node, failcode). Non-204 errors and malformed payloads are silently skipped -- they have no actionable channel/node to record. - failcode & 0x2000 (NODE-level): append a disable_node call against the persistent xrebalance layer, taking the whole forwarder out of consideration for subsequent calls. - failcode without 0x2000 (channel-level): append an inform_channel_constrained call against erring_channel / erring_direction with the amount the failing hop was asked to push -- recovered from the askrene path by indexing path[erring_index].amount_in_msat (erring_index is the 0-based sendpay-route index, and the askrene path occupies the leading positions of that route). Per askrene-inform-channel semantics this sets max_msat just below the failing amount, so the channel remains usable for strictly smaller payments but is excluded for routes carrying the failing amount or more. Same gradient signal xpay and FundsMover/Attempter write -- it lets askrene's probability estimate distinguish "channel can't push 800m right now" from "channel is dead". Fallback to amount=1 (full exclusion) if the path lookup fails; a conservative signal beats no signal. - If erring_channel matches one of our local-channel scids (source_scids or dest_scids), the write is suppressed. Askrene's auto.localchans is authoritative for local- channel state; constraining it in our layer would only poison routing via the min-across-layers rule. - This MVP intentionally omits the FundsMover/Attempter channel_update-refresh branch (parse_chan_update + update_channel with refreshed policy from the embedded BOLT 07 raw_message). The manual xmovefunds primitive does not yet retry within a single invocation, so the inform-constrained path alone is sufficient to make the NEXT manual invocation pick a different route. The richer policy-refresh path is the obvious next step for the Layer 2+ algorithm. Success (per part, in waitsendpay's .then) - For each network middle hop on the part's askrene path, append an inform_channel_unconstrained call with the amount the hop actually carried (amount_out_msat). This is the standard "channel proved it can push at least X msat recently" lower-bound observation that FundsMover/ Attempter and xpay both write -- it keeps askrene's probability estimate from a monotonic darkening of failure-only writes. - "Network middle hops" = every askrene hop whose scid is NOT one of our local scids. For a circular self-pay this drops the head hop (us -> drain_peer); the appended closing hop (fill_peer -> us) is never an askrene-path hop and is therefore implicitly excluded. Mechanism - per_part_middle (shared) is populated during the sendpay build loop with each part's network middle hops. Indexed by part i so the success branch knows what to reinforce. - feedback_actions (shared) is the per-part accumulator; the .then/.catching lambdas push Ev::Io<void> entries into it. - After the waitsendpay loop a single deferred-eval phase (Ev::lift().then(...) reading feedback_actions at execute time) chains all the accumulated actions and runs them in sequence before assembling the reply. Running feedback after waitsendpay (rather than inline per-part) keeps the payment-critical path latency unaffected and avoids interleaving RPC traffic with sendpay/waitsendpay. - A debug log line reports the number of feedback entries written. Failure modes - inform_channel_* and disable_node already swallow RpcError (degraded-learning posture), so the feedback phase is structurally idempotent -- any partial failure leaves the layer in a coherent state. No new error-handling code needed at this caller level. - All feedback actions write to the same persistent layer (clboss-xrebalance). No aging is added in this commit -- FundsMover already ages the parallel clboss layer on a TimerRandomHourly with a 24h cutoff; extending that timer to also age clboss-xrebalance is the obvious next commit.
2026-05-31 19:06:28 -07:00
.then([results, i,
per_part_middle,
EarningsTracker: attribute clboss-xrebalance per-part fees + amounts XMoveFunds successful rebalances were moving real sats but reporting nothing to clboss's earnings stats -- they did not show up in clboss-status, in the fee model, or anywhere else operators could see them. This wires per-part attribution end-to-end via a new bus message. New message: Boss::Msg::XRebalanceAttribution { source, destination, amount_moved, fee_spent } Emission (XMoveFunds): Inside the existing waitsendpay-success branch, after pushing the result and accumulating positive-reinforcement feedback for the middle hops, parse the askrene path for this part: - source peer = path[0]["node_id_out"] -- the peer we forwarded to on the first hop, i.e. the far end of the source channel for this part. - dest peer = path[last]["node_id_in"] -- the peer that forwarded back to us on the closing hop (a.k.a. fill_peer). - amount_moved = waitsendpay.amount_msat (delivered to us). - fee_spent = amount_sent_msat - amount_msat. Defensive on malformed JSON: missing fields, non-array path, or amount_sent < amount_moved all skip the raise rather than crash the success handler. An MPP-split clboss-xmovefunds invocation that lands different parts on different (source, dest) pairs (the common case once source_scid / dest_scid are sets) gets one attribution per part, with the actual pair each part used -- finer-grained than FundsMover's per-Runner attribution. Subscription (EarningsTracker): Subscribe alongside the existing Msg::ResponseMoveFunds handler and run the same symmetric DB update -- source peer gets in_expenditures += fee and in_rebalanced += amount, destination peer gets out_expenditures += fee and out_rebalanced += amount. No pendings lookup: XMoveFunds already identified both peers before raising, so the new path skips the requester -> Pending map entirely. Symmetry sanity check while wiring: The DEVSTATE/REBALANCER-REDESIGN-2026-05-28.org "Earnings attribution symmetry" note flagged that the FundsMover path might debit only one side. Verified against the current code: response_move_funds already updates both source's in_* and destination's out_* (EarningsTracker.cpp around lines 430 and 447) -- no fix needed there. The doc concern was correct at the time but has been resolved since.
2026-06-01 10:50:34 -07:00
feedback_actions,
askrene_response, this]
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
(Jsmn::Object r) {
results->push_back(r);
XMoveFunds: feed sendpay outcomes back into clboss-xrebalance layer Three consecutive lab0 runs hitting the identical broken route proved the obvious failure mode of the manual primitive: with no write-back to the persistent layer, askrene picks the same path every time, and a single flaky intermediate node makes the whole command useless on repeat. This commit closes that loop, in both directions: Failure (per part, in waitsendpay's .catching<RpcError>) - Parse the 204 error data (erring_index, erring_channel, erring_direction, erring_node, failcode). Non-204 errors and malformed payloads are silently skipped -- they have no actionable channel/node to record. - failcode & 0x2000 (NODE-level): append a disable_node call against the persistent xrebalance layer, taking the whole forwarder out of consideration for subsequent calls. - failcode without 0x2000 (channel-level): append an inform_channel_constrained call against erring_channel / erring_direction with the amount the failing hop was asked to push -- recovered from the askrene path by indexing path[erring_index].amount_in_msat (erring_index is the 0-based sendpay-route index, and the askrene path occupies the leading positions of that route). Per askrene-inform-channel semantics this sets max_msat just below the failing amount, so the channel remains usable for strictly smaller payments but is excluded for routes carrying the failing amount or more. Same gradient signal xpay and FundsMover/Attempter write -- it lets askrene's probability estimate distinguish "channel can't push 800m right now" from "channel is dead". Fallback to amount=1 (full exclusion) if the path lookup fails; a conservative signal beats no signal. - If erring_channel matches one of our local-channel scids (source_scids or dest_scids), the write is suppressed. Askrene's auto.localchans is authoritative for local- channel state; constraining it in our layer would only poison routing via the min-across-layers rule. - This MVP intentionally omits the FundsMover/Attempter channel_update-refresh branch (parse_chan_update + update_channel with refreshed policy from the embedded BOLT 07 raw_message). The manual xmovefunds primitive does not yet retry within a single invocation, so the inform-constrained path alone is sufficient to make the NEXT manual invocation pick a different route. The richer policy-refresh path is the obvious next step for the Layer 2+ algorithm. Success (per part, in waitsendpay's .then) - For each network middle hop on the part's askrene path, append an inform_channel_unconstrained call with the amount the hop actually carried (amount_out_msat). This is the standard "channel proved it can push at least X msat recently" lower-bound observation that FundsMover/ Attempter and xpay both write -- it keeps askrene's probability estimate from a monotonic darkening of failure-only writes. - "Network middle hops" = every askrene hop whose scid is NOT one of our local scids. For a circular self-pay this drops the head hop (us -> drain_peer); the appended closing hop (fill_peer -> us) is never an askrene-path hop and is therefore implicitly excluded. Mechanism - per_part_middle (shared) is populated during the sendpay build loop with each part's network middle hops. Indexed by part i so the success branch knows what to reinforce. - feedback_actions (shared) is the per-part accumulator; the .then/.catching lambdas push Ev::Io<void> entries into it. - After the waitsendpay loop a single deferred-eval phase (Ev::lift().then(...) reading feedback_actions at execute time) chains all the accumulated actions and runs them in sequence before assembling the reply. Running feedback after waitsendpay (rather than inline per-part) keeps the payment-critical path latency unaffected and avoids interleaving RPC traffic with sendpay/waitsendpay. - A debug log line reports the number of feedback entries written. Failure modes - inform_channel_* and disable_node already swallow RpcError (degraded-learning posture), so the feedback phase is structurally idempotent -- any partial failure leaves the layer in a coherent state. No new error-handling code needed at this caller level. - All feedback actions write to the same persistent layer (clboss-xrebalance). No aging is added in this commit -- FundsMover already ages the parallel clboss layer on a TimerRandomHourly with a 24h cutoff; extending that timer to also age clboss-xrebalance is the obvious next commit.
2026-05-31 19:06:28 -07:00
/* Positive reinforcement:
* every middle hop carried
* its amount on this part. */
for (auto const& hop
: (*per_part_middle)[i]) {
feedback_actions
->push_back(
Boss::Mod::
AskreneLayer::
inform_channel_unconstrained(
*rpc,
Boss::Mod::
AskreneLayer::
xrebalance_layer_name,
std::get<0>(hop),
std::get<1>(hop),
std::get<2>(hop)));
}
EarningsTracker: attribute clboss-xrebalance per-part fees + amounts XMoveFunds successful rebalances were moving real sats but reporting nothing to clboss's earnings stats -- they did not show up in clboss-status, in the fee model, or anywhere else operators could see them. This wires per-part attribution end-to-end via a new bus message. New message: Boss::Msg::XRebalanceAttribution { source, destination, amount_moved, fee_spent } Emission (XMoveFunds): Inside the existing waitsendpay-success branch, after pushing the result and accumulating positive-reinforcement feedback for the middle hops, parse the askrene path for this part: - source peer = path[0]["node_id_out"] -- the peer we forwarded to on the first hop, i.e. the far end of the source channel for this part. - dest peer = path[last]["node_id_in"] -- the peer that forwarded back to us on the closing hop (a.k.a. fill_peer). - amount_moved = waitsendpay.amount_msat (delivered to us). - fee_spent = amount_sent_msat - amount_msat. Defensive on malformed JSON: missing fields, non-array path, or amount_sent < amount_moved all skip the raise rather than crash the success handler. An MPP-split clboss-xmovefunds invocation that lands different parts on different (source, dest) pairs (the common case once source_scid / dest_scid are sets) gets one attribution per part, with the actual pair each part used -- finer-grained than FundsMover's per-Runner attribution. Subscription (EarningsTracker): Subscribe alongside the existing Msg::ResponseMoveFunds handler and run the same symmetric DB update -- source peer gets in_expenditures += fee and in_rebalanced += amount, destination peer gets out_expenditures += fee and out_rebalanced += amount. No pendings lookup: XMoveFunds already identified both peers before raising, so the new path skips the requester -> Pending map entirely. Symmetry sanity check while wiring: The DEVSTATE/REBALANCER-REDESIGN-2026-05-28.org "Earnings attribution symmetry" note flagged that the FundsMover path might debit only one side. Verified against the current code: response_move_funds already updates both source's in_* and destination's out_* (EarningsTracker.cpp around lines 430 and 447) -- no fix needed there. The doc concern was correct at the time but has been resolved since.
2026-06-01 10:50:34 -07:00
/* Per-part earnings
* attribution. The askrene
* path identifies which
* source/dest peer this part
* actually used (MCF flow
* split means different parts
* of one xmovefunds invocation
* can land on different
* source/dest pairs).
* EarningsTracker subscribes
* to the resulting message and
* applies the same symmetric
* DB update it already runs on
* Msg::ResponseMoveFunds.
* Defensive on malformed JSON:
* skip silently if the path
* or waitsendpay result lacks
* the expected fields. */
auto askrene_path =
(*askrene_response)
["routes"][i]["path"];
return raise_attribution(
askrene_path, r);
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
}).catching<RpcError>(
XMoveFunds: feed sendpay outcomes back into clboss-xrebalance layer Three consecutive lab0 runs hitting the identical broken route proved the obvious failure mode of the manual primitive: with no write-back to the persistent layer, askrene picks the same path every time, and a single flaky intermediate node makes the whole command useless on repeat. This commit closes that loop, in both directions: Failure (per part, in waitsendpay's .catching<RpcError>) - Parse the 204 error data (erring_index, erring_channel, erring_direction, erring_node, failcode). Non-204 errors and malformed payloads are silently skipped -- they have no actionable channel/node to record. - failcode & 0x2000 (NODE-level): append a disable_node call against the persistent xrebalance layer, taking the whole forwarder out of consideration for subsequent calls. - failcode without 0x2000 (channel-level): append an inform_channel_constrained call against erring_channel / erring_direction with the amount the failing hop was asked to push -- recovered from the askrene path by indexing path[erring_index].amount_in_msat (erring_index is the 0-based sendpay-route index, and the askrene path occupies the leading positions of that route). Per askrene-inform-channel semantics this sets max_msat just below the failing amount, so the channel remains usable for strictly smaller payments but is excluded for routes carrying the failing amount or more. Same gradient signal xpay and FundsMover/Attempter write -- it lets askrene's probability estimate distinguish "channel can't push 800m right now" from "channel is dead". Fallback to amount=1 (full exclusion) if the path lookup fails; a conservative signal beats no signal. - If erring_channel matches one of our local-channel scids (source_scids or dest_scids), the write is suppressed. Askrene's auto.localchans is authoritative for local- channel state; constraining it in our layer would only poison routing via the min-across-layers rule. - This MVP intentionally omits the FundsMover/Attempter channel_update-refresh branch (parse_chan_update + update_channel with refreshed policy from the embedded BOLT 07 raw_message). The manual xmovefunds primitive does not yet retry within a single invocation, so the inform-constrained path alone is sufficient to make the NEXT manual invocation pick a different route. The richer policy-refresh path is the obvious next step for the Layer 2+ algorithm. Success (per part, in waitsendpay's .then) - For each network middle hop on the part's askrene path, append an inform_channel_unconstrained call with the amount the hop actually carried (amount_out_msat). This is the standard "channel proved it can push at least X msat recently" lower-bound observation that FundsMover/ Attempter and xpay both write -- it keeps askrene's probability estimate from a monotonic darkening of failure-only writes. - "Network middle hops" = every askrene hop whose scid is NOT one of our local scids. For a circular self-pay this drops the head hop (us -> drain_peer); the appended closing hop (fill_peer -> us) is never an askrene-path hop and is therefore implicitly excluded. Mechanism - per_part_middle (shared) is populated during the sendpay build loop with each part's network middle hops. Indexed by part i so the success branch knows what to reinforce. - feedback_actions (shared) is the per-part accumulator; the .then/.catching lambdas push Ev::Io<void> entries into it. - After the waitsendpay loop a single deferred-eval phase (Ev::lift().then(...) reading feedback_actions at execute time) chains all the accumulated actions and runs them in sequence before assembling the reply. Running feedback after waitsendpay (rather than inline per-part) keeps the payment-critical path latency unaffected and avoids interleaving RPC traffic with sendpay/waitsendpay. - A debug log line reports the number of feedback entries written. Failure modes - inform_channel_* and disable_node already swallow RpcError (degraded-learning posture), so the feedback phase is structurally idempotent -- any partial failure leaves the layer in a coherent state. No new error-handling code needed at this caller level. - All feedback actions write to the same persistent layer (clboss-xrebalance). No aging is added in this commit -- FundsMover already ages the parallel clboss layer on a TimerRandomHourly with a 24h cutoff; extending that timer to also age clboss-xrebalance is the obvious next commit.
2026-05-31 19:06:28 -07:00
[results, err_msgs,
our_scids,
askrene_response, i,
XMoveFunds: delpay failed parts + summary stats in reply Failed-part cleanup: Failed waitsendpay parts previously lingered forever in CLN's listpays, which made a heavy testing session leave dozens of dead payment_hash entries behind. This adds a best-effort delpay_part(payment_hash, partid, groupid, status="failed") invoked from the per-part waitsendpay catching branch, modeled on FundsMover/Attempter::delpay (line ~1570 there). CLN's delpay schema requires "both partid and groupid, or neither" (cln/lightningd/pay.c:2344), so the helper sets both for MPP and neither for the single-part path -- letting CLN delete the only entry by payment_hash in the latter case. RpcError is swallowed. PAY_NO_SUCH_PAYMENT is expected for parts that never reached CLN (sendpay rejected up front), and the rebalance failure itself is already surfaced via err_msgs. Summary stats in the RPC reply: Adds four fields to the top-level response: parts_complete -- count of parts with status == "complete" delivered_msat -- sum of r.amount_msat over the same set fee_total_msat -- sum of (amount_sent_msat - amount_msat) fee_ppm -- fee_total_msat * 1e6 / delivered_msat, omitted when delivered_msat == 0 These are all derivable from the per-part results[] array, but putting them inline lets operators (and jq/awk scripts) read the outcome of a rebalance without post-processing -- and the numbers match what EarningsTracker records via the per-part Msg::XRebalanceAttribution path. Defensive: parts that lack status, amount_msat, or amount_sent_msat are skipped, as are parts where sent < msat (which would only happen on corrupt CLN output). Failed parts have a Jsmn::Object() placeholder in results so they naturally fall out of the filter.
2026-06-01 11:34:57 -07:00
feedback_actions,
payment_hash, partid,
groupid_actual, this]
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
(RpcError const& e) {
results->push_back(
Jsmn::Object());
err_msgs->push_back(
"waitsendpay: "
+ Util::stringify(
e.error));
XMoveFunds: feed sendpay outcomes back into clboss-xrebalance layer Three consecutive lab0 runs hitting the identical broken route proved the obvious failure mode of the manual primitive: with no write-back to the persistent layer, askrene picks the same path every time, and a single flaky intermediate node makes the whole command useless on repeat. This commit closes that loop, in both directions: Failure (per part, in waitsendpay's .catching<RpcError>) - Parse the 204 error data (erring_index, erring_channel, erring_direction, erring_node, failcode). Non-204 errors and malformed payloads are silently skipped -- they have no actionable channel/node to record. - failcode & 0x2000 (NODE-level): append a disable_node call against the persistent xrebalance layer, taking the whole forwarder out of consideration for subsequent calls. - failcode without 0x2000 (channel-level): append an inform_channel_constrained call against erring_channel / erring_direction with the amount the failing hop was asked to push -- recovered from the askrene path by indexing path[erring_index].amount_in_msat (erring_index is the 0-based sendpay-route index, and the askrene path occupies the leading positions of that route). Per askrene-inform-channel semantics this sets max_msat just below the failing amount, so the channel remains usable for strictly smaller payments but is excluded for routes carrying the failing amount or more. Same gradient signal xpay and FundsMover/Attempter write -- it lets askrene's probability estimate distinguish "channel can't push 800m right now" from "channel is dead". Fallback to amount=1 (full exclusion) if the path lookup fails; a conservative signal beats no signal. - If erring_channel matches one of our local-channel scids (source_scids or dest_scids), the write is suppressed. Askrene's auto.localchans is authoritative for local- channel state; constraining it in our layer would only poison routing via the min-across-layers rule. - This MVP intentionally omits the FundsMover/Attempter channel_update-refresh branch (parse_chan_update + update_channel with refreshed policy from the embedded BOLT 07 raw_message). The manual xmovefunds primitive does not yet retry within a single invocation, so the inform-constrained path alone is sufficient to make the NEXT manual invocation pick a different route. The richer policy-refresh path is the obvious next step for the Layer 2+ algorithm. Success (per part, in waitsendpay's .then) - For each network middle hop on the part's askrene path, append an inform_channel_unconstrained call with the amount the hop actually carried (amount_out_msat). This is the standard "channel proved it can push at least X msat recently" lower-bound observation that FundsMover/ Attempter and xpay both write -- it keeps askrene's probability estimate from a monotonic darkening of failure-only writes. - "Network middle hops" = every askrene hop whose scid is NOT one of our local scids. For a circular self-pay this drops the head hop (us -> drain_peer); the appended closing hop (fill_peer -> us) is never an askrene-path hop and is therefore implicitly excluded. Mechanism - per_part_middle (shared) is populated during the sendpay build loop with each part's network middle hops. Indexed by part i so the success branch knows what to reinforce. - feedback_actions (shared) is the per-part accumulator; the .then/.catching lambdas push Ev::Io<void> entries into it. - After the waitsendpay loop a single deferred-eval phase (Ev::lift().then(...) reading feedback_actions at execute time) chains all the accumulated actions and runs them in sequence before assembling the reply. Running feedback after waitsendpay (rather than inline per-part) keeps the payment-critical path latency unaffected and avoids interleaving RPC traffic with sendpay/waitsendpay. - A debug log line reports the number of feedback entries written. Failure modes - inform_channel_* and disable_node already swallow RpcError (degraded-learning posture), so the feedback phase is structurally idempotent -- any partial failure leaves the layer in a coherent state. No new error-handling code needed at this caller level. - All feedback actions write to the same persistent layer (clboss-xrebalance). No aging is added in this commit -- FundsMover already ages the parallel clboss layer on a TimerRandomHourly with a 24h cutoff; extending that timer to also age clboss-xrebalance is the obvious next commit.
2026-05-31 19:06:28 -07:00
/* Negative reinforcement:
* parse erring_channel /
* erring_node and write a
* constraint to the
* persistent layer so the
* next getroutes call steers
* around it. The per-hop
* amount used as the
* constraint is recovered
* from this part's askrene
* path. */
auto askrene_path =
(*askrene_response)
["routes"][i]["path"];
accumulate_failure_feedback(
e, *our_scids,
askrene_path,
*feedback_actions);
XMoveFunds: delpay failed parts + summary stats in reply Failed-part cleanup: Failed waitsendpay parts previously lingered forever in CLN's listpays, which made a heavy testing session leave dozens of dead payment_hash entries behind. This adds a best-effort delpay_part(payment_hash, partid, groupid, status="failed") invoked from the per-part waitsendpay catching branch, modeled on FundsMover/Attempter::delpay (line ~1570 there). CLN's delpay schema requires "both partid and groupid, or neither" (cln/lightningd/pay.c:2344), so the helper sets both for MPP and neither for the single-part path -- letting CLN delete the only entry by payment_hash in the latter case. RpcError is swallowed. PAY_NO_SUCH_PAYMENT is expected for parts that never reached CLN (sendpay rejected up front), and the rebalance failure itself is already surfaced via err_msgs. Summary stats in the RPC reply: Adds four fields to the top-level response: parts_complete -- count of parts with status == "complete" delivered_msat -- sum of r.amount_msat over the same set fee_total_msat -- sum of (amount_sent_msat - amount_msat) fee_ppm -- fee_total_msat * 1e6 / delivered_msat, omitted when delivered_msat == 0 These are all derivable from the per-part results[] array, but putting them inline lets operators (and jq/awk scripts) read the outcome of a rebalance without post-processing -- and the numbers match what EarningsTracker records via the per-part Msg::XRebalanceAttribution path. Defensive: parts that lack status, amount_msat, or amount_sent_msat are skipped, as are parts where sent < msat (which would only happen on corrupt CLN output). Failed parts have a Jsmn::Object() placeholder in results so they naturally fall out of the filter.
2026-06-01 11:34:57 -07:00
/* Tidy CLN's payment store
* so dead payment_hash
* entries do not pile up
* across heavy testing
* sessions. Best-effort:
* the rebalance failure is
* already reported via
* err_msgs. */
return delpay_part(
*payment_hash, partid,
*groupid_actual);
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
});
});
}
XMoveFunds: feed sendpay outcomes back into clboss-xrebalance layer Three consecutive lab0 runs hitting the identical broken route proved the obvious failure mode of the manual primitive: with no write-back to the persistent layer, askrene picks the same path every time, and a single flaky intermediate node makes the whole command useless on repeat. This commit closes that loop, in both directions: Failure (per part, in waitsendpay's .catching<RpcError>) - Parse the 204 error data (erring_index, erring_channel, erring_direction, erring_node, failcode). Non-204 errors and malformed payloads are silently skipped -- they have no actionable channel/node to record. - failcode & 0x2000 (NODE-level): append a disable_node call against the persistent xrebalance layer, taking the whole forwarder out of consideration for subsequent calls. - failcode without 0x2000 (channel-level): append an inform_channel_constrained call against erring_channel / erring_direction with the amount the failing hop was asked to push -- recovered from the askrene path by indexing path[erring_index].amount_in_msat (erring_index is the 0-based sendpay-route index, and the askrene path occupies the leading positions of that route). Per askrene-inform-channel semantics this sets max_msat just below the failing amount, so the channel remains usable for strictly smaller payments but is excluded for routes carrying the failing amount or more. Same gradient signal xpay and FundsMover/Attempter write -- it lets askrene's probability estimate distinguish "channel can't push 800m right now" from "channel is dead". Fallback to amount=1 (full exclusion) if the path lookup fails; a conservative signal beats no signal. - If erring_channel matches one of our local-channel scids (source_scids or dest_scids), the write is suppressed. Askrene's auto.localchans is authoritative for local- channel state; constraining it in our layer would only poison routing via the min-across-layers rule. - This MVP intentionally omits the FundsMover/Attempter channel_update-refresh branch (parse_chan_update + update_channel with refreshed policy from the embedded BOLT 07 raw_message). The manual xmovefunds primitive does not yet retry within a single invocation, so the inform-constrained path alone is sufficient to make the NEXT manual invocation pick a different route. The richer policy-refresh path is the obvious next step for the Layer 2+ algorithm. Success (per part, in waitsendpay's .then) - For each network middle hop on the part's askrene path, append an inform_channel_unconstrained call with the amount the hop actually carried (amount_out_msat). This is the standard "channel proved it can push at least X msat recently" lower-bound observation that FundsMover/ Attempter and xpay both write -- it keeps askrene's probability estimate from a monotonic darkening of failure-only writes. - "Network middle hops" = every askrene hop whose scid is NOT one of our local scids. For a circular self-pay this drops the head hop (us -> drain_peer); the appended closing hop (fill_peer -> us) is never an askrene-path hop and is therefore implicitly excluded. Mechanism - per_part_middle (shared) is populated during the sendpay build loop with each part's network middle hops. Indexed by part i so the success branch knows what to reinforce. - feedback_actions (shared) is the per-part accumulator; the .then/.catching lambdas push Ev::Io<void> entries into it. - After the waitsendpay loop a single deferred-eval phase (Ev::lift().then(...) reading feedback_actions at execute time) chains all the accumulated actions and runs them in sequence before assembling the reply. Running feedback after waitsendpay (rather than inline per-part) keeps the payment-critical path latency unaffected and avoids interleaving RPC traffic with sendpay/waitsendpay. - A debug log line reports the number of feedback entries written. Failure modes - inform_channel_* and disable_node already swallow RpcError (degraded-learning posture), so the feedback phase is structurally idempotent -- any partial failure leaves the layer in a coherent state. No new error-handling code needed at this caller level. - All feedback actions write to the same persistent layer (clboss-xrebalance). No aging is added in this commit -- FundsMover already ages the parallel clboss layer on a TimerRandomHourly with a 24h cutoff; extending that timer to also age clboss-xrebalance is the obvious next commit.
2026-05-31 19:06:28 -07:00
/* Run the feedback actions accumulated above before
* assembling the reply. At chain-build time the vector
* is empty; the wrapping Ev::lift().then(...) defers
* iteration until execute time, after waitsendpay has
* populated it. */
chain = std::move(chain) + Ev::lift().then(
[feedback_actions, this]() -> Ev::Io<void> {
if (feedback_actions->empty())
return Ev::lift();
auto fb = Ev::lift();
for (auto& act : *feedback_actions) {
fb = std::move(fb) + std::move(act);
}
return std::move(fb)
+ Boss::log( bus, Debug
, "XMoveFunds: wrote %zu "
"feedback entries to "
"clboss-xrebalance layer"
, feedback_actions->size()
);
});
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
return std::move(chain).then(
[payment_hash, preimage, label,
groupid_actual, num_parts, results, err_msgs]() {
auto out = Json::Out();
auto obj = out.start_object();
obj.field("payment_hash",
std::string(*payment_hash));
obj.field("preimage",
std::string(*preimage));
obj.field("label", *label);
obj.field("groupid", *groupid_actual);
obj.field("parts", num_parts);
XMoveFunds: delpay failed parts + summary stats in reply Failed-part cleanup: Failed waitsendpay parts previously lingered forever in CLN's listpays, which made a heavy testing session leave dozens of dead payment_hash entries behind. This adds a best-effort delpay_part(payment_hash, partid, groupid, status="failed") invoked from the per-part waitsendpay catching branch, modeled on FundsMover/Attempter::delpay (line ~1570 there). CLN's delpay schema requires "both partid and groupid, or neither" (cln/lightningd/pay.c:2344), so the helper sets both for MPP and neither for the single-part path -- letting CLN delete the only entry by payment_hash in the latter case. RpcError is swallowed. PAY_NO_SUCH_PAYMENT is expected for parts that never reached CLN (sendpay rejected up front), and the rebalance failure itself is already surfaced via err_msgs. Summary stats in the RPC reply: Adds four fields to the top-level response: parts_complete -- count of parts with status == "complete" delivered_msat -- sum of r.amount_msat over the same set fee_total_msat -- sum of (amount_sent_msat - amount_msat) fee_ppm -- fee_total_msat * 1e6 / delivered_msat, omitted when delivered_msat == 0 These are all derivable from the per-part results[] array, but putting them inline lets operators (and jq/awk scripts) read the outcome of a rebalance without post-processing -- and the numbers match what EarningsTracker records via the per-part Msg::XRebalanceAttribution path. Defensive: parts that lack status, amount_msat, or amount_sent_msat are skipped, as are parts where sent < msat (which would only happen on corrupt CLN output). Failed parts have a Jsmn::Object() placeholder in results so they naturally fall out of the filter.
2026-06-01 11:34:57 -07:00
/* Per-call summary across successful parts:
* delivered_msat = sum r.amount_msat for parts
* with status == "complete"
* fee_total_msat = sum (amount_sent_msat -
* amount_msat) over the
* same set
* parts_complete = count of parts that settled
* fee_ppm = fee_total_msat * 1e6 /
* delivered_msat (omitted if
* delivered_msat == 0)
*
* Derivable from results[] but provided in-line
* so operators can read the result without
* post-processing -- and so a caller using
* jq/awk against the reply gets the same numbers
* EarningsTracker will record via the per-part
* Msg::XRebalanceAttribution path.
*
* Defensive: a failed part has a Jsmn::Object()
* placeholder in results (no status field); we
* filter on status == "complete" and require both
* amount fields before summing, so malformed or
* absent entries fall out cleanly. */
auto delivered_msat = std::uint64_t(0);
auto sent_msat = std::uint64_t(0);
auto parts_complete = std::uint32_t(0);
for (auto const& r : *results) {
if (!r.is_object()
|| !r.has("status")
|| !r["status"].is_string()
|| std::string(r["status"]) != "complete"
|| !r.has("amount_msat")
|| !r.has("amount_sent_msat"))
continue;
try {
auto am = Ln::Amount::object(
r["amount_msat"]);
auto as = Ln::Amount::object(
r["amount_sent_msat"]);
if (as < am)
continue;
delivered_msat += am.to_msat();
sent_msat += as.to_msat();
parts_complete += 1;
} catch (std::exception const&) {
continue;
}
}
auto fee_total_msat = sent_msat - delivered_msat;
obj.field("parts_complete", parts_complete);
obj.field("delivered_msat", delivered_msat);
obj.field("fee_total_msat", fee_total_msat);
if (delivered_msat > 0) {
auto fee_ppm = std::uint64_t(
fee_total_msat * std::uint64_t(1000000)
/ delivered_msat);
obj.field("fee_ppm", fee_ppm);
}
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
{
auto arr = obj.start_array("results");
for (auto const& r : *results)
arr.entry(r);
arr.end_array();
}
if (!err_msgs->empty()) {
auto arr = obj.start_array("errors");
for (auto const& m : *err_msgs)
arr.entry(m);
arr.end_array();
}
obj.end_object();
return Ev::lift(std::move(out));
});
}
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
/* Per-request flow. Builds + uses a uuid-suffixed transient
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
* layer, calls getroutes, optionally runs sendpay, and
* returns the response. The transient layer is removed
* before returning (success or failure). */
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
Ev::Io<void>
do_plan(std::shared_ptr<Params> p, Ln::CommandId id) {
auto transient =
Boss::Mod::AskreneLayer::xrebalance_layer_name
+ "-tmp-"
+ std::string(Uuid::random());
auto routes = std::make_shared<Jsmn::Object>();
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
auto channels = std::make_shared<Jsmn::Object>();
auto exec_result = std::make_shared<Json::Out>();
auto exec_done = std::make_shared<bool>(false);
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
auto err_msg = std::make_shared<std::string>();
auto err_code = std::make_shared<int>(0);
return create_transient_layer(transient
).then([this, p, transient]() {
return list_my_channels();
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
}).then([this, p, transient, channels]
(Jsmn::Object c) {
*channels = c;
return write_masks(transient, c, *p);
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
}).then([this, p, transient]() {
return call_getroutes(transient, *p);
}).then([routes](Jsmn::Object r) {
*routes = r;
return Ev::lift();
}).catching<RpcError>(
[err_msg, err_code](RpcError const& e) {
*err_code = -32603;
*err_msg = Util::stringify(e.error);
return Ev::lift();
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
}).then([this, p, routes, channels, exec_result,
exec_done, err_code]() {
if (*err_code != 0 || !p->execute)
return Ev::lift();
return do_execute(p, routes, channels)
.then([exec_result, exec_done]
(Json::Out r) {
*exec_result = std::move(r);
*exec_done = true;
return Ev::lift();
});
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
}).then([this, transient]() {
return remove_layer(transient);
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
}).then([this, p, id, routes, channels, exec_result,
exec_done, err_msg, err_code]() {
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
if (*err_code != 0) {
return bus.raise(Msg::CommandFail{
id, *err_code,
"getroutes failed: " + *err_msg,
Json::Out::empty_object()
});
}
auto plan = Json::Out();
auto obj = plan.start_object();
obj.field("status",
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
std::string(*exec_done
? "executed"
: p->execute
? "execute_skipped"
: "planned"));
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
{
auto arr =
obj.start_array("source_scids");
for (auto const& s : p->source_scids)
arr.entry(std::string(s));
arr.end_array();
}
{
auto arr =
obj.start_array("dest_scids");
for (auto const& s : p->dest_scids)
arr.entry(std::string(s));
arr.end_array();
}
obj.field("amount_msat",
std::uint64_t(
p->amount.to_msat()));
/* Echo whatever caps the caller actually passed,
* then the binding value that ended up in
* effect. When only one cap is specified the
* effective value equals it; when both are
* specified the smaller binds and the caller
* can see which one was the constraint. */
if (p->maxfee_msat_in) {
obj.field("maxfee_msat",
std::uint64_t(
p->maxfee_msat_in->to_msat()));
}
if (p->maxfee_ppm_in) {
obj.field("maxfee_ppm",
*p->maxfee_ppm_in);
}
obj.field("maxfee_effective_msat",
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
std::uint64_t(
p->maxfee.to_msat()));
obj.field("maxparts", p->maxparts);
obj.field("execute", p->execute);
/* Echo the askrene response in full so the
* caller (and the spike harness) can inspect
* the planned routes, per-hop amounts, and
* probabilities. */
obj.field("askrene", *routes);
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
if (*exec_done) {
obj.field("execution",
std::move(*exec_result));
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
}
obj.end_object();
return bus.raise(Msg::CommandResponse{
id, std::move(plan)
});
});
}
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
Ev::Io<void> run_command(Jsmn::Object params, Ln::CommandId id) {
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
auto p = std::make_shared<Params>();
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
try {
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
*p = parse_params(params);
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
} catch (std::exception const& ex) {
return bus.raise(Msg::CommandFail{
id, RPC_INVALID_PARAMS,
ex.what(),
Json::Out::empty_object()
});
}
return Boss::log( bus, Info
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
, "XMoveFunds: planning %s -> %s, "
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
"amount=%" PRIu64 " msat, "
"maxfee=%" PRIu64 " msat, "
"maxparts=%" PRIu32 ", execute=%s"
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
, join_scids(p->source_scids).c_str()
, join_scids(p->dest_scids).c_str()
, std::uint64_t(p->amount.to_msat())
, std::uint64_t(p->maxfee.to_msat())
, p->maxparts
, p->execute ? "true" : "false"
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
)
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
+ wait_for_ready()
+ do_plan(p, id);
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
}
public:
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
Impl(S::Bus& bus_)
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
: bus(bus_)
, rpc(nullptr)
, claimer(bus_)
, layer_ready(false)
XMoveFunds: age the clboss-xrebalance layer, runtime-tunable via setconfig Adds periodic askrene-age against the persistent clboss-xrebalance layer so capacity constraints written by inform_channel_constrained do not accumulate forever. Mirrors FundsMover's age_clboss_layer: fires on Msg::TimerRandomHourly, logs num_removed at Debug, and catches RpcError with -32601 stayed at Debug (graceful degradation on CLN without askrene-age) while other codes promote to Warn so sustained aging failure is visible. The aging cutoff is controlled by a new plugin option, clboss-xrebalance-age-secs, default 3600 (1h) to match FundsMover's production value. Operators on networks with slower flows (signet) are expected to widen this; the right value is empirical and will be tuned after observation. The option is registered dynamic=true so the window is mutable at runtime via: lightning-cli setconfig clboss-xrebalance-age-secs <secs> No clboss / lightningd restart required. To support that, three small infrastructure pieces: * Boss::Msg::ManifestOption gains a bool dynamic field (default false; preserves existing behavior). * 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 flag) from Msg::ManifestOption events, then handles incoming Msg::CommandRequest where command == "setconfig" by validating the named option is registered + dynamic and re-raising a fresh Msg::Option on the bus. Existing option handlers re-apply the new value transparently. Contract for any future opt-in to dynamic: at startup lightningd encodes Int / Bool / Flag option values as JSON primitives, but at setconfig time it encodes them as JSON strings. Handlers for dynamic options must accept both Jsmn shapes. The XMoveFunds option handler does this; the contract is documented in SetConfigHandler's header comment so future modules can opt in safely.
2026-06-01 09:31:50 -07:00
, aging_window_secs(3600)
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
, rng(static_cast<std::uint64_t>(
std::chrono::system_clock::now()
.time_since_epoch().count())) {
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
bus.subscribe<Msg::Init>([this](Msg::Init const& init) {
rpc = &init.rpc;
XMoveFunds: wire the askrene call + plan response Replaces the scaffold's echo-only stub with the real per-request flow. clboss-xmovefunds now: 1. Manifests an idempotent startup-time create of the persistent askrene layer named "clboss-xrebalance" (created with persistent=true so it survives CLN restart and accumulates probe knowledge across calls). 2. Per request: a. Parses params (unchanged from the scaffold commit). b. Waits for layer-ready. c. Lists peer channels via listpeerchannels. d. Generates a fresh transient layer name clboss-xrebalance-tmp-<uuid> and creates it (persistent=false). e. Writes the per-direction masks to the transient layer: every us->peer not listed in source_scid is disabled, every peer->us not listed in dest_scid is disabled, via askrene-update-channel enabled=false. Direction is computed from BOLT 7 canonical id ordering. f. Calls getroutes with source=self_id, destination=self_id, layers=["auto.localchans", "clboss-xrebalance", <transient>], amount_msat, maxfee_msat, final_cltv=14, maxparts. Patched askrene (circular-askrene4 branch of ksedgwic/lightning) interprets source=destination as circular self-rebalance routing; stock CLN crashes here with "child died with signal 6", which is the operator's signal to apply the patch. g. Removes the transient layer (best-effort -- swallows errors on the cleanup path). h. Replies with the original parsed plan plus the askrene response embedded under "askrene". Status is "planned" when execute=false, "ready" when execute=true (sendpay path comes in a subsequent commit; for now execute=true still falls through to the plan reply with the same shape, just a different status string). Architecture decisions - Persistent xrebalance layer for accumulated knowledge; transient layer per request for ephemeral masks. See DEVSTATE/XREBALANCE-PLAN-2026-05-30.org section "Two-layer pattern per getroutes call". - AskreneLayer helpers reused -- they already take a layer name parameter and live at the neutral Boss::Mod::AskreneLayer namespace. This commit adds the constant xrebalance_layer_name = "clboss-xrebalance" alongside the existing clboss_layer_name = "clboss" so both subsystems coexist without commingling their layer state. - The patched-askrene requirement is intentional and opt-in: clboss-xmovefunds is a manual RPC trigger, no autonomous code path will exercise circular routing until the periodic xrebalance (Layer 3) and JIT xrebalance (Layer 4) code paths land. At that point we will need a startup feature-detection probe; deferred until then.
2026-05-31 17:32:09 -07:00
self_id = init.self_id;
return Boss::concurrent(create_xrebalance_layer());
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
});
bus.subscribe<Msg::Manifestation
>([this](Msg::Manifestation const&) {
return bus.raise(Msg::ManifestCommand{
"clboss-xmovefunds",
"source_scid(s) dest_scid(s) amount_msat "
"[maxfee_msat] [maxfee_ppm] "
"[maxparts] [execute]",
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
"Manually move funds in a circular "
"self-payment via askrene. Each of "
"source_scid and dest_scid may be either "
"a single scid string (e.g. "
"\"305607x10x0\") or a JSON array of "
"scid strings (e.g. "
"[\"305607x10x0\",\"305121x18x2\"]); "
"the masking layer enables the us->peer "
"direction of every listed source and "
"the peer->us direction of every listed "
"dest, then askrene's MCF distributes "
"the flow. At least one of maxfee_msat "
"(absolute cap, msat) or maxfee_ppm "
"(relative cap, parts-per-million of "
"amount_msat) is required; both may be "
"specified and the more restrictive of "
"the two binds. maxparts defaults to "
"10. execute defaults to true (the route "
"is actually sent via sendpay); pass "
"execute=false for a plan-only response "
"that returns the askrene plan without "
"sending. Lowest-level primitive used by "
"the xrebalance algorithm; the caller "
XMoveFunds: implement sendpay execution path Flips clboss-xmovefunds from plan-only to actually sending the returned route via sendpay when execute=true. All the masking and getroutes work from the previous commit is unchanged; this commit adds the post-getroutes sendpay/waitsendpay machinery plus the supporting infrastructure for self-payment HTLC resolution. Mechanism Once getroutes returns one or more routes (potentially multi- part), do_execute: 1. Generates a fresh preimage and payment_secret via the new XMoveFunds::Claimer (mirroring FundsMover::Claimer's pattern but maintained independently so the two subsystems coexist without sharing the entry table). The preimage is registered in the claim table so that when the resulting HTLCs arrive at us we auto-resolve them. 2. Constructs a sendpay-format route per part by copying the askrene path[] hops verbatim (mapping short_channel_id_dir, amount_out_msat, cltv_out, and node_id_out into the sendpay hop shape) and appending a closing hop (fill_peer -> self_id via the dest_scid that matches the route's last node_id_out). 3. Issues sendpay for each part with shared payment_hash, payment_secret, label, and groupid. Single-part payments use partid=0 (non-MPP). Multi-part payments use partid 1..N and pass amount_msat as the total across all parts. 4. Waits for every part via waitsendpay. Sendpay or waitsendpay errors per part are captured into an errors array so the overall reply can still summarise what happened to each part instead of bailing on the first failure. Label format is "clboss-xrebalance-<unix-ts>" so the eventual EarningsTracker integration can disambiguate xrebalance-family payments from FundsMover ones (see R4 in the plan). execute default flipped to true Per the plan's "Manual command actually executes (signet)" decision, the default is now execute=true. Caller explicitly passes execute=false to get the plan-only response (the existing "predict-and-compare" mode), which still returns the askrene plan with no sendpay. API surface The reply gains: status "executed" when sendpay+waitsendpay ran, "execute_skipped" when execute=true was requested but getroutes errored, or "planned" when execute=false. execution object, only present when status = "executed": payment_hash hex preimage hex label "clboss-xrebalance-<ts>" groupid u64 parts number of MPP parts results array of per-part waitsendpay responses errors array of per-part error strings (only present if any failed)
2026-05-31 17:54:35 -07:00
"specifies the explicit channel set.",
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
false
XMoveFunds: age the clboss-xrebalance layer, runtime-tunable via setconfig Adds periodic askrene-age against the persistent clboss-xrebalance layer so capacity constraints written by inform_channel_constrained do not accumulate forever. Mirrors FundsMover's age_clboss_layer: fires on Msg::TimerRandomHourly, logs num_removed at Debug, and catches RpcError with -32601 stayed at Debug (graceful degradation on CLN without askrene-age) while other codes promote to Warn so sustained aging failure is visible. The aging cutoff is controlled by a new plugin option, clboss-xrebalance-age-secs, default 3600 (1h) to match FundsMover's production value. Operators on networks with slower flows (signet) are expected to widen this; the right value is empirical and will be tuned after observation. The option is registered dynamic=true so the window is mutable at runtime via: lightning-cli setconfig clboss-xrebalance-age-secs <secs> No clboss / lightningd restart required. To support that, three small infrastructure pieces: * Boss::Msg::ManifestOption gains a bool dynamic field (default false; preserves existing behavior). * 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 flag) from Msg::ManifestOption events, then handles incoming Msg::CommandRequest where command == "setconfig" by validating the named option is registered + dynamic and re-raising a fresh Msg::Option on the bus. Existing option handlers re-apply the new value transparently. Contract for any future opt-in to dynamic: at startup lightningd encodes Int / Bool / Flag option values as JSON primitives, but at setconfig time it encodes them as JSON strings. Handlers for dynamic options must accept both Jsmn shapes. The XMoveFunds option handler does this; the contract is documented in SetConfigHandler's header comment so future modules can opt in safely.
2026-06-01 09:31:50 -07:00
}) + bus.raise(Msg::ManifestOption{
"clboss-xrebalance-age-secs",
Msg::OptionType_Int,
Json::Out::direct(aging_window_secs),
"Cutoff (seconds) for periodic askrene-age "
"on the persistent clboss-xrebalance "
"layer. Constraints older than this are "
"trimmed once per TimerRandomHourly tick "
"so stale capacity pessimism does not "
"accumulate forever. Dynamic: settable "
"at runtime via `lightning-cli setconfig "
"clboss-xrebalance-age-secs <secs>`. "
"Default 3600 (1h); operators on slower "
"networks (signet) typically widen this.",
/* dynamic = */ true
});
});
bus.subscribe<Msg::Option
>([this](Msg::Option const& o) {
if (o.name != "clboss-xrebalance-age-secs")
return Ev::lift();
/* At startup lightningd sends Int options as a
* JSON number primitive (Initiator forwards the
* value verbatim from the init request); at
* runtime lightningd's setconfig path encodes
* the value as a JSON string (see
* cln/lightningd/plugin.c
* plugin_set_dynamic_opt). Tolerate both. */
auto secs = std::uint64_t(0);
try {
if (o.value.is_number()) {
secs = std::uint64_t(double(o.value));
} else if (o.value.is_string()) {
secs = std::stoull(std::string(o.value));
} else {
return Boss::log( bus, Warn
, "XMoveFunds: "
"clboss-xrebalance-"
"age-secs: "
"unsupported value "
"type; keeping "
"%" PRIu64 "."
, aging_window_secs
);
}
} catch (std::exception const& e) {
return Boss::log( bus, Warn
, "XMoveFunds: clboss-"
"xrebalance-age-secs: "
"parse error '%s'; "
"keeping %" PRIu64 "."
, e.what()
, aging_window_secs
);
}
if (secs == 0) {
return Boss::log( bus, Warn
, "XMoveFunds: clboss-"
"xrebalance-age-secs: "
"must be > 0; keeping "
"%" PRIu64 "."
, aging_window_secs
);
}
aging_window_secs = secs;
return Boss::log( bus, Info
, "XMoveFunds: xrebalance layer "
"aging window = %" PRIu64
" seconds"
, aging_window_secs
);
});
bus.subscribe<Msg::TimerRandomHourly
>([this](Msg::TimerRandomHourly const&) {
return wait_for_ready().then([this]() {
return age_xrebalance_layer();
XMoveFunds: scaffold the manual-trigger rebalance primitive Adds Boss/Mod/XMoveFunds/Main.{hpp,cpp} and registers a new RPC command `clboss-xmovefunds`. This is the lowest-level primitive that the upcoming xrebalance algorithm will sit on top of: the caller hands it an explicit set of source channels (drain candidates), destination channels (fill candidates), an amount, and optional knobs, and the module will eventually drive an askrene-getroutes call (circular mode) and an optional sendpay. The module follows the pimpl + Impl pattern already used by FundsMover and is installed in Boss/Mod/all.cpp alongside the existing rebalance machinery. API surface Each of source_scid and dest_scid may be either a single scid string or a JSON array of scid strings. The single-channel form is convenient for hand-typed RPC calls; the array form is what the xrebalance algorithm will use to express multi-source / multi-dest tier selection. parse_scid_list() in the implementation handles both shapes uniformly. Named parameters: source_scid scid string or array -- channels whose us->peer direction stays unmasked. Required. dest_scid scid string or array -- channels whose peer->us direction stays unmasked. Required. amount_msat integer -- total amount to move. Required. maxfee_msat integer. Defaults to 5000 ppm of amount_msat, floored at 1000 msat. maxparts integer. Defaults to 10. execute boolean. Defaults to false; when false, the route is computed and reported but not sent. The manifest help text spells out the array shape with concrete examples so anyone running `lightning-cli help clboss-xmovefunds` sees the full surface without having to read the code.
2026-05-31 13:48:03 -07:00
});
});
bus.subscribe<Msg::CommandRequest
>([this](Msg::CommandRequest const& m) {
if (m.command != "clboss-xmovefunds")
return Ev::lift();
return run_command(m.params, m.id);
});
}
};
Main::Main(Main&&) =default;
Main::~Main() =default;
Main::Main(S::Bus& bus_) : pimpl(Util::make_unique<Impl>(bus_)) { }
}}}