servo: clightning: initialize a script for rebalancing with peers
This commit is contained in:
316
hosts/by-name/servo/services/cryptocurrencies/clightning-sane/clightning-sane
Executable file
316
hosts/by-name/servo/services/cryptocurrencies/clightning-sane/clightning-sane
Executable file
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env nix-shell
|
||||
#!nix-shell -i python3 -p "python3.withPackages (ps: [ ps.pyln-client ])"
|
||||
|
||||
# pyln-client docs: <https://github.com/ElementsProject/lightning/tree/master/contrib/pyln-client>
|
||||
# terminology:
|
||||
# - "scid": "Short Channel ID", e.g. 123456x7890x0
|
||||
# from this id, we can locate the actual channel, its peers, and its parameters
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pyln.client import LightningRpc, Millisatoshi, RpcError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RPC_FILE = "/var/lib/clightning/bitcoin/lightning-rpc"
|
||||
# CLTV (HLTC delta) of the final hop
|
||||
CLTV = 9
|
||||
|
||||
class RebalanceResult(Enum):
|
||||
SUCCESS = "SUCCESS"
|
||||
FAIL_TEMPORARY = "FAIL_TEMPORARY"
|
||||
FAIL_PERMANENT = "FAIL_PERMANENT"
|
||||
|
||||
class RouteError(Enum):
|
||||
HAS_BASE_FEE = "HAS_BASE_FEE"
|
||||
NO_ROUTE = "NO_ROUTE"
|
||||
|
||||
class LocalChannel:
|
||||
def __init__(self, channels: list, self_id: str):
|
||||
assert len(channels) == 2, f"unexpected: more than 2 channels: {channels}"
|
||||
out = None
|
||||
in_ = None
|
||||
for c in channels:
|
||||
if c["source"] == self_id:
|
||||
assert out is None, f"unexpected: multiple channels from self: {channels}"
|
||||
out = c
|
||||
if c["destination"] == self_id:
|
||||
assert in_ is None, f"unexpected: multiple channels to self: {channels}"
|
||||
in_ = c
|
||||
assert out is not None, f"no channel from self: {channels}"
|
||||
assert in_ is not None, f"no channel to self: {channels}"
|
||||
assert out["destination"] == in_["source"], f"channel peers are asymmetric?! {channels}"
|
||||
assert out["short_channel_id"] == in_["short_channel_id"], f"channel ids differ?! {channels}"
|
||||
|
||||
self.from_me = out
|
||||
self.to_me = in_
|
||||
|
||||
@property
|
||||
def remote_peer(self) -> str:
|
||||
return self.from_me["destination"]
|
||||
|
||||
@property
|
||||
def scid(self) -> str:
|
||||
return self.from_me["short_channel_id"]
|
||||
|
||||
@property
|
||||
def htlc_minimum_msat(self) -> Millisatoshi:
|
||||
return max(self.from_me["htlc_minimum_msat"], self.to_me["htlc_minimum_msat"])
|
||||
|
||||
@property
|
||||
def htlc_maximum_msat(self) -> Millisatoshi:
|
||||
return min(self.from_me["htlc_maximum_msat"], self.to_me["htlc_maximum_msat"])
|
||||
|
||||
@property
|
||||
def direction_to_me(self) -> int:
|
||||
return self.to_me["direction"]
|
||||
|
||||
@property
|
||||
def direction_from_me(self) -> int:
|
||||
return self.from_me["direction"]
|
||||
|
||||
@property
|
||||
def directed_scid_to_me(self) -> str:
|
||||
scid, dir = self.to_me["short_channel_id"], self.direction_to_me
|
||||
return f"{scid}/{dir}"
|
||||
|
||||
@property
|
||||
def directed_scid_from_me(self) -> str:
|
||||
scid, dir = self.from_me["short_channel_id"], self.direction_from_me
|
||||
return f"{scid}/{dir}"
|
||||
|
||||
@property
|
||||
def delay_them(self) -> str:
|
||||
return self.to_me["delay"]
|
||||
|
||||
@property
|
||||
def delay_me(self) -> str:
|
||||
return self.from_me["delay"]
|
||||
|
||||
class Balancer:
|
||||
def __init__(self, rpc: LightningRpc):
|
||||
self.rpc = rpc
|
||||
self.self_id = rpc.getinfo()["id"]
|
||||
self.bad_channels = [] # list of directed scid
|
||||
self.nonzero_base_channels = [] # list of directed scid
|
||||
|
||||
def _localchannel(self, scid: str) -> LocalChannel:
|
||||
return LocalChannel(self.rpc.listchannels(scid)["channels"], self.self_id)
|
||||
|
||||
def _get_directed_scid(self, scid: str, direction: int) -> dict:
|
||||
channels = self.rpc.listchannels(scid)["channels"]
|
||||
channels = [c for c in channels if c["direction"] == direction]
|
||||
assert len(channels) == 1, f"expected exactly 1 channel: {channels}"
|
||||
return channels[0]
|
||||
|
||||
def balance_once_with_retries(self, out_scid: str, in_scid: str, min_tx_msat: int, max_tx_msat: int, retries: int = 20) -> None:
|
||||
for i in range(retries):
|
||||
if i != 0:
|
||||
logger.info(f"retrying rebalance: {i} of {retries}\n")
|
||||
res = self.balance_once(out_scid, in_scid, min_tx_msat, max_tx_msat)
|
||||
if res == RebalanceResult.SUCCESS:
|
||||
logger.info(f"rebalanced once with success {out_scid} -> {in_scid}")
|
||||
break
|
||||
if res == RebalanceResult.FAIL_PERMANENT:
|
||||
logger.info(f"rebalance {out_scid} -> {in_scid} is impossible (likely no route)")
|
||||
break
|
||||
else:
|
||||
logger.info(f"failed to rebalance {out_scid} -> {in_scid} within {retries} attempts")
|
||||
|
||||
def balance_once(self, out_scid: str, in_scid: str, min_tx_msat: int, max_tx_msat: int) -> None:
|
||||
out_ch = self._localchannel(out_scid)
|
||||
in_ch = self._localchannel(in_scid)
|
||||
|
||||
if out_ch.directed_scid_from_me in self.bad_channels or in_ch.directed_scid_to_me in self.bad_channels:
|
||||
logger.info(f"rebalance {out_scid} -> {in_scid} failed in our own channel")
|
||||
return RebalanceResult.FAIL_PERMANENT
|
||||
|
||||
tx_bounds = self._bound_tx_size(out_ch, in_ch, min_tx_msat, max_tx_msat)
|
||||
if tx_bounds is None:
|
||||
return RebalanceResult.FAIL_PERMANENT # no valid bounds
|
||||
min_tx_msat, max_tx_msat = tx_bounds
|
||||
|
||||
route = self.route(out_ch, in_ch, min_tx_msat, max_tx_msat)
|
||||
logger.debug(f"route: {route}")
|
||||
if route == RouteError.NO_ROUTE:
|
||||
return RebalanceResult.FAIL_PERMANENT
|
||||
elif route == RouteError.HAS_BASE_FEE:
|
||||
# try again with a different route
|
||||
return RebalanceResult.FAIL_TEMPORARY
|
||||
|
||||
amount_msat = route[0]["amount_msat"]
|
||||
invoice_id = f"rebalance-{time.time():.6f}".replace(".", "_")
|
||||
invoice_desc = f"bal {out_scid}:{in_scid}"
|
||||
invoice = self.rpc.invoice("any", invoice_id, invoice_desc)
|
||||
logger.debug(f"invoice: {invoice}")
|
||||
|
||||
payment = self.rpc.sendpay(route, invoice["payment_hash"], invoice_id, amount_msat, invoice["bolt11"], invoice["payment_secret"])
|
||||
logger.debug(f"sent: {payment}")
|
||||
|
||||
try:
|
||||
wait = self.rpc.waitsendpay(invoice["payment_hash"])
|
||||
logger.debug(f"result: {wait}")
|
||||
except RpcError as e:
|
||||
err_data = e.error["data"]
|
||||
err_scid, err_dir = err_data["erring_channel"], err_data["erring_direction"]
|
||||
err_directed_scid = f"{err_scid}/{err_dir}"
|
||||
logger.debug(f"ch failed, adding to excludes: {err_directed_scid}; {e.error}")
|
||||
self.bad_channels.append(err_directed_scid)
|
||||
return RebalanceResult.FAIL_TEMPORARY
|
||||
else:
|
||||
return RebalanceResult.SUCCESS
|
||||
|
||||
def _bound_tx_size(self, out_ch: LocalChannel, in_ch: LocalChannel, min_tx_msat: int, max_tx_msat: int) -> tuple[int, int] | None:
|
||||
# don't even try to route if the channels advertise to not support our request
|
||||
min_, max_ = min_tx_msat, max_tx_msat
|
||||
min_tx_msat = max(min_tx_msat, out_ch.htlc_minimum_msat)
|
||||
min_tx_msat = max(min_tx_msat, in_ch.htlc_minimum_msat)
|
||||
max_tx_msat = min(max_tx_msat, out_ch.htlc_maximum_msat)
|
||||
max_tx_msat = min(max_tx_msat, in_ch.htlc_maximum_msat)
|
||||
if min_ != min_tx_msat:
|
||||
logger.debug(f"increased min_tx_msat due to route requirements: {min_} -> {min_tx_msat}")
|
||||
if max_ != max_tx_msat:
|
||||
logger.debug(f"decreased max_tx_msat due to route requirements: {max_} -> {max_tx_msat}")
|
||||
|
||||
if in_ch.to_me["base_fee_millisatoshi"] != 0:
|
||||
logger.info(f"aborting route because inbound requires base fees")
|
||||
return None
|
||||
|
||||
per_mili = in_ch.to_me["fee_per_millionth"]
|
||||
if per_mili != 0:
|
||||
new_max = math.ceil(1000000 / per_mili) - 1
|
||||
if new_max < max_tx_msat:
|
||||
logger.debug(f"decreased max_tx_msat due to inbound fee ppm: {max_tx_msat} -> {new_max}")
|
||||
max_tx_msat = new_max
|
||||
|
||||
if min_tx_msat > max_tx_msat:
|
||||
logger.info(f"aborting route because of conflicting HTLC min/max requirements ({min_tx_msat} > {max_tx_msat})")
|
||||
return None
|
||||
|
||||
return min_tx_msat, max_tx_msat
|
||||
|
||||
def route(self, out_ch: LocalChannel, in_ch: LocalChannel, min_tx_msat: int, max_tx_msat: int) -> list[dict] | RouteError:
|
||||
exclude = [
|
||||
# ensure the payment doesn't cross either channel in reverse.
|
||||
# note that this doesn't preclude it from taking additional trips through self, with other peers.
|
||||
# out_ch.directed_scid_to_me,
|
||||
# in_ch.directed_scid_from_me,
|
||||
|
||||
# alternatively, never route through self. this avoids a class of logic error, like what to do with fees i charge "myself".
|
||||
self.self_id
|
||||
] + self.bad_channels + self.nonzero_base_channels
|
||||
|
||||
out_peer = out_ch.remote_peer
|
||||
in_peer = in_ch.remote_peer
|
||||
route_or_max_tx = self._find_partial_route(out_peer, in_peer, max_tx_msat, exclude=exclude)
|
||||
|
||||
while isinstance(route_or_max_tx, int):
|
||||
logger.debug(f"max feeless tx: {route_or_max_tx}")
|
||||
try_again_msat = max(min_tx_msat, route_or_max_tx)
|
||||
|
||||
if try_again_msat == route_or_max_tx:
|
||||
return RouteError.NO_ROUTE
|
||||
|
||||
# due to per-channel HTLC size requirements, we have to try again and we'll maybe get a different route
|
||||
route_or_max_tx = self._find_partial_route(out_peer, in_peer, try_again_msat, exclude=exclude)
|
||||
route = route_or_max_tx
|
||||
|
||||
if isinstance(route, RouteError):
|
||||
return route
|
||||
|
||||
route = self._add_route_endpoints(route, out_ch, in_ch)
|
||||
return route
|
||||
|
||||
def _find_partial_route(self, out_peer: str, in_peer: str, tx_msat: int, exclude: list[str]=[]) -> list[dict] | RouteError | int:
|
||||
route = self.rpc.getroute(in_peer, amount_msat=tx_msat, riskfactor=0, fromid=out_peer, exclude=exclude, cltv=CLTV)
|
||||
route = route["route"]
|
||||
if route == []:
|
||||
logger.debug(f"no route for {tx_msat}msat {out_peer} -> {in_peer}")
|
||||
return RouteError.NO_ROUTE
|
||||
|
||||
send_msat = route[0]["amount_msat"]
|
||||
if send_msat != Millisatoshi(tx_msat):
|
||||
logger.debug(f"found route with non-zero fee: {send_msat} -> {tx_msat}. {route}")
|
||||
return self._max_feeless_tx_for_route(route)
|
||||
|
||||
return route
|
||||
|
||||
def _max_feeless_tx_for_route(self, route: list[dict]) -> int|None:
|
||||
has_base_fee = False
|
||||
max_fee_per_mili = 0
|
||||
for hop in route:
|
||||
hop_scid = hop["channel"]
|
||||
hop_dir = hop["direction"]
|
||||
ch = self._get_directed_scid(hop_scid, hop_dir)
|
||||
|
||||
feebase = ch["base_fee_millisatoshi"]
|
||||
if feebase:
|
||||
has_base_fee = True
|
||||
self.nonzero_base_channels.append(f"{hop_scid}/{hop_dir}")
|
||||
|
||||
per_mili = ch["fee_per_millionth"]
|
||||
max_fee_per_mili = max(max_fee_per_mili, per_mili)
|
||||
|
||||
if has_base_fee:
|
||||
return RouteError.HAS_BASE_FEE
|
||||
|
||||
if max_fee_per_mili == 0:
|
||||
return int(route[0]["amount_msat"]) # no practical limit
|
||||
|
||||
return math.ceil(1000000 / max_fee_per_mili) - 1
|
||||
|
||||
def _add_route_endpoints(self, route, out_ch: LocalChannel, in_ch: LocalChannel):
|
||||
inbound_hop = dict(
|
||||
id=self.self_id,
|
||||
channel=in_ch.scid,
|
||||
direction=in_ch.direction_to_me,
|
||||
amount_msat=route[-1]["amount_msat"],
|
||||
delay=route[-1]["delay"],
|
||||
style="tlv",
|
||||
)
|
||||
route = self._add_route_delay(route, in_ch.delay_them) + [ inbound_hop ]
|
||||
|
||||
outbound_hop = dict(
|
||||
id=out_ch.remote_peer,
|
||||
channel=out_ch.scid,
|
||||
direction=out_ch.direction_from_me,
|
||||
amount_msat=route[0]["amount_msat"],
|
||||
delay=route[0]["delay"] + out_ch.delay_them,
|
||||
style="tlv",
|
||||
)
|
||||
route = [ outbound_hop ] + route
|
||||
return route
|
||||
|
||||
def _add_route_delay(self, route: list[dict], delay: int) -> list[dict]:
|
||||
return [ dict(hop, delay=hop["delay"] + delay) for hop in route ]
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig()
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
parser = argparse.ArgumentParser(description="rebalance lightning channel balances")
|
||||
parser.add_argument("out", help="peer id to send tx through")
|
||||
parser.add_argument("in_", help="peer id to receive tx through")
|
||||
parser.add_argument("--verbose", action="store_true", help="more logging")
|
||||
parser.add_argument("--min-msat", default="999", help="min to rebalance")
|
||||
parser.add_argument("--max-msat", default="1000000", help="max to rebalance")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
rpc = LightningRpc(RPC_FILE)
|
||||
balancer = Balancer(rpc)
|
||||
|
||||
balancer.balance_once_with_retries(args.out, args.in_, int(args.min_msat), int(args.max_msat))
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
Reference in New Issue
Block a user