mirror of
https://github.com/aljazceru/plugins.git
synced 2025-12-25 08:54:20 +01:00
1053 lines
43 KiB
Python
Executable File
1053 lines
43 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
from clnutils import cln_parse_rpcversion
|
|
from datetime import timedelta
|
|
from functools import reduce
|
|
from pyln.client import Plugin, Millisatoshi, RpcError
|
|
import threading
|
|
import time
|
|
import uuid
|
|
import concurrent.futures
|
|
|
|
plugin = Plugin()
|
|
plugin.rebalance_stop_by_user = False
|
|
plugin.rebalance_stop_by_thread = False
|
|
plugin.rebalance_stop_by_event = False
|
|
plugin.threadids = {}
|
|
|
|
|
|
def rebalance_stopping():
|
|
return (plugin.rebalance_stop_by_user or
|
|
plugin.rebalance_stop_by_thread or
|
|
plugin.rebalance_stop_by_event)
|
|
|
|
|
|
def get_thread_id_str():
|
|
max_digits = len(str(plugin.threads - 1))
|
|
return f"{plugin.threadids.get(threading.get_ident(), 0):{'0' + str(max_digits)}}"
|
|
|
|
|
|
# The route msat helpers are needed because older versions of cln
|
|
# had different msat/msatoshi fields with different types Millisatoshi/int
|
|
def route_set_msat(obj, msat):
|
|
if plugin.rpcversion[0] == 0 and plugin.rpcversion[1] < 12:
|
|
obj[plugin.msatfield] = msat.millisatoshis
|
|
obj['amount_msat'] = Millisatoshi(msat)
|
|
else:
|
|
obj[plugin.msatfield] = Millisatoshi(msat)
|
|
|
|
|
|
def route_get_msat(r):
|
|
return Millisatoshi(r[plugin.msatfield])
|
|
|
|
|
|
def setup_routing_fees(route, msat):
|
|
delay = plugin.cltv_final
|
|
for r in reversed(route):
|
|
route_set_msat(r, msat)
|
|
r['delay'] = delay
|
|
channels = plugin.rpc.listchannels(r['channel'])
|
|
ch = next(c for c in channels.get('channels') if c['destination'] == r['id'])
|
|
fee = Millisatoshi(ch['base_fee_millisatoshi'])
|
|
# BOLT #7 requires fee >= fee_base_msat + ( amount_to_forward * fee_proportional_millionths / 1000000 )
|
|
fee += (msat * ch['fee_per_millionth'] + 10**6 - 1) // 10**6 # integer math trick to round up
|
|
msat += fee
|
|
delay += ch['delay']
|
|
|
|
|
|
def get_channel(payload, peer_id, scid, check_state: bool = False):
|
|
if plugin.listpeerchannels:
|
|
channels = plugin.rpc.listpeerchannels(peer_id)['channels']
|
|
channel = next(c for c in channels if c.get('short_channel_id') == scid)
|
|
if check_state:
|
|
if channel['state'] != "CHANNELD_NORMAL":
|
|
raise RpcError('rebalance', payload, {'message': 'Channel %s not in state CHANNELD_NORMAL, but: %s' % (scid, channel['state'])})
|
|
if not channel['peer_connected']:
|
|
raise RpcError('rebalance', payload, {'message': 'Channel %s peer is not connected.' % scid})
|
|
return channel
|
|
peer = plugin.rpc.listpeers(peer_id).get('peers')[0]
|
|
channel = next(c for c in peer['channels'] if c.get('short_channel_id') == scid)
|
|
if check_state:
|
|
if channel['state'] != "CHANNELD_NORMAL":
|
|
raise RpcError('rebalance', payload, {'message': 'Channel %s not in state CHANNELD_NORMAL, but: %s' % (scid, channel['state'])})
|
|
if not peer['connected']:
|
|
raise RpcError('rebalance', payload, {'message': 'Channel %s peer is not connected.' % scid})
|
|
return channel
|
|
|
|
|
|
def amounts_from_scid(scid):
|
|
channels = plugin.rpc.listfunds().get('channels')
|
|
channel = next(c for c in channels if c.get('short_channel_id') == scid)
|
|
our_msat = Millisatoshi(channel['our_amount_msat'])
|
|
total_msat = Millisatoshi(channel['amount_msat'])
|
|
return our_msat, total_msat
|
|
|
|
|
|
def peer_from_scid(short_channel_id, my_node_id, payload):
|
|
channels = plugin.rpc.listchannels(short_channel_id).get('channels')
|
|
for ch in channels:
|
|
if ch['source'] == my_node_id:
|
|
return ch['destination']
|
|
raise RpcError("rebalance", payload, {'message': 'Cannot find peer for channel: ' + short_channel_id})
|
|
|
|
|
|
def get_node_alias(node_id):
|
|
node = plugin.rpc.listnodes(node_id)['nodes']
|
|
s = ""
|
|
if len(node) != 0 and 'alias' in node[0]:
|
|
s += node[0]['alias']
|
|
else:
|
|
s += node_id[0:7]
|
|
return s
|
|
|
|
|
|
def find_worst_channel(route):
|
|
if len(route) < 4:
|
|
return None
|
|
start_idx = 2
|
|
worst = route[start_idx]
|
|
worst_val = route_get_msat(route[start_idx - 1]) - route_get_msat(worst)
|
|
for i in range(start_idx + 1, len(route) - 1):
|
|
val = route_get_msat(route[i - 1]) - route_get_msat(route[i])
|
|
if val > worst_val:
|
|
worst = route[i]
|
|
worst_val = val
|
|
return worst
|
|
|
|
|
|
def cleanup(label, payload, rpc_result, error=None):
|
|
try:
|
|
plugin.rpc.delinvoice(label, 'unpaid')
|
|
except RpcError as e:
|
|
# race condition: waitsendpay timed out, but invoice get paid
|
|
if 'status is paid' in e.error.get('message', ""):
|
|
return rpc_result
|
|
|
|
if error is not None:
|
|
if isinstance(error, RpcError):
|
|
# unwrap rebalance errors as 'normal' RPC result
|
|
if error.method == "rebalance":
|
|
return {"status": "exception",
|
|
"message": error.error.get('message', "error not given")}
|
|
raise error
|
|
|
|
return rpc_result
|
|
|
|
|
|
# This function calculates the optimal rebalance amount
|
|
# based on the selected channels capacity and state.
|
|
# It will return a value that brings at least one of the channels to balance.
|
|
# It will raise an error, when this isnt possible.
|
|
#
|
|
# EXAMPLE
|
|
# |------------------- out_total -------------|
|
|
# OUT -v => |-------- out_ours -------||-- out_theirs --| => +v
|
|
#
|
|
# IN +v <= |-- in_ours --||---------- in_theirs ---------| <= -v
|
|
# |--------- in_total --------------------------|
|
|
#
|
|
# CHEAP SOLUTION: take v_min from 50/50 values
|
|
# O* vo = out_ours - (out_total/2)
|
|
# I* vi = (in_total/2) - in_ours
|
|
# return min(vo, vi)
|
|
#
|
|
# ... and cover edge cases with exceeding in/out capacity or negative values.
|
|
def calc_optimal_amount(out_ours, out_total, in_ours, in_total, payload):
|
|
out_ours, out_total = int(out_ours), int(out_total)
|
|
in_ours, in_total = int(in_ours), int(in_total)
|
|
|
|
in_theirs = in_total - in_ours
|
|
vo = int(out_ours - (out_total / 2))
|
|
vi = int((in_total / 2) - in_ours)
|
|
|
|
# cases where one option can be eliminated because it exceeds other capacity
|
|
if vo > in_theirs and vi > 0 and vi < out_ours:
|
|
return Millisatoshi(vi)
|
|
if vi > out_ours and vo > 0 and vo < in_theirs:
|
|
return Millisatoshi(vo)
|
|
|
|
# cases where one channel is still capable to bring other to balance
|
|
if vo < 0 and vi > 0 and vi < out_ours:
|
|
return Millisatoshi(vi)
|
|
if vi < 0 and vo > 0 and vo < in_theirs:
|
|
return Millisatoshi(vo)
|
|
|
|
# when both options are possible take the one with least effort
|
|
if vo > 0 and vo < in_theirs and vi > 0 and vi < out_ours:
|
|
return Millisatoshi(min(vi, vo))
|
|
|
|
raise RpcError("rebalance", payload, {'message': 'rebalancing these channels will make things worse'})
|
|
|
|
|
|
class NoRouteException(Exception):
|
|
pass
|
|
|
|
|
|
def getroute_basic(targetid, fromid, excludes, msatoshi: Millisatoshi):
|
|
try:
|
|
""" This does not make special assumptions and tries all routes
|
|
it gets. Uses less CPU and does not filter any routes.
|
|
"""
|
|
return plugin.rpc.getroute(targetid,
|
|
fromid=fromid,
|
|
exclude=excludes,
|
|
msatoshi=msatoshi,
|
|
maxhops=plugin.maxhops,
|
|
riskfactor=10, cltv=9)
|
|
except RpcError as e:
|
|
# could not find route -> change params and restart loop
|
|
if e.method == "getroute" and e.error.get('code') == 205:
|
|
raise NoRouteException
|
|
raise e
|
|
|
|
|
|
def getroute_iterative(targetid, fromid, excludes, msatoshi: Millisatoshi):
|
|
""" This searches for 'shorter and bigger pipes' first in order
|
|
to increase likelyhood of success on short timeout.
|
|
Can be useful for manual `rebalance`.
|
|
"""
|
|
try:
|
|
return plugin.rpc.getroute(targetid,
|
|
fromid=fromid,
|
|
exclude=excludes,
|
|
msatoshi=msatoshi * plugin.msatfactoridx,
|
|
maxhops=plugin.maxhopidx,
|
|
riskfactor=10, cltv=9)
|
|
except RpcError as e:
|
|
# could not find route -> change params and restart loop
|
|
if e.method == "getroute" and e.error.get('code') == 205:
|
|
# reduce _msatfactor to look for smaller channels now
|
|
plugin.msatfactoridx -= 1
|
|
if plugin.msatfactoridx < 1:
|
|
# when we reached neutral msat factor:
|
|
# increase _maxhops and restart with msatfactor
|
|
plugin.maxhopidx += 1
|
|
plugin.msatfactoridx = plugin.msatfactor
|
|
# abort if we reached maxhop limit
|
|
if plugin.maxhops > 0 and plugin.maxhopidx > plugin.maxhops:
|
|
raise NoRouteException
|
|
raise e
|
|
|
|
|
|
def getroute_switch(method_name):
|
|
switch = {
|
|
"basic": getroute_basic,
|
|
"iterative": getroute_iterative
|
|
}
|
|
return switch.get(method_name, getroute_iterative)
|
|
|
|
|
|
def waitsendpay(payment_hash, start_ts, retry_for):
|
|
while True:
|
|
try:
|
|
timeout = min(max(retry_for + start_ts - int(time.time()), 0), 10)
|
|
result = plugin.rpc.waitsendpay(payment_hash, timeout)
|
|
return result
|
|
except RpcError as e:
|
|
if e.method == "waitsendpay" and e.error.get('code') == 200:
|
|
if rebalance_stopping():
|
|
raise e
|
|
if int(time.time()) - start_ts >= retry_for:
|
|
raise e
|
|
else:
|
|
raise e
|
|
|
|
|
|
@plugin.method("rebalance")
|
|
def rebalance(plugin, outgoing_scid, incoming_scid, msatoshi: Millisatoshi = None,
|
|
retry_for: int = 60, maxfeepercent: float = 0.5,
|
|
exemptfee: Millisatoshi = Millisatoshi(5000),
|
|
getroute_method=None):
|
|
"""Rebalancing channel liquidity with circular payments.
|
|
|
|
This tool helps to move some msatoshis between your channels.
|
|
"""
|
|
if msatoshi:
|
|
msatoshi = Millisatoshi(msatoshi)
|
|
retry_for = int(retry_for)
|
|
maxfeepercent = float(maxfeepercent)
|
|
if getroute_method is None:
|
|
getroute = plugin.getroute
|
|
else:
|
|
getroute = getroute_switch(getroute_method)
|
|
exemptfee = Millisatoshi(exemptfee)
|
|
payload = {
|
|
"outgoing_scid": outgoing_scid,
|
|
"incoming_scid": incoming_scid,
|
|
"msatoshi": msatoshi,
|
|
"retry_for": retry_for,
|
|
"maxfeepercent": maxfeepercent,
|
|
"exemptfee": exemptfee
|
|
}
|
|
my_node_id = plugin.getinfo.get('id')
|
|
outgoing_node_id = peer_from_scid(outgoing_scid, my_node_id, payload)
|
|
incoming_node_id = peer_from_scid(incoming_scid, my_node_id, payload)
|
|
get_channel(payload, outgoing_node_id, outgoing_scid, True)
|
|
get_channel(payload, incoming_node_id, incoming_scid, True)
|
|
out_ours, out_total = amounts_from_scid(outgoing_scid)
|
|
in_ours, in_total = amounts_from_scid(incoming_scid)
|
|
|
|
# If amount was not given, calculate a suitable 50/50 rebalance amount
|
|
if msatoshi is None:
|
|
msatoshi = calc_optimal_amount(out_ours, out_total, in_ours, in_total, payload)
|
|
plugin.log("Estimating optimal amount %s" % msatoshi)
|
|
|
|
# Check requested amounts are selected channels
|
|
if msatoshi > out_ours or msatoshi > in_total - in_ours:
|
|
raise RpcError("rebalance", payload, {'message': 'Channel capacities too low'})
|
|
|
|
plugin.log(f"starting rebalance out_scid:{outgoing_scid} in_scid:{incoming_scid} amount:{msatoshi}", 'debug')
|
|
|
|
route_out = {'id': outgoing_node_id, 'channel': outgoing_scid, 'direction': int(not my_node_id < outgoing_node_id)}
|
|
route_in = {'id': my_node_id, 'channel': incoming_scid, 'direction': int(not incoming_node_id < my_node_id)}
|
|
start_ts = int(time.time())
|
|
label = "Rebalance-" + str(uuid.uuid4())
|
|
description = "%s to %s" % (outgoing_scid, incoming_scid)
|
|
invoice = plugin.rpc.invoice(msatoshi, label, description, retry_for + 60)
|
|
payment_hash = invoice['payment_hash']
|
|
# The requirement for payment_secret coincided with its addition to the invoice output.
|
|
payment_secret = invoice.get('payment_secret')
|
|
|
|
rpc_result = None
|
|
excludes = [my_node_id] # excude all own channels to prevent shortcuts
|
|
nodes = {} # here we store erring node counts
|
|
plugin.maxhopidx = 1 # start with short routes and increase
|
|
plugin.msatfactoridx = plugin.msatfactor # start with high capacity factor
|
|
# and decrease to reduce WIRE_TEMPORARY failures because of imbalances
|
|
|
|
# 'disable' maxhops filter if set to <= 0
|
|
# I know this is ugly, but we don't ruin the rest of the code this way
|
|
if plugin.maxhops <= 0:
|
|
plugin.maxhopidx = 20
|
|
|
|
# trace stats
|
|
count = 0
|
|
count_sendpay = 0
|
|
time_getroute = 0
|
|
time_sendpay = 0
|
|
|
|
try:
|
|
while int(time.time()) - start_ts < retry_for and not rebalance_stopping():
|
|
count += 1
|
|
try:
|
|
time_start = time.time()
|
|
r = getroute(targetid=incoming_node_id,
|
|
fromid=outgoing_node_id,
|
|
excludes=excludes,
|
|
msatoshi=msatoshi)
|
|
time_getroute += time.time() - time_start
|
|
except NoRouteException:
|
|
# no more chance for a successful getroute
|
|
rpc_result = {'status': 'error', 'message': 'No suitable routes found'}
|
|
return cleanup(label, payload, rpc_result)
|
|
except RpcError as e:
|
|
# getroute can be successful next time with different parameters
|
|
if e.method == "getroute" and e.error.get('code') == 205:
|
|
continue
|
|
else:
|
|
raise e
|
|
|
|
route_mid = r['route']
|
|
route = [route_out] + route_mid + [route_in]
|
|
setup_routing_fees(route, msatoshi)
|
|
fees = route_get_msat(route[0]) - msatoshi
|
|
|
|
# check fee and exclude worst channel the next time
|
|
# NOTE: the int(msat) casts are just a workaround for outdated pylightning versions
|
|
if fees > exemptfee and int(fees) > int(msatoshi) * maxfeepercent / 100:
|
|
worst_channel = find_worst_channel(route)
|
|
if worst_channel is None:
|
|
raise RpcError("rebalance", payload, {'message': 'Insufficient fee'})
|
|
excludes.append(worst_channel['channel'] + '/' + str(worst_channel['direction']))
|
|
continue
|
|
|
|
rpc_result = {
|
|
"sent": msatoshi + fees,
|
|
"received": msatoshi,
|
|
"fee": fees,
|
|
"hops": len(route),
|
|
"outgoing_scid": outgoing_scid,
|
|
"incoming_scid": incoming_scid,
|
|
"status": "complete",
|
|
"message": f"{msatoshi + fees} sent over {len(route)} hops to rebalance {msatoshi}",
|
|
}
|
|
midroute_str = reduce(lambda x, y: x + " -> " + y, map(lambda r: get_node_alias(r['id']), route_mid))
|
|
full_route_str = "%s -> %s -> %s -> %s" % (get_node_alias(my_node_id), get_node_alias(outgoing_node_id), midroute_str, get_node_alias(my_node_id))
|
|
plugin.log(f"Thread{get_thread_id_str()} {len(route)} hops and {fees.to_satoshi_str()} fees for {msatoshi.to_satoshi_str()} along route: {full_route_str}")
|
|
for r in route:
|
|
plugin.log(" - %s %14s %s" % (r['id'], r['channel'], route_get_msat(r)), 'debug')
|
|
|
|
time_start = time.time()
|
|
count_sendpay += 1
|
|
try:
|
|
plugin.rpc.sendpay(route, payment_hash, payment_secret=payment_secret)
|
|
result = waitsendpay(payment_hash, start_ts, retry_for)
|
|
time_sendpay += time.time() - time_start
|
|
if result.get('status') == "complete":
|
|
rpc_result["stats"] = f"running_for:{int(time.time()) - start_ts} count_getroute:{count} time_getroute:{time_getroute} time_getroute_avg:{time_getroute / count} count_sendpay:{count_sendpay} time_sendpay:{time_sendpay} time_sendpay_avg:{time_sendpay / count_sendpay}"
|
|
return cleanup(label, payload, rpc_result)
|
|
|
|
except RpcError as e:
|
|
time_sendpay += time.time() - time_start
|
|
plugin.log(f"maxhops:{plugin.maxhopidx} msatfactor:{plugin.msatfactoridx} running_for:{int(time.time()) - start_ts} count_getroute:{count} time_getroute:{time_getroute} time_getroute_avg:{time_getroute / count} count_sendpay:{count_sendpay} time_sendpay:{time_sendpay} time_sendpay_avg:{time_sendpay / count_sendpay}", 'debug')
|
|
# plugin.log(f"RpcError: {str(e)}", 'debug')
|
|
# check if we ran into the `rpc.waitsendpay` timeout
|
|
if e.method == "waitsendpay" and e.error.get('code') == 200:
|
|
raise RpcError("rebalance", payload, {'message': 'Timeout reached'})
|
|
# check if we have problems with our own channels
|
|
erring_node = e.error.get('data', {}).get('erring_node')
|
|
erring_channel = e.error.get('data', {}).get('erring_channel')
|
|
erring_direction = e.error.get('data', {}).get('erring_direction')
|
|
if erring_channel == incoming_scid:
|
|
raise RpcError("rebalance", payload, {'message': 'Error with incoming channel'})
|
|
if erring_channel == outgoing_scid:
|
|
raise RpcError("rebalance", payload, {'message': 'Error with outgoing channel'})
|
|
# exclude other erroring channels
|
|
if erring_channel is not None and erring_direction is not None:
|
|
excludes.append(erring_channel + '/' + str(erring_direction))
|
|
# count and exclude nodes that produce a lot of errors
|
|
if erring_node and plugin.erringnodes > 0:
|
|
if nodes.get(erring_node) is None:
|
|
nodes[erring_node] = 0
|
|
nodes[erring_node] += 1
|
|
if nodes[erring_node] >= plugin.erringnodes:
|
|
excludes.append(erring_node)
|
|
|
|
except Exception as e:
|
|
return cleanup(label, payload, rpc_result, e)
|
|
rpc_result = {'status': 'error', 'message': 'Timeout reached'}
|
|
return cleanup(label, payload, rpc_result)
|
|
|
|
|
|
def a_minus_b(a: Millisatoshi, b: Millisatoshi):
|
|
# a minus b, but Millisatoshi cannot be negative
|
|
return a - b if a > b else Millisatoshi(0)
|
|
|
|
|
|
def must_send(liquidity):
|
|
# liquidity is too high, must send some sats
|
|
return a_minus_b(liquidity["min"], liquidity["their"])
|
|
|
|
|
|
def should_send(liquidity):
|
|
# liquidity is a bit high, would be good to send some sats
|
|
return a_minus_b(liquidity["ideal"]["their"], liquidity["their"])
|
|
|
|
|
|
def could_send(liquidity):
|
|
# liquidity maybe a bit low, but can send some more sats, if needed
|
|
return a_minus_b(liquidity["our"], liquidity["min"])
|
|
|
|
|
|
def must_receive(liquidity):
|
|
# liquidity is too low, must receive some sats
|
|
return a_minus_b(liquidity["min"], liquidity["our"])
|
|
|
|
|
|
def should_receive(liquidity):
|
|
# liquidity is a bit low, would be good to receive some sats
|
|
return a_minus_b(liquidity["ideal"]["our"], liquidity["our"])
|
|
|
|
|
|
def could_receive(liquidity):
|
|
# liquidity maybe a bit high, but can receive some more sats, if needed
|
|
return a_minus_b(liquidity["their"], liquidity["min"])
|
|
|
|
|
|
def get_open_channels(plugin: Plugin):
|
|
result = []
|
|
if plugin.listpeerchannels:
|
|
channels = plugin.rpc.listpeerchannels()['channels']
|
|
for ch in channels:
|
|
if ch["state"] == "CHANNELD_NORMAL" and not ch["private"]:
|
|
result.append(ch)
|
|
else:
|
|
for peer in plugin.rpc.listpeers()["peers"]:
|
|
for ch in peer["channels"]:
|
|
if ch["state"] == "CHANNELD_NORMAL" and not ch["private"]:
|
|
result.append(ch)
|
|
return result
|
|
|
|
|
|
def check_liquidity_threshold(channels: list, threshold: Millisatoshi):
|
|
# check if overall rebalances can be successful with this threshold
|
|
our = sum(ch["to_us_msat"] for ch in channels)
|
|
total = sum(ch["total_msat"] for ch in channels)
|
|
required = Millisatoshi(0)
|
|
for ch in channels:
|
|
required += min(threshold, ch["total_msat"] / 2)
|
|
return required < our and required < total - our
|
|
|
|
|
|
def get_enough_liquidity_threshold(channels: list):
|
|
low = Millisatoshi(0)
|
|
biggest_channel = max(channels, key=lambda ch: ch["total_msat"])
|
|
high = biggest_channel["total_msat"] / 2
|
|
while True:
|
|
mid = (low + high) / 2
|
|
if high - low < Millisatoshi("1sat"):
|
|
break
|
|
if check_liquidity_threshold(channels, mid):
|
|
low = mid
|
|
else:
|
|
high = mid
|
|
return mid / 2
|
|
|
|
|
|
def get_ideal_ratio(channels: list, enough_liquidity: Millisatoshi):
|
|
# ideal liquidity ratio for big channels:
|
|
# small channels should have a 50/50 liquidity ratio to be usable
|
|
# and big channels can store the remaining liquidity above the threshold
|
|
assert len(channels) > 0
|
|
our = sum(ch["to_us_msat"] for ch in channels)
|
|
total = sum(ch["total_msat"] for ch in channels)
|
|
chs = list(channels) # get a copy!
|
|
while len(chs) > 0:
|
|
ratio = int(our) / int(total)
|
|
smallest_channel = min(chs, key=lambda ch: ch["total_msat"])
|
|
if smallest_channel["total_msat"] * min(ratio, 1 - ratio) > enough_liquidity:
|
|
break
|
|
min_liquidity = min(smallest_channel["total_msat"] / 2, enough_liquidity)
|
|
diff = smallest_channel["total_msat"] * ratio
|
|
diff = max(diff, min_liquidity)
|
|
diff = min(diff, smallest_channel["total_msat"] - min_liquidity)
|
|
our -= diff
|
|
total -= smallest_channel["total_msat"]
|
|
chs.remove(smallest_channel)
|
|
assert 0 <= ratio and ratio <= 1
|
|
return ratio
|
|
|
|
|
|
def feeadjust_would_be_nice():
|
|
commands = [c for c in plugin.rpc.help().get("help") if c["command"].split()[0] == "feeadjust"]
|
|
if len(commands) == 1:
|
|
msg = plugin.rpc.feeadjust()
|
|
plugin.log(f"Feeadjust succeeded: {msg}")
|
|
else:
|
|
plugin.log("The feeadjuster plugin would be useful here")
|
|
|
|
|
|
def get_max_amount(i: int, plugin: Plugin):
|
|
return max(plugin.min_amount, plugin.enough_liquidity / (4**i))
|
|
|
|
|
|
def get_max_fee(msat: Millisatoshi):
|
|
# TODO: sanity check
|
|
return (plugin.fee_base + msat * plugin.fee_ppm / 10**6) * plugin.feeratio
|
|
|
|
|
|
def get_chan(scid: str):
|
|
if plugin.listpeerchannels:
|
|
channels = plugin.rpc.listpeerchannels()['channels']
|
|
for chan in channels:
|
|
if chan.get("short_channel_id") == scid:
|
|
return chan
|
|
else:
|
|
for peer in plugin.rpc.listpeers()["peers"]:
|
|
if len(peer["channels"]) == 0:
|
|
continue
|
|
for chan in peer["channels"]:
|
|
if chan.get("short_channel_id") == scid:
|
|
return chan
|
|
|
|
|
|
def liquidity_info(channel, enough_liquidity: Millisatoshi, ideal_ratio: float):
|
|
liquidity = {
|
|
"our": channel["to_us_msat"],
|
|
"their": channel["total_msat"] - channel["to_us_msat"],
|
|
"min": min(enough_liquidity, channel["total_msat"] / 2),
|
|
"max": max(a_minus_b(channel["total_msat"], enough_liquidity), channel["total_msat"] / 2),
|
|
"ideal": {}
|
|
}
|
|
liquidity["ideal"]["our"] = min(max(channel["total_msat"] * ideal_ratio, liquidity["min"]), liquidity["max"])
|
|
liquidity["ideal"]["their"] = min(max(channel["total_msat"] * (1 - ideal_ratio), liquidity["min"]), liquidity["max"])
|
|
return liquidity
|
|
|
|
|
|
def wait_for(success, timeout: int = 60):
|
|
# cyclical lambda helper
|
|
# taken and modified from pyln-testing/pyln/testing/utils.py
|
|
start_time = time.time()
|
|
interval = 0.25
|
|
while not success():
|
|
time_left = start_time + timeout - time.time()
|
|
if time_left <= 0:
|
|
return False
|
|
time.sleep(min(interval, time_left))
|
|
interval *= 2
|
|
if interval > 5:
|
|
interval = 5
|
|
return True
|
|
|
|
|
|
def wait_for_htlcs(failed_channels: list, scids: list = None):
|
|
# HTLC settlement helper
|
|
# taken and modified from pyln-testing/pyln/testing/utils.py
|
|
result = True
|
|
peers = plugin.rpc.listpeers()['peers']
|
|
for p, peer in enumerate(peers):
|
|
pid = peer['id']
|
|
channels = []
|
|
if 'channels' in peer:
|
|
channels = peer['channels']
|
|
elif 'num_channels' in peer and peer['num_channels'] > 0:
|
|
channels = plugin.rpc.listpeerchannels(peer['id'])['channels']
|
|
for c, channel in enumerate(channels):
|
|
scid = channel.get('short_channel_id')
|
|
if scids is not None and scid not in scids:
|
|
continue
|
|
if scid in failed_channels:
|
|
result = False
|
|
continue
|
|
if 'htlcs' in channel:
|
|
lam = lambda: len(plugin.rpc.listpeers()['peers'][p]['channels'][c]['htlcs']) == 0
|
|
if plugin.listpeerchannels:
|
|
lam = lambda: len(plugin.rpc.listpeerchannels(pid)['channels'][c]['htlcs']) == 0
|
|
if not wait_for(lam):
|
|
failed_channels.append(scid)
|
|
plugin.log(f"Thread{get_thread_id_str()} timeout while waiting for htlc settlement in channel {scid}")
|
|
result = False
|
|
return result
|
|
|
|
|
|
def maybe_rebalance_pairs(ch1, ch2, failed_channels: list):
|
|
scid1 = ch1["short_channel_id"]
|
|
scid2 = ch2["short_channel_id"]
|
|
result = {"success": False, "fee_spent": Millisatoshi(0)}
|
|
if scid1 + ":" + scid2 in failed_channels:
|
|
return result
|
|
# check if HTLCs are settled
|
|
if not wait_for_htlcs(failed_channels, [scid1, scid2]):
|
|
return result
|
|
i = 0
|
|
while not rebalance_stopping():
|
|
liquidity1 = liquidity_info(ch1, plugin.enough_liquidity, plugin.ideal_ratio)
|
|
liquidity2 = liquidity_info(ch2, plugin.enough_liquidity, plugin.ideal_ratio)
|
|
amount1 = min(must_send(liquidity1), could_receive(liquidity2))
|
|
amount2 = min(should_send(liquidity1), should_receive(liquidity2))
|
|
amount3 = min(could_send(liquidity1), must_receive(liquidity2))
|
|
amount = max(amount1, amount2, amount3)
|
|
if amount < plugin.min_amount:
|
|
return result
|
|
amount = min(amount, get_max_amount(i, plugin))
|
|
maxfee = get_max_fee(amount)
|
|
plugin.log(f"Thread{get_thread_id_str()} tries to rebalance: {scid1} -> {scid2}; amount={amount.to_satoshi_str()}; maxfee={maxfee.to_satoshi_str()}")
|
|
start_ts = time.time()
|
|
try:
|
|
res = rebalance(plugin, outgoing_scid=scid1, incoming_scid=scid2,
|
|
msatoshi=amount, retry_for=1200, maxfeepercent=0,
|
|
exemptfee=maxfee)
|
|
if not res.get('status') == 'complete':
|
|
raise Exception # fall into exception handler below
|
|
except Exception:
|
|
failed_channels.append(scid1 + ":" + scid2)
|
|
# rebalance failed, let's try with a smaller amount
|
|
while (get_max_amount(i, plugin) >= amount and
|
|
get_max_amount(i, plugin) != get_max_amount(i + 1, plugin)):
|
|
i += 1
|
|
if amount > get_max_amount(i, plugin):
|
|
continue
|
|
return result
|
|
result["success"] = True
|
|
result["fee_spent"] += res["fee"]
|
|
htlc_start_ts = time.time()
|
|
# wait for settlement
|
|
htlc_success = wait_for_htlcs(failed_channels, [scid1, scid2])
|
|
current_ts = time.time()
|
|
res["elapsed_time"] = str(timedelta(seconds=current_ts - start_ts))[:-3]
|
|
res["htlc_time"] = str(timedelta(seconds=current_ts - htlc_start_ts))[:-3]
|
|
plugin.log(f"Thread{get_thread_id_str()} rebalance succeeded: {res}")
|
|
if not htlc_success:
|
|
return result
|
|
ch1 = get_chan(scid1)
|
|
assert ch1 is not None
|
|
ch2 = get_chan(scid2)
|
|
assert ch2 is not None
|
|
return result
|
|
|
|
|
|
def rebalance_pair_picker(threadid, channel_pairs: list, failed_channels: list):
|
|
plugin.threadids[threading.get_ident()] = threadid
|
|
result = {"success": False, "fee_spent": Millisatoshi(0)}
|
|
idle_count = 0
|
|
while not rebalance_stopping():
|
|
idle_count += 1
|
|
for pair in channel_pairs:
|
|
if rebalance_stopping():
|
|
return result
|
|
ch1 = pair[0]
|
|
ch2 = pair[1]
|
|
processed = pair[2]
|
|
if processed:
|
|
continue
|
|
if ch1["lock"].acquire(blocking=False):
|
|
if ch2["lock"].acquire(blocking=False):
|
|
idle_count = 0
|
|
pair[2] = True
|
|
result = maybe_rebalance_pairs(ch1, ch2, failed_channels)
|
|
ch2["lock"].release()
|
|
ch1["lock"].release()
|
|
if result["success"]:
|
|
plugin.log(f"Thread{get_thread_id_str()} restarts rebalance threads after successful rebalance")
|
|
plugin.rebalance_stop_by_thread = True
|
|
return result
|
|
unprocessed = [p for p in channel_pairs if not p[2]]
|
|
if len(unprocessed) == 0:
|
|
return result
|
|
if idle_count == 1:
|
|
plugin.log(f"Thread{get_thread_id_str()} is idle, {len(unprocessed)} possible channel pairs remained")
|
|
if idle_count > 0:
|
|
time.sleep(10)
|
|
return result
|
|
|
|
|
|
def maybe_rebalance_once(failed_channels: list):
|
|
channels = get_open_channels(plugin)
|
|
for ch in channels:
|
|
ch["lock"] = threading.Lock()
|
|
channel_pairs = []
|
|
for ch1 in channels:
|
|
for ch2 in channels:
|
|
if ch1 == ch2:
|
|
continue
|
|
channel_pairs.append([ch1, ch2, False])
|
|
|
|
plugin.log(f"Start to rebalance {len(channel_pairs)} possible channel pairs")
|
|
executor = concurrent.futures.ThreadPoolExecutor(max_workers=plugin.threads)
|
|
futures = set()
|
|
for threadid in range(plugin.threads):
|
|
futures.add(executor.submit(rebalance_pair_picker, threadid, channel_pairs, failed_channels))
|
|
result = {"success": False, "fee_spent": Millisatoshi(0)}
|
|
for future in concurrent.futures.as_completed(futures):
|
|
r2 = future.result()
|
|
if r2["success"]:
|
|
if result["success"]:
|
|
result["fee_spent"] += r2["fee_spent"]
|
|
result["success_count"] = result.get("success_count", 1) + 1
|
|
else:
|
|
result = r2
|
|
plugin.rebalance_stop_by_thread = False
|
|
return result
|
|
|
|
|
|
def feeadjuster_toggle(new_value: bool):
|
|
commands = [c for c in plugin.rpc.help().get("help") if c["command"].split()[0] == "feeadjuster-toggle"]
|
|
if len(commands) == 1:
|
|
msg = plugin.rpc.feeadjuster_toggle(new_value)
|
|
return msg["forward_event_subscription"]["previous"]
|
|
else:
|
|
return True
|
|
|
|
|
|
def refresh_parameters():
|
|
channels = get_open_channels(plugin)
|
|
plugin.enough_liquidity = get_enough_liquidity_threshold(channels)
|
|
plugin.ideal_ratio = get_ideal_ratio(channels, plugin.enough_liquidity)
|
|
plugin.log(f"Automatic rebalance is running with enough liquidity threshold: {plugin.enough_liquidity.to_satoshi_str()}, "
|
|
f"ideal liquidity ratio: {plugin.ideal_ratio * 100:.2f}%, "
|
|
f"min rebalancable amount: {plugin.min_amount.to_satoshi_str()}, "
|
|
f"feeratio: {plugin.feeratio}")
|
|
|
|
|
|
def rebalanceall_thread():
|
|
if not plugin.mutex.acquire(blocking=False):
|
|
return
|
|
try:
|
|
start_ts = time.time()
|
|
feeadjuster_state = feeadjuster_toggle(False)
|
|
plugin.log(f"Automatic rebalance started")
|
|
failed_channels = []
|
|
success = 0
|
|
fee_spent = Millisatoshi(0)
|
|
while True:
|
|
refresh_parameters()
|
|
result = maybe_rebalance_once(failed_channels)
|
|
if not result["success"] and not plugin.rebalance_stop_by_event:
|
|
break
|
|
if result["success"]:
|
|
success += result.get("success_count", 1)
|
|
fee_spent += result["fee_spent"]
|
|
plugin.rebalance_stop_by_event = False
|
|
if rebalance_stopping():
|
|
break
|
|
feeadjust_would_be_nice()
|
|
feeadjuster_toggle(feeadjuster_state)
|
|
elapsed_time = timedelta(seconds=time.time() - start_ts)
|
|
plugin.rebalanceall_msg = (f"Automatic rebalance finished: {success} successful rebalance, "
|
|
f"{fee_spent.to_satoshi_str()} fee spent, it took {str(elapsed_time)[:-3]}")
|
|
plugin.log(plugin.rebalanceall_msg)
|
|
finally:
|
|
plugin.mutex.release()
|
|
|
|
|
|
@plugin.subscribe("forward_event")
|
|
def forward_event(plugin: Plugin, forward_event: dict, **kwargs):
|
|
if not plugin.mutex.locked():
|
|
return
|
|
if forward_event["status"] == "settled":
|
|
plugin.log("Forward event restarts rebalance threads")
|
|
plugin.rebalance_stop_by_event = True
|
|
|
|
|
|
@plugin.subscribe("invoice_payment")
|
|
def invoice_payment(plugin: Plugin, invoice_payment: dict, **kwargs):
|
|
if not plugin.mutex.locked():
|
|
return
|
|
if invoice_payment.get('label').startswith("Rebalance"):
|
|
return
|
|
plugin.log("Invoice payment restarts rebalance threads")
|
|
plugin.rebalance_stop_by_event = True
|
|
|
|
|
|
@plugin.subscribe("sendpay_success")
|
|
def sendpay_success(plugin: Plugin, sendpay_success: dict, **kwargs):
|
|
if not plugin.mutex.locked():
|
|
return
|
|
my_node_id = plugin.getinfo.get('id')
|
|
if sendpay_success.get('destination') == my_node_id:
|
|
return
|
|
plugin.log("Sendpay success restarts rebalance threads")
|
|
plugin.rebalance_stop_by_event = True
|
|
|
|
|
|
@plugin.subscribe("channel_state_changed")
|
|
def channel_state_changed(plugin: Plugin, channel_state_changed: dict, **kwargs):
|
|
if not plugin.mutex.locked():
|
|
return
|
|
if channel_state_changed.get('old_state') != 'CHANNELD_NORMAL' and channel_state_changed.get('new_state') != 'CHANNELD_NORMAL':
|
|
return
|
|
plugin.log("Channel state changed restarts rebalance threads")
|
|
plugin.rebalance_stop_by_event = True
|
|
|
|
|
|
@plugin.method("rebalanceall")
|
|
def rebalanceall(plugin: Plugin, min_amount: Millisatoshi = Millisatoshi("50000sat"), feeratio: float = 0.5):
|
|
"""Rebalance all unbalanced channels if possible for a very low fee.
|
|
Default minimum rebalancable amount is 50000sat. Default feeratio = 0.5, half of our node's default fee.
|
|
To be economical, it tries to fix the liquidity cheaper than it can be ruined by transaction forwards.
|
|
It may run for a long time (hours) in the background, but can be stopped with the rebalancestop method.
|
|
"""
|
|
# some early checks before we start the async thread
|
|
if plugin.mutex.locked():
|
|
return {"message": "Rebalance is already running, this may take a while. To stop it use the cli method 'rebalancestop'."}
|
|
channels = get_open_channels(plugin)
|
|
if len(channels) <= 1:
|
|
return {"message": "Error: Not enough open channels to rebalance anything"}
|
|
our = sum(ch["to_us_msat"] for ch in channels)
|
|
total = sum(ch["total_msat"] for ch in channels)
|
|
min_amount = Millisatoshi(min_amount)
|
|
if total - our < min_amount or our < min_amount:
|
|
return {"message": "Error: Not enough liquidity to rebalance anything"}
|
|
|
|
# param parsing ensure correct type
|
|
plugin.feeratio = float(feeratio)
|
|
plugin.min_amount = min_amount
|
|
|
|
# run the job
|
|
t = threading.Thread(target=rebalanceall_thread, args=())
|
|
t.start()
|
|
return {"message": f"Rebalance started with min rebalancable amount: {plugin.min_amount}, feeratio: {plugin.feeratio}"}
|
|
|
|
|
|
@plugin.method("rebalancestop")
|
|
def rebalancestop(plugin: Plugin):
|
|
"""It stops the ongoing rebalanceall.
|
|
"""
|
|
if not plugin.mutex.locked():
|
|
if plugin.rebalanceall_msg is None:
|
|
return {"message": "No rebalance is running, nothing to stop."}
|
|
return {"message": f"No rebalance is running, nothing to stop. "
|
|
f"Last 'rebalanceall' gave: {plugin.rebalanceall_msg}"}
|
|
start_ts = time.time()
|
|
plugin.rebalance_stop_by_user = True
|
|
plugin.mutex.acquire(blocking=True)
|
|
plugin.rebalance_stop_by_user = False
|
|
plugin.mutex.release()
|
|
elapsed_time = timedelta(seconds=time.time() - start_ts)
|
|
plugin.log(f"Automatic rebalance stopped in {str(elapsed_time)[:-3]}")
|
|
return {"message": plugin.rebalanceall_msg}
|
|
|
|
|
|
def health_score(liquidity):
|
|
if int(liquidity["ideal"]["our"]) == 0 or int(liquidity["ideal"]["their"]) == 0 or int(liquidity["min"]) == 0:
|
|
return 0
|
|
score_our = int(liquidity["our"]) / int(liquidity["ideal"]["our"])
|
|
score_their = int(liquidity["their"]) / int(liquidity["ideal"]["their"])
|
|
# distance from ideal liquidity (between 50 and 100)
|
|
score = min(score_our, score_their) * 50 + 50
|
|
coefficient_our = int(liquidity["our"]) / int(liquidity["min"])
|
|
coefficient_their = int(liquidity["their"]) / int(liquidity["min"])
|
|
# distance from minimal liquidity as a coefficient (between 0 and 1)
|
|
coefficient = min(coefficient_our, coefficient_their, 1)
|
|
return score * coefficient
|
|
|
|
|
|
def get_avg_forward_fees(intervals):
|
|
now = time.time()
|
|
max_interval = max(intervals)
|
|
total = [0] * len(intervals)
|
|
fees = [0] * len(intervals)
|
|
res = [0] * len(intervals)
|
|
all_forwards = list(filter(lambda fwd: fwd.get("status") == "settled"
|
|
and fwd.get("resolved_time", 0)
|
|
+ max_interval * 60 * 60 * 24 > now,
|
|
plugin.rpc.listforwards()["forwards"]))
|
|
|
|
# build intermediate result per interval
|
|
for fwd in all_forwards:
|
|
for idx, i in enumerate(intervals):
|
|
if now > fwd["resolved_time"] + i * 60 * 60 * 24:
|
|
continue
|
|
total[idx] += fwd["out_msat"]
|
|
fees[idx] += fwd["fee_msat"]
|
|
|
|
# return average intermediate
|
|
for idx, i in enumerate(res):
|
|
if int(total[idx]) > 0:
|
|
res[idx] = fees[idx] / total[idx] * 10**6
|
|
else:
|
|
res[idx] = 0
|
|
return res
|
|
|
|
|
|
@plugin.method("rebalancereport")
|
|
def rebalancereport(plugin: Plugin):
|
|
"""Show information about rebalance
|
|
"""
|
|
res = {}
|
|
res["rebalanceall_is_running"] = plugin.mutex.locked()
|
|
res["getroute_method"] = plugin.getroute.__name__
|
|
res["maxhops_threshold"] = plugin.maxhops
|
|
res["msatfactor"] = plugin.msatfactor
|
|
res["erringnodes_threshold"] = plugin.erringnodes
|
|
channels = get_open_channels(plugin)
|
|
health_percent = 0.0
|
|
if len(channels) > 1:
|
|
enough_liquidity = get_enough_liquidity_threshold(channels)
|
|
ideal_ratio = get_ideal_ratio(channels, enough_liquidity)
|
|
res["enough_liquidity_threshold"] = enough_liquidity
|
|
res["ideal_liquidity_ratio"] = f"{ideal_ratio * 100:.2f}%"
|
|
for ch in channels:
|
|
liquidity = liquidity_info(ch, enough_liquidity, ideal_ratio)
|
|
health_percent += health_score(liquidity) * int(ch["total_msat"])
|
|
health_percent /= int(sum(ch["total_msat"] for ch in channels))
|
|
else:
|
|
res["enough_liquidity_threshold"] = Millisatoshi(0)
|
|
res["ideal_liquidity_ratio"] = "0%"
|
|
res["liquidity_health"] = f"{health_percent:.2f}%"
|
|
invoices = plugin.rpc.listinvoices()['invoices']
|
|
rebalances = [i for i in invoices if i.get('status') == 'paid' and i.get('label').startswith("Rebalance")]
|
|
total_fee = Millisatoshi(0)
|
|
total_amount = Millisatoshi(0)
|
|
res["total_successful_rebalances"] = len(rebalances)
|
|
|
|
# iterate if cln doesn't already support `status` on listpays since v0.10.2
|
|
if plugin.rpcversion[0] == 0 and plugin.rpcversion[1] <= 10 and plugin.rpcversion[2] < 2:
|
|
pays = plugin.rpc.listpays()["pays"]
|
|
pays = [p for p in pays if p.get('status') == 'complete']
|
|
else:
|
|
pays = plugin.rpc.listpays(status="complete")["pays"]
|
|
|
|
for r in rebalances:
|
|
try:
|
|
pay = next(p for p in pays if p["payment_hash"] == r["payment_hash"])
|
|
total_amount += pay["amount_msat"]
|
|
total_fee += pay["amount_sent_msat"] - pay["amount_msat"]
|
|
except Exception:
|
|
res["total_successful_rebalances"] -= 1
|
|
res["total_rebalanced_amount"] = total_amount
|
|
res["total_rebalance_fee"] = total_fee
|
|
if total_amount > Millisatoshi(0):
|
|
res["average_rebalance_fee_ppm"] = round(total_fee / total_amount * 10**6, 2)
|
|
else:
|
|
res["average_rebalance_fee_ppm"] = 0
|
|
|
|
avg_forward_fees = get_avg_forward_fees([1, 7, 30])
|
|
res['average_forward_fee_ppm_1d'] = avg_forward_fees[0]
|
|
res['average_forward_fee_ppm_7d'] = avg_forward_fees[1]
|
|
res['average_forward_fee_ppm_30d'] = avg_forward_fees[2]
|
|
|
|
return res
|
|
|
|
|
|
@plugin.init()
|
|
def init(options, configuration, plugin):
|
|
rpchelp = plugin.rpc.help().get('help')
|
|
# detect if server cli has moved `listpeers.channels[]` to `listpeerchannels`
|
|
# See https://github.com/ElementsProject/lightning/pull/5825
|
|
# TODO: replace by rpc version check once v23 is released
|
|
plugin.listpeerchannels = False
|
|
if len([c for c in rpchelp if c["command"].startswith("listpeerchannels ")]) != 0:
|
|
plugin.listpeerchannels = True
|
|
|
|
# do all the stuff that needs to be done just once ...
|
|
plugin.getinfo = plugin.rpc.getinfo()
|
|
plugin.rpcversion = cln_parse_rpcversion(plugin.getinfo.get('version'))
|
|
config = plugin.rpc.listconfigs()
|
|
plugin.cltv_final = config.get("cltv-final")
|
|
plugin.fee_base = Millisatoshi(config.get("fee-base"))
|
|
plugin.fee_ppm = config.get("fee-per-satoshi")
|
|
plugin.mutex = threading.Lock()
|
|
plugin.maxhops = int(options.get("rebalance-maxhops"))
|
|
plugin.msatfactor = float(options.get("rebalance-msatfactor"))
|
|
plugin.erringnodes = int(options.get("rebalance-erringnodes"))
|
|
plugin.threads = int(options.get("rebalance-threads"))
|
|
plugin.getroute = getroute_switch(options.get("rebalance-getroute"))
|
|
plugin.rebalanceall_msg = None
|
|
|
|
# use getroute amount_msat/msatoshi field depending on version
|
|
plugin.msatfield = 'amount_msat'
|
|
if plugin.rpcversion[0] == 0 and plugin.rpcversion[1] < 12:
|
|
plugin.msatfield = 'msatoshi'
|
|
|
|
plugin.log(f"Plugin rebalance initialized with {plugin.fee_base.to_satoshi_str()} base / {plugin.fee_ppm} ppm fee "
|
|
f"cltv_final:{plugin.cltv_final} "
|
|
f"maxhops:{plugin.maxhops} "
|
|
f"msatfactor:{plugin.msatfactor} "
|
|
f"erringnodes:{plugin.erringnodes} "
|
|
f"getroute:{plugin.getroute.__name__} "
|
|
f"threads:{plugin.threads} ")
|
|
|
|
|
|
plugin.add_option(
|
|
"rebalance-getroute",
|
|
"iterative",
|
|
"Getroute method for route search can be 'basic' or 'iterative'."
|
|
"'basic': Tries all routes sequentially. "
|
|
"'iterative': Tries shorter and bigger routes first.",
|
|
"string"
|
|
)
|
|
plugin.add_option(
|
|
"rebalance-maxhops",
|
|
"5",
|
|
"Maximum number of hops for `getroute` call. Set to 0 to disable. "
|
|
"Note: Two hops are added for own nodes input and output channel. "
|
|
"Note: Routes with a 8 or more hops have less than 3% success rate.",
|
|
"string"
|
|
)
|
|
|
|
plugin.add_option(
|
|
"rebalance-msatfactor",
|
|
"4",
|
|
"Will instruct `getroute` call to use higher requested capacity first. "
|
|
"Note: This will decrease to 1 when no routes can be found.",
|
|
"string"
|
|
)
|
|
|
|
plugin.add_option(
|
|
"rebalance-erringnodes",
|
|
"5",
|
|
"Exclude nodes from routing that raised N or more errors. "
|
|
"Note: Use 0 to disable.",
|
|
"string"
|
|
)
|
|
|
|
plugin.add_option(
|
|
"rebalance-threads",
|
|
"8",
|
|
"Number of threads used parallelly by `rebalanceall` "
|
|
"Higher numbers increase speed and CPU consumption",
|
|
"string"
|
|
)
|
|
|
|
plugin.run()
|