contrib: add clboss-xrebalance-spike (R1 askrene mask test)
Some checks failed
Code Base Sanity Check / tests (push) Has been cancelled
Code Base Sanity Check / coverage (push) Has been cancelled
Code Base Sanity Check / build-clang (push) Has been cancelled

Adds a single-shot Python spike that exercises askrene-getroutes on
the running node to answer one question: can we steer the route to
terminate at a specific channel by disabling every other incoming
direction in a layer?
This commit is contained in:
Ken Sedgwick 2026-05-30 13:52:56 -07:00
parent 4d1b92e2bd
commit d5b748e455
No known key found for this signature in database
GPG key ID: DBD2AF0849D711A9

368
contrib/clboss-xrebalance-spike Executable file
View file

@ -0,0 +1,368 @@
#!/usr/bin/env python3
# clboss-xrebalance-spike — askrene mask test (R1 spike).
#
# Tests whether askrene-getroutes can be steered via layer-level
# masks to produce a self-cycle through two pinned channels: one
# outgoing (source channel) and one incoming (destination channel).
#
# The production xrebalance plan:
# - source channel = the chosen drain (us -> peer)
# - dest channel = the chosen fill (peer -> us)
# - in a transient askrene layer, mask EVERY OTHER outgoing
# direction we own (every us -> peer) and EVERY OTHER incoming
# direction (every peer -> us). Only source and dest remain
# usable.
# - call askrene-getroutes source=us destination=us with that
# layer included. If askrene returns a route, the only viable
# cycle is us -> source_peer -> ... -> dest_peer -> us.
#
# This script does exactly that, then reports whether askrene
# returned a route and whether its first and last hops match the
# pinned channels.
#
# Layer note: the layer "xspike" is just a container for the mask
# constraints (askrene-update-channel / askrene-inform-channel
# require a layer to write into). No routing feedback is stored.
# Layer is ephemeral (persistent=false) and removed at end unless
# --keep-layer is passed.
#
# Risk being tested: askrene treats source=destination as a node
# pair with zero required flow and may return an empty path. If
# that happens we will pivot to source=source_peer / dest=dest_peer
# with us in disabled_nodes.
import argparse
import json
import subprocess
import sys
SPIKE_LAYER = "xspike"
def lcli(network_option, lightning_dir, command, **kwargs):
cmd = ["lightning-cli", network_option]
if lightning_dir:
cmd.append(f"--lightning-dir={lightning_dir}")
cmd.append(command)
for k, v in kwargs.items():
if isinstance(v, (dict, list)):
cmd.append(f"{k}={json.dumps(v)}")
elif isinstance(v, bool):
cmd.append(f"{k}={'true' if v else 'false'}")
else:
cmd.append(f"{k}={v}")
print(f" $ {' '.join(cmd)}", file=sys.stderr)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f" FAILED ({result.returncode})", file=sys.stderr)
if result.stderr.strip():
print(f" STDERR: {result.stderr.strip()}", file=sys.stderr)
if result.stdout.strip():
print(f" STDOUT: {result.stdout.strip()}", file=sys.stderr)
return None
out = result.stdout.strip()
if not out:
return {}
try:
return json.loads(out)
except json.JSONDecodeError:
return out
def get_us(net, ld):
return lcli(net, ld, "getinfo")["id"]
def get_channels(net, ld):
return lcli(net, ld, "listpeerchannels")["channels"]
def us_to_peer_dir(us_id, peer_id):
# BOLT 7: direction 0 means node_id_1 -> node_id_2 where
# node_id_1 < node_id_2 lexicographically. So us -> peer is
# direction 0 when us < peer, else 1.
return 0 if us_id < peer_id else 1
def peer_to_us_dir(us_id, peer_id):
return 0 if peer_id < us_id else 1
def create_layer(net, ld):
return lcli(net, ld, "askrene-create-layer",
layer=SPIKE_LAYER, persistent=False)
def disable_self(net, ld, us_id):
# Add us to the spike layer's disabled_nodes. This forbids us
# as an intermediate. Source/destination are exempt (verified
# by FundsMover already using disable_node in the clboss layer
# alongside dest=us). The intent is to force askrene's MCF
# away from the degenerate zero-hop self-flow that crashed
# cln-askrene with source=dest=us and no self-disable.
return lcli(net, ld, "askrene-disable-node",
layer=SPIKE_LAYER, node=us_id)
def remove_layer(net, ld):
return lcli(net, ld, "askrene-remove-layer", layer=SPIKE_LAYER)
def mask_direction(net, ld, sdir, method):
if method == "update":
return lcli(net, ld, "askrene-update-channel",
layer=SPIKE_LAYER,
short_channel_id_dir=sdir,
enabled=False,
htlc_minimum_msat=0,
htlc_maximum_msat=0,
fee_base_msat=0,
fee_proportional_millionths=0,
cltv_expiry_delta=0)
if method == "inform":
return lcli(net, ld, "askrene-inform-channel",
layer=SPIKE_LAYER,
short_channel_id_dir=sdir,
amount_msat=1,
inform="constrained")
raise ValueError(f"unknown mask method: {method}")
def write_masks(net, ld, us_id, channels, source_scid, dest_scid, method):
"""Mask every (us->peer) direction except source_scid and every
(peer->us) direction except dest_scid. Returns (n_masked,
n_skipped, problems)."""
n_masked = 0
n_skipped = 0
problems = []
for ch in channels:
if ch.get("state") != "CHANNELD_NORMAL":
n_skipped += 1
continue
scid = ch.get("short_channel_id")
if scid is None:
n_skipped += 1
continue
peer_id = ch["peer_id"]
# us -> peer direction
if scid != source_scid:
sdir = f"{scid}/{us_to_peer_dir(us_id, peer_id)}"
r = mask_direction(net, ld, sdir, method)
if r is None:
problems.append(f"failed to mask outgoing {sdir}")
else:
n_masked += 1
# peer -> us direction
if scid != dest_scid:
sdir = f"{scid}/{peer_to_us_dir(us_id, peer_id)}"
r = mask_direction(net, ld, sdir, method)
if r is None:
problems.append(f"failed to mask incoming {sdir}")
else:
n_masked += 1
return n_masked, n_skipped, problems
def getroutes(net, ld, source, dest, amount_msat, layers,
maxfee_msat, final_cltv, maxparts):
# The askrene plugin registers this RPC as plain "getroutes",
# not "askrene-getroutes" (verified in CLBOSS code:
# ChannelCandidateMatchmaker, ActiveProber, Dowser).
return lcli(net, ld, "getroutes",
source=source,
destination=dest,
amount_msat=amount_msat,
layers=layers,
maxfee_msat=maxfee_msat,
final_cltv=final_cltv,
maxparts=maxparts)
def summarize_route(result):
if result is None:
return " (rpc failed)"
if not isinstance(result, dict):
return f" (unexpected: {result!r})"
routes = result.get("routes")
if not routes:
return f" (no routes) raw={json.dumps(result)[:200]}"
out = []
for i, route in enumerate(routes):
path = route.get("path", [])
out.append(
f" route[{i}] "
f"final_amount_msat={route.get('final_amount_msat')} "
f"probability_ppm={route.get('probability_ppm')} "
f"hops={len(path)}"
)
for j, hop in enumerate(path):
sdir = hop.get("short_channel_id_dir") \
or hop.get("short_channel_id") \
or "?"
nxt = hop.get("next_node_id", "")
nxt_short = nxt[:12] + "..." if nxt else "?"
out.append(
f" hop[{j}] {sdir} -> {nxt_short} "
f"amount={hop.get('amount_msat', '?')} "
f"delay={hop.get('delay', '?')}"
)
return "\n".join(out)
def first_hop_scid(result):
if not isinstance(result, dict):
return None
routes = result.get("routes")
if not routes:
return None
path = routes[0].get("path", [])
if not path:
return None
sdir = path[0].get("short_channel_id_dir") \
or path[0].get("short_channel_id")
return sdir.split("/")[0] if sdir else None
def last_hop_scid(result):
if not isinstance(result, dict):
return None
routes = result.get("routes")
if not routes:
return None
path = routes[0].get("path", [])
if not path:
return None
sdir = path[-1].get("short_channel_id_dir") \
or path[-1].get("short_channel_id")
return sdir.split("/")[0] if sdir else None
def main():
ap = argparse.ArgumentParser(description="askrene mask R1 spike")
ap.add_argument("--network", default="signet",
choices=["signet", "bitcoin", "regtest"])
ap.add_argument("--lightning-dir", default=None)
ap.add_argument("--source-scid", required=True,
help="SCID of the chosen outgoing channel "
"(drain). Its us->peer direction stays "
"unmasked; every other outgoing direction "
"is masked.")
ap.add_argument("--dest-scid", required=True,
help="SCID of the chosen incoming channel "
"(fill). Its peer->us direction stays "
"unmasked; every other incoming direction "
"is masked.")
ap.add_argument("--amount-msat", type=int, required=True)
ap.add_argument("--maxfee-msat", type=int, required=True)
ap.add_argument("--final-cltv", type=int, default=14)
ap.add_argument("--maxparts", type=int, default=1)
ap.add_argument("--mask-method", default="update",
choices=["update", "inform"])
ap.add_argument("--keep-layer", action="store_true",
help="don't remove the spike layer at end")
args = ap.parse_args()
net = f"--{args.network}"
ld = args.lightning_dir
print("=" * 72)
print("R1 SPIKE — askrene mask test")
print("=" * 72)
us = get_us(net, ld)
print(f" us = {us}")
print(f" source scid = {args.source_scid} (outgoing kept open)")
print(f" dest scid = {args.dest_scid} (incoming kept open)")
print(f" amount = {args.amount_msat} msat")
print(f" maxfee = {args.maxfee_msat} msat")
print(f" mask method = {args.mask_method}")
print(f" spike layer = {SPIKE_LAYER} (ephemeral)")
print()
print("=" * 72)
print(f"STEP 1: create transient layer {SPIKE_LAYER}, "
f"add us to disabled_nodes")
print("=" * 72)
create_layer(net, ld)
disable_self(net, ld, us)
print()
print("=" * 72)
print(f"STEP 2: mask all other (us->peer) and (peer->us) directions")
print("=" * 72)
channels = get_channels(net, ld)
n_masked, n_skipped, problems = write_masks(
net, ld, us, channels,
args.source_scid, args.dest_scid, args.mask_method)
print(f" masked {n_masked} directions, skipped {n_skipped} channels")
for p in problems:
print(f" PROBLEM: {p}")
print()
print("=" * 72)
print(f"STEP 3: getroutes source=us destination=us "
f"layers=[auto.localchans, {SPIKE_LAYER}]")
print("=" * 72)
result = getroutes(net, ld, us, us,
args.amount_msat,
["auto.localchans", SPIKE_LAYER],
args.maxfee_msat, args.final_cltv, args.maxparts)
print(summarize_route(result))
print()
print("=" * 72)
if args.keep_layer:
print(f"STEP 4: keeping layer {SPIKE_LAYER} (per --keep-layer)")
print(f" inspect: lightning-cli {net} askrene-listlayers "
f"layer={SPIKE_LAYER}")
print(f" cleanup: lightning-cli {net} askrene-remove-layer "
f"layer={SPIKE_LAYER}")
else:
print(f"STEP 4: cleanup layer {SPIKE_LAYER}")
remove_layer(net, ld)
print("=" * 72)
print()
print("=" * 72)
print("ANALYSIS")
print("=" * 72)
first = first_hop_scid(result)
last = last_hop_scid(result)
print(f" first hop SCID: {first} (want: {args.source_scid})")
print(f" last hop SCID: {last} (want: {args.dest_scid})")
print()
if result is None or not isinstance(result, dict) \
or not result.get("routes"):
print(" RESULT: NO ROUTE. Possible causes:")
print(" - askrene treats source=destination as zero-flow "
"and returns empty path.")
print(" - masks are working but no viable cycle exists "
"between source_peer and dest_peer.")
print(" - amount too large for any path.")
print(" Try --keep-layer and inspect the layer state; "
"consider rerunning with source=source_peer / "
"destination=dest_peer instead.")
sys.exit(2)
if first == args.source_scid and last == args.dest_scid:
print(" RESULT: PASS — askrene found a cycle using both "
"pinned channels. Mask-based pinning works.")
sys.exit(0)
if first == args.source_scid:
print(" RESULT: PARTIAL — first hop matches but last hop "
"differs. Incoming mask may be ineffective.")
sys.exit(3)
if last == args.dest_scid:
print(" RESULT: PARTIAL — last hop matches but first hop "
"differs. Outgoing mask may be ineffective.")
sys.exit(3)
print(" RESULT: FAIL — neither pinned channel was used. "
"Masks ineffective or askrene found an unexpected path.")
sys.exit(1)
if __name__ == "__main__":
main()