mirror of
https://github.com/ZmnSCPxj/clboss.git
synced 2026-08-13 12:33:20 +02:00
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?
This commit is contained in:
parent
4d1b92e2bd
commit
d5b748e455
1 changed files with 368 additions and 0 deletions
368
contrib/clboss-xrebalance-spike
Executable file
368
contrib/clboss-xrebalance-spike
Executable 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue