From d5b748e455e72e7d3d7c189e250c89e381eb67b5 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Sat, 30 May 2026 13:52:56 -0700 Subject: [PATCH] contrib: add clboss-xrebalance-spike (R1 askrene mask test) 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? --- contrib/clboss-xrebalance-spike | 368 ++++++++++++++++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100755 contrib/clboss-xrebalance-spike diff --git a/contrib/clboss-xrebalance-spike b/contrib/clboss-xrebalance-spike new file mode 100755 index 0000000..3d4a686 --- /dev/null +++ b/contrib/clboss-xrebalance-spike @@ -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()