mirror of
https://github.com/aljazceru/plugins.git
synced 2026-01-05 06:14:21 +01:00
Move jitrebalance, feeadjuster, noise to Unmaintained directory
This commit is contained in:
24
Unmaintained/feeadjuster/README.md
Normal file
24
Unmaintained/feeadjuster/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Fee Adjuster
|
||||
|
||||
This plugin dynamically adjusts fees according to channel balances. The default behaviour is to automatically adjust fees at startup
|
||||
and following forwarding events. There is a threshold for balance deltas that must be crossed before an update is triggered. It can
|
||||
also set the max htlc for channels according to available liquidity. This may reduce transaction failures but it will also reveal
|
||||
information about what the current channel balance is.
|
||||
|
||||
## Options
|
||||
|
||||
- `feeadjuster-deactivate-fuzz` boolean (default `False`) deactivates update threshold randomization and hysterisis
|
||||
- `feeadjuster-deactivate-fee-update` boolean (default `False`) deactivates automatic fee updates for forward events
|
||||
- `feeadjuster-threshold` default 0.05 - Relative channel balance delta at which to trigger an update. Default 0.05 means 5%. Note: it's also fuzzed by 1.5%.
|
||||
- `feeadjuster-threshold-abs` default 0.001btc - Absolute channel balance delta at which to always trigger an update. Note: it's also fuzzed by 1.5%.
|
||||
- `feeadjuster-enough-liquidity` default 0msat (turned off) - Beyond this liquidity do not adjust fees.
|
||||
This also modifies the fee curve to achieve having this amount of liquidity.
|
||||
- `feeadjuster-adjustment-method` Adjustment method to calculate channel fee. Can be 'default', 'soft' for less difference or 'hard' for higher difference.
|
||||
- `feeadjuster-imbalance` default 0.5 (always acts) - Ratio at which channel imbalance the feeadjuster should start acting. Set higher or lower values to
|
||||
limit feeadjuster's activity to more imbalanced channels. E.g. 0.3 for '70/30'% or 0.6 for '40/60'%.
|
||||
- `feeadjuster-feestrategy` Sets the per channel fee selection strategy. Can be 'global' (default) to use global config or default values, or 'median' to use
|
||||
the median fees from peers of peer.
|
||||
- `feeadjuster-median-multiplier` Sets the factor with which the median fee is multiplied if using the fee strategy
|
||||
'median'. This allows over- or underbidding other nodes by a constant factor (default: 1.0).
|
||||
- `feeadjuster-max-htlc-steps` Default 0 (turned off). Sets the number of max htlc adjustment steps. If our local channel balance drops below a step level
|
||||
it will reduce the max htlc to that level, which can reduce local routing channel failures. A value of 0 disables the stepping.
|
||||
23
Unmaintained/feeadjuster/clnutils.py
Normal file
23
Unmaintained/feeadjuster/clnutils.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import re
|
||||
|
||||
|
||||
def cln_parse_rpcversion(string):
|
||||
"""
|
||||
Parse cln version string to determine RPC version.
|
||||
|
||||
cln switched from 'semver' alike `major.minor.sub[rcX][-mod]`
|
||||
to ubuntu style with version 22.11 `yy.mm[.patch][-mod]`
|
||||
make sure we can read all of them for (the next 80 years).
|
||||
"""
|
||||
rpcversion = string
|
||||
if rpcversion.startswith('v'): # strip leading 'v'
|
||||
rpcversion = rpcversion[1:]
|
||||
if rpcversion.find('-') != -1: # strip mods
|
||||
rpcversion = rpcversion[:rpcversion.find('-')]
|
||||
if re.search('.*(rc[\\d]*)$', rpcversion): # strip release candidates
|
||||
rpcversion = rpcversion[:rpcversion.find('rc')]
|
||||
if rpcversion.count('.') == 1: # imply patch version 0 if not given
|
||||
rpcversion = rpcversion + '.0'
|
||||
|
||||
# split and convert numeric string parts to actual integers
|
||||
return list(map(int, rpcversion.split('.')))
|
||||
443
Unmaintained/feeadjuster/feeadjuster.py
Executable file
443
Unmaintained/feeadjuster/feeadjuster.py
Executable file
@@ -0,0 +1,443 @@
|
||||
#!/usr/bin/env python3
|
||||
import random
|
||||
import statistics
|
||||
import time
|
||||
import math
|
||||
from clnutils import cln_parse_rpcversion
|
||||
from pyln.client import Plugin, Millisatoshi, RpcError
|
||||
from threading import Lock
|
||||
|
||||
|
||||
plugin = Plugin()
|
||||
# Our amount and the total amount in each of our channel, indexed by scid
|
||||
plugin.adj_balances = {}
|
||||
# Cache to avoid loads of RPC calls
|
||||
plugin.our_node_id = None
|
||||
plugin.peerchannels = None
|
||||
plugin.channels = None
|
||||
# Users can configure this
|
||||
plugin.update_threshold = 0.05
|
||||
# forward_event must wait for init
|
||||
plugin.mutex = Lock()
|
||||
plugin.mutex.acquire()
|
||||
|
||||
|
||||
def get_adjusted_percentage(plugin: Plugin, scid: str):
|
||||
"""
|
||||
For big channels, there may be a wide range where the liquidity is just okay.
|
||||
Note: if big_enough_liquidity is greater than {total} * 2
|
||||
then percentage is actually {our} / {total}, as it was before
|
||||
"""
|
||||
channel = plugin.adj_balances[scid]
|
||||
if plugin.big_enough_liquidity == Millisatoshi(0):
|
||||
return channel["our"] / channel["total"]
|
||||
min_liquidity = min(channel["total"] / 2, int(plugin.big_enough_liquidity))
|
||||
theirs = channel["total"] - channel["our"]
|
||||
if channel["our"] >= min_liquidity and theirs >= min_liquidity:
|
||||
# the liquidity is just okay
|
||||
return 0.5
|
||||
if channel["our"] < min_liquidity:
|
||||
# our liquidity is too low
|
||||
return channel["our"] / min_liquidity / 2
|
||||
# their liquidity is too low
|
||||
return (min_liquidity - theirs) / min_liquidity / 2 + 0.5
|
||||
|
||||
|
||||
def get_ratio_soft(our_percentage):
|
||||
"""
|
||||
Basic algorithm: lesser difference than default
|
||||
"""
|
||||
return 10**(0.5 - our_percentage)
|
||||
|
||||
|
||||
def get_ratio(our_percentage):
|
||||
"""
|
||||
Basic algorithm: the farther we are from the optimal case, the more we
|
||||
bump/lower.
|
||||
"""
|
||||
return 50**(0.5 - our_percentage)
|
||||
|
||||
|
||||
def get_ratio_hard(our_percentage):
|
||||
"""
|
||||
Return value is between 0 and 20: 0 -> 20; 0.5 -> 1; 1 -> 0
|
||||
"""
|
||||
return 100**(0.5 - our_percentage) * (1 - our_percentage) * 2
|
||||
|
||||
|
||||
def get_peerchannels(plugin: Plugin):
|
||||
""" Helper to reconstruct `listpeerchannels` for older CLN versions """
|
||||
# first the good case
|
||||
if plugin.rpcversion[0] > 23 or plugin.rpcversion[0] == 23 and plugin.rpcversion[1] >= 2:
|
||||
return plugin.rpc.listpeerchannels()["channels"]
|
||||
# now the workaround
|
||||
channels = []
|
||||
peers = plugin.rpc.listpeers()['peers']
|
||||
for peer in peers:
|
||||
newchans = peer['channels']
|
||||
for ch in newchans:
|
||||
ch['peer_id'] = peer['id'] # all we need is to set the 'peer_id'
|
||||
channels.extend(newchans)
|
||||
return channels
|
||||
|
||||
|
||||
def get_peer_id_for_scid(plugin: Plugin, scid: str):
|
||||
for ch in plugin.peerchannels:
|
||||
if ch.get('short_channel_id') == scid:
|
||||
return ch['peer_id']
|
||||
return None
|
||||
|
||||
|
||||
def get_peerchannel(plugin: Plugin, scid: str):
|
||||
for ch in plugin.peerchannels:
|
||||
if ch.get("short_channel_id") == scid:
|
||||
return ch
|
||||
return None
|
||||
|
||||
|
||||
def get_chan_fees(plugin: Plugin, scid: str):
|
||||
channel = get_peerchannel(plugin, scid)
|
||||
assert channel is not None
|
||||
return {"base": channel["fee_base_msat"], "ppm": channel["fee_proportional_millionths"]}
|
||||
|
||||
|
||||
def get_fees_global(plugin: Plugin, scid: str):
|
||||
return {"base": plugin.adj_basefee, "ppm": plugin.adj_ppmfee}
|
||||
|
||||
|
||||
def get_fees_median(plugin: Plugin, scid: str):
|
||||
""" Median fees from peers or peer.
|
||||
|
||||
The assumption is that our node competes in fees to other peers of a peer.
|
||||
"""
|
||||
peer_id = get_peer_id_for_scid(plugin, scid)
|
||||
assert peer_id is not None
|
||||
if plugin.listchannels_by_dst:
|
||||
plugin.channels = plugin.rpc.call("listchannels",
|
||||
{"destination": peer_id})['channels']
|
||||
channels_to_peer = [ch for ch in plugin.channels
|
||||
if ch['destination'] == peer_id
|
||||
and ch['source'] != plugin.our_node_id]
|
||||
if len(channels_to_peer) == 0:
|
||||
return None
|
||||
# fees > ~5000 (base and ppm) are currently about top 2% of network fee extremists
|
||||
fees_ppm = [ch['fee_per_millionth'] for ch in channels_to_peer if 0 < ch['fee_per_millionth'] < 5000]
|
||||
fees_base = [ch['base_fee_millisatoshi'] for ch in channels_to_peer if 0 < ch['base_fee_millisatoshi'] < 5000]
|
||||
|
||||
# if lists are emtpy use default values, otherwise statistics.median will fail.
|
||||
if len(fees_ppm) == 0:
|
||||
fees_ppm = [int(plugin.adj_ppmfee / plugin.median_multiplier)]
|
||||
if len(fees_base) == 0:
|
||||
fees_base = [int(plugin.adj_basefee / plugin.median_multiplier)]
|
||||
return {"base": statistics.median(fees_base) * plugin.median_multiplier,
|
||||
"ppm": statistics.median(fees_ppm) * plugin.median_multiplier}
|
||||
|
||||
|
||||
def setchannelfee(plugin: Plugin, scid: str, base: int, ppm: int, min_htlc: int = None, max_htlc: int = None):
|
||||
fees = get_chan_fees(plugin, scid)
|
||||
if fees is None or base == fees['base'] and ppm == fees['ppm']:
|
||||
return False
|
||||
try:
|
||||
plugin.rpc.setchannel(scid, base, ppm, min_htlc, max_htlc)
|
||||
return True
|
||||
except RpcError as e:
|
||||
plugin.log(f"Could not adjust fees for channel {scid}: '{e}'", level="error")
|
||||
return False
|
||||
|
||||
|
||||
def significant_update(plugin: Plugin, scid: str):
|
||||
channel = plugin.adj_balances[scid]
|
||||
last_liquidity = channel.get("last_liquidity")
|
||||
if last_liquidity is None:
|
||||
return True
|
||||
# Only update on substantial balance moves to avoid flooding, and add
|
||||
# some pseudo-randomness to avoid too easy channel balance probing
|
||||
update_threshold = plugin.update_threshold
|
||||
update_threshold_abs = int(plugin.update_threshold_abs)
|
||||
if not plugin.deactivate_fuzz:
|
||||
update_threshold += random.uniform(-0.015, 0.015)
|
||||
update_threshold_abs += update_threshold_abs * random.uniform(-0.015, 0.015)
|
||||
last_percentage = last_liquidity / channel["total"]
|
||||
percentage = channel["our"] / channel["total"]
|
||||
if (abs(last_percentage - percentage) > update_threshold
|
||||
or abs(last_liquidity - channel["our"]) > update_threshold_abs):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def maybe_adjust_fees(plugin: Plugin, scids: list):
|
||||
channels_adjusted = 0
|
||||
for scid in scids:
|
||||
our = plugin.adj_balances[scid]["our"]
|
||||
total = plugin.adj_balances[scid]["total"]
|
||||
percentage = our / total
|
||||
base = int(plugin.adj_basefee)
|
||||
ppm = int(plugin.adj_ppmfee)
|
||||
|
||||
# select ideal values per channel
|
||||
fees = plugin.fee_strategy(plugin, scid)
|
||||
if fees is not None:
|
||||
base = int(fees['base'])
|
||||
ppm = int(fees['ppm'])
|
||||
|
||||
# reset to normal fees if imbalance is not high enough
|
||||
if (percentage > plugin.imbalance and percentage < 1 - plugin.imbalance):
|
||||
if setchannelfee(plugin, scid, base, ppm):
|
||||
plugin.log(f"Set default fees as imbalance is too low for {scid}: ppm {ppm} base {base}msat")
|
||||
plugin.adj_balances[scid]["last_liquidity"] = our
|
||||
channels_adjusted += 1
|
||||
continue
|
||||
|
||||
if not significant_update(plugin, scid):
|
||||
continue
|
||||
|
||||
percentage = get_adjusted_percentage(plugin, scid)
|
||||
assert 0 <= percentage and percentage <= 1
|
||||
ratio = plugin.get_ratio(percentage)
|
||||
if plugin.max_htlc_steps >= 1:
|
||||
max_htlc = int(total * math.ceil(plugin.max_htlc_steps * percentage) / plugin.max_htlc_steps)
|
||||
else:
|
||||
max_htlc = None
|
||||
if setchannelfee(plugin, scid, base, int(ppm * ratio), None, max_htlc):
|
||||
plugin.log(f"Adjusted fees of {scid} with a ratio of {ratio}: ppm {int(ppm * ratio)} base {base}msat max_htlc {max_htlc}")
|
||||
plugin.adj_balances[scid]["last_liquidity"] = our
|
||||
channels_adjusted += 1
|
||||
return channels_adjusted
|
||||
|
||||
|
||||
def maybe_add_new_balances(plugin: Plugin, scids: list):
|
||||
for scid in scids:
|
||||
if scid not in plugin.adj_balances:
|
||||
chan = get_peerchannel(plugin, scid)
|
||||
assert chan is not None
|
||||
plugin.adj_balances[scid] = {
|
||||
"our": int(chan["to_us_msat"]),
|
||||
"total": int(chan["total_msat"])
|
||||
}
|
||||
|
||||
|
||||
@plugin.subscribe("forward_event")
|
||||
def forward_event(plugin: Plugin, forward_event: dict, **kwargs):
|
||||
if not plugin.forward_event_subscription:
|
||||
return
|
||||
plugin.mutex.acquire(blocking=True)
|
||||
plugin.peerchannels = get_peerchannels(plugin)
|
||||
if plugin.fee_strategy == get_fees_median and not plugin.listchannels_by_dst:
|
||||
plugin.channels = plugin.rpc.listchannels()['channels']
|
||||
if forward_event["status"] == "settled":
|
||||
in_scid = forward_event["in_channel"]
|
||||
out_scid = forward_event["out_channel"]
|
||||
maybe_add_new_balances(plugin, [in_scid, out_scid])
|
||||
|
||||
if plugin.rpcversion[0] == 0 and plugin.rpcversion[1] < 12:
|
||||
plugin.adj_balances[in_scid]["our"] += int(Millisatoshi(forward_event["in_msatoshi"]))
|
||||
plugin.adj_balances[out_scid]["our"] -= int(Millisatoshi(forward_event["out_msatoshi"]))
|
||||
else:
|
||||
plugin.adj_balances[in_scid]["our"] += int(Millisatoshi(forward_event["in_msat"]))
|
||||
plugin.adj_balances[out_scid]["our"] -= int(Millisatoshi(forward_event["out_msat"]))
|
||||
|
||||
try:
|
||||
# Pseudo-randomly add some hysterisis to the update
|
||||
if not plugin.deactivate_fuzz and random.randint(0, 9) == 9:
|
||||
time.sleep(random.randint(0, 5))
|
||||
maybe_adjust_fees(plugin, [in_scid, out_scid])
|
||||
except Exception as e:
|
||||
plugin.log("Adjusting fees: " + str(e), level="error")
|
||||
plugin.mutex.release()
|
||||
|
||||
|
||||
@plugin.method("feeadjust")
|
||||
def feeadjust(plugin: Plugin, scid: str = None):
|
||||
"""Adjust fees for all channels (default) or just a given `scid`.
|
||||
|
||||
This method is automatically called in plugin init, or can be called manually after a successful payment.
|
||||
Otherwise, the plugin keeps the fees up-to-date.
|
||||
|
||||
To stop setting the channels with a list of nodes place a file called `feeadjuster-exclude.list` in the
|
||||
lightningd data directory with a simple line-by-line list of pubkeys.
|
||||
"""
|
||||
plugin.mutex.acquire(blocking=True)
|
||||
plugin.peerchannels = get_peerchannels(plugin)
|
||||
if plugin.fee_strategy == get_fees_median and not plugin.listchannels_by_dst:
|
||||
plugin.channels = plugin.rpc.listchannels()['channels']
|
||||
channels_adjusted = 0
|
||||
try:
|
||||
with open('feeadjuster-exclude.list') as file:
|
||||
exclude_list = [l.rstrip("\n") for l in file]
|
||||
print("Excluding the channels with the nodes:", exclude_list)
|
||||
except FileNotFoundError:
|
||||
exclude_list = []
|
||||
print("There is no feeadjuster-exclude.list given, applying the options to the channels with all peers.")
|
||||
for chan in plugin.peerchannels:
|
||||
if chan["peer_id"] in exclude_list:
|
||||
continue
|
||||
if chan["state"] == "CHANNELD_NORMAL":
|
||||
_scid = chan.get("short_channel_id")
|
||||
if scid is not None and scid != _scid:
|
||||
continue
|
||||
plugin.adj_balances[_scid] = {
|
||||
"our": int(chan["to_us_msat"]),
|
||||
"total": int(chan["total_msat"])
|
||||
}
|
||||
channels_adjusted += maybe_adjust_fees(plugin, [_scid])
|
||||
msg = f"{channels_adjusted} channel(s) adjusted"
|
||||
plugin.log(msg)
|
||||
plugin.mutex.release()
|
||||
return msg
|
||||
|
||||
|
||||
@plugin.method("feeadjuster-toggle")
|
||||
def feeadjuster_toggle(plugin: Plugin, value: bool = None):
|
||||
"""Activates/Deactivates automatic fee updates for forward events.
|
||||
|
||||
The status will be set to value.
|
||||
"""
|
||||
msg = {"forward_event_subscription": {"previous": plugin.forward_event_subscription}}
|
||||
if value is None:
|
||||
plugin.forward_event_subscription = not plugin.forward_event_subscription
|
||||
else:
|
||||
plugin.forward_event_subscription = bool(value)
|
||||
msg["forward_event_subscription"]["current"] = plugin.forward_event_subscription
|
||||
return msg
|
||||
|
||||
|
||||
@plugin.init()
|
||||
def init(options: dict, configuration: dict, plugin: Plugin, **kwargs):
|
||||
# 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'))
|
||||
plugin.our_node_id = plugin.getinfo["id"]
|
||||
plugin.deactivate_fuzz = options.get("feeadjuster-deactivate-fuzz")
|
||||
plugin.forward_event_subscription = not options.get("feeadjuster-deactivate-fee-update")
|
||||
plugin.update_threshold = float(options.get("feeadjuster-threshold"))
|
||||
plugin.update_threshold_abs = Millisatoshi(options.get("feeadjuster-threshold-abs"))
|
||||
plugin.big_enough_liquidity = Millisatoshi(options.get("feeadjuster-enough-liquidity"))
|
||||
plugin.imbalance = float(options.get("feeadjuster-imbalance"))
|
||||
plugin.max_htlc_steps = int(options.get("feeadjuster-max-htlc-steps"))
|
||||
adjustment_switch = {
|
||||
"soft": get_ratio_soft,
|
||||
"hard": get_ratio_hard,
|
||||
"default": get_ratio
|
||||
}
|
||||
plugin.get_ratio = adjustment_switch.get(options.get("feeadjuster-adjustment-method"), get_ratio)
|
||||
fee_strategy_switch = {
|
||||
"global": get_fees_global,
|
||||
"median": get_fees_median
|
||||
}
|
||||
plugin.fee_strategy = fee_strategy_switch.get(options.get("feeadjuster-feestrategy"), get_fees_global)
|
||||
plugin.median_multiplier = float(options.get("feeadjuster-median-multiplier"))
|
||||
config = plugin.rpc.listconfigs()
|
||||
plugin.adj_basefee = config["fee-base"]
|
||||
plugin.adj_ppmfee = config["fee-per-satoshi"]
|
||||
|
||||
# normalize the imbalance percentage value to 0%-50%
|
||||
if plugin.imbalance < 0 or plugin.imbalance > 1:
|
||||
raise ValueError("feeadjuster-imbalance must be between 0 and 1.")
|
||||
if plugin.imbalance > 0.5:
|
||||
plugin.imbalance = 1 - plugin.imbalance
|
||||
|
||||
# detect if server supports the new listchannels by `destination` (#4614)
|
||||
plugin.listchannels_by_dst = False
|
||||
rpchelp = plugin.rpc.help().get('help')
|
||||
if len([c for c in rpchelp if c["command"].startswith("listchannels ")
|
||||
and "destination" in c["command"]]) == 1:
|
||||
plugin.listchannels_by_dst = True
|
||||
|
||||
# Detect if server supports new 'setchannel' command over setchannelfee.
|
||||
# If not, make plugin.rpc.setchannel a 'symlink' to setchannelfee
|
||||
if len([c for c in rpchelp if c["command"].startswith("setchannel ")]) == 0:
|
||||
plugin.rpc.setchannel = plugin.rpc.setchannelfee
|
||||
|
||||
plugin.log(f"Plugin feeadjuster initialized "
|
||||
f"({plugin.adj_basefee} base / {plugin.adj_ppmfee} ppm) with an "
|
||||
f"imbalance of {int(100 * plugin.imbalance)}%/{int(100 * ( 1 - plugin.imbalance))}%, "
|
||||
f"update_threshold: {int(100 * plugin.update_threshold)}%, "
|
||||
f"update_threshold_abs: {plugin.update_threshold_abs}, "
|
||||
f"enough_liquidity: {plugin.big_enough_liquidity}, "
|
||||
f"deactivate_fuzz: {plugin.deactivate_fuzz}, "
|
||||
f"forward_event_subscription: {plugin.forward_event_subscription}, "
|
||||
f"adjustment_method: {plugin.get_ratio.__name__}, "
|
||||
f"fee_strategy: {plugin.fee_strategy.__name__}, "
|
||||
f"listchannels_by_dst: {plugin.listchannels_by_dst},"
|
||||
f"max_htlc_steps: {plugin.max_htlc_steps}")
|
||||
plugin.mutex.release()
|
||||
feeadjust(plugin)
|
||||
|
||||
|
||||
plugin.add_option(
|
||||
"feeadjuster-deactivate-fuzz",
|
||||
False,
|
||||
"Deactivate update threshold randomization and hysterisis.",
|
||||
"flag"
|
||||
)
|
||||
plugin.add_option(
|
||||
"feeadjuster-deactivate-fee-update",
|
||||
False,
|
||||
"Deactivate automatic fee updates for forward events.",
|
||||
"flag"
|
||||
)
|
||||
plugin.add_option(
|
||||
"feeadjuster-threshold",
|
||||
"0.05",
|
||||
"Relative channel balance delta at which to trigger an update. Default 0.05 means 5%. "
|
||||
"Note: it's also fuzzed by 1.5%",
|
||||
"string"
|
||||
)
|
||||
plugin.add_option(
|
||||
"feeadjuster-threshold-abs",
|
||||
"0.001btc",
|
||||
"Absolute channel balance delta at which to always trigger an update. "
|
||||
"Note: it's also fuzzed by 1.5%",
|
||||
"string"
|
||||
)
|
||||
plugin.add_option(
|
||||
"feeadjuster-enough-liquidity",
|
||||
"0msat",
|
||||
"Beyond this liquidity do not adjust fees. "
|
||||
"This also modifies the fee curve to achieve having this amount of liquidity. "
|
||||
"Default: '0msat' (turned off).",
|
||||
"string"
|
||||
)
|
||||
plugin.add_option(
|
||||
"feeadjuster-adjustment-method",
|
||||
"default",
|
||||
"Adjustment method to calculate channel fee"
|
||||
"Can be 'default', 'soft' for less difference or 'hard' for higher difference"
|
||||
"string"
|
||||
)
|
||||
plugin.add_option(
|
||||
"feeadjuster-imbalance",
|
||||
"0.5",
|
||||
"Ratio at which channel imbalance the feeadjuster should start acting. "
|
||||
"Default: 0.5 (always). Set higher or lower values to limit feeadjuster's "
|
||||
"activity to more imbalanced channels. "
|
||||
"E.g. 0.3 for '70/30'% or 0.6 for '40/60'%.",
|
||||
"string"
|
||||
)
|
||||
plugin.add_option(
|
||||
"feeadjuster-feestrategy",
|
||||
"global",
|
||||
"Sets the per channel fee selection strategy. "
|
||||
"Can be 'global' to use global config or default values, "
|
||||
"or 'median' to use the median fees from peers of peer "
|
||||
"Default: 'global'.",
|
||||
"string"
|
||||
)
|
||||
plugin.add_option(
|
||||
"feeadjuster-median-multiplier",
|
||||
"1.0",
|
||||
"Sets the factor with which the median fee is multiplied if using the fee strategy 'median'. "
|
||||
"This allows over or underbidding other nodes by a constant factor"
|
||||
"Default: '1.0'.",
|
||||
"string"
|
||||
)
|
||||
plugin.add_option(
|
||||
"feeadjuster-max-htlc-steps",
|
||||
"0",
|
||||
"Sets the number of max htlc adjustment steps. "
|
||||
"This will reduce the max htlc according to available "
|
||||
"liquidity, which can reduce local routing channel failures."
|
||||
"A value of 0 disables the stepping.",
|
||||
"string"
|
||||
)
|
||||
plugin.run()
|
||||
1
Unmaintained/feeadjuster/requirements.txt
Normal file
1
Unmaintained/feeadjuster/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
pyln-client>=0.12
|
||||
43
Unmaintained/feeadjuster/test_clnutils.py
Normal file
43
Unmaintained/feeadjuster/test_clnutils.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from clnutils import cln_parse_rpcversion
|
||||
|
||||
|
||||
def test_rpcversion():
|
||||
foo = cln_parse_rpcversion("0.11.2")
|
||||
assert(foo[0] == 0)
|
||||
assert(foo[1] == 11)
|
||||
assert(foo[2] == 2)
|
||||
|
||||
foo = cln_parse_rpcversion("0.11.2rc2-modded")
|
||||
assert(foo[0] == 0)
|
||||
assert(foo[1] == 11)
|
||||
assert(foo[2] == 2)
|
||||
|
||||
foo = cln_parse_rpcversion("22.11")
|
||||
assert(foo[0] == 22)
|
||||
assert(foo[1] == 11)
|
||||
assert(foo[2] == 0)
|
||||
|
||||
foo = cln_parse_rpcversion("22.11rc1")
|
||||
assert(foo[0] == 22)
|
||||
assert(foo[1] == 11)
|
||||
assert(foo[2] == 0)
|
||||
|
||||
foo = cln_parse_rpcversion("22.11rc1-modded")
|
||||
assert(foo[0] == 22)
|
||||
assert(foo[1] == 11)
|
||||
assert(foo[2] == 0)
|
||||
|
||||
foo = cln_parse_rpcversion("22.11-modded")
|
||||
assert(foo[0] == 22)
|
||||
assert(foo[1] == 11)
|
||||
assert(foo[2] == 0)
|
||||
|
||||
foo = cln_parse_rpcversion("22.11.0")
|
||||
assert(foo[0] == 22)
|
||||
assert(foo[1] == 11)
|
||||
assert(foo[2] == 0)
|
||||
|
||||
foo = cln_parse_rpcversion("22.11.1")
|
||||
assert(foo[0] == 22)
|
||||
assert(foo[1] == 11)
|
||||
assert(foo[2] == 1)
|
||||
331
Unmaintained/feeadjuster/test_feeadjuster.py
Normal file
331
Unmaintained/feeadjuster/test_feeadjuster.py
Normal file
@@ -0,0 +1,331 @@
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
|
||||
import unittest
|
||||
from pyln.testing.fixtures import * # noqa: F401,F403
|
||||
from pyln.testing.utils import wait_for
|
||||
|
||||
|
||||
plugin_path = os.path.join(os.path.dirname(__file__), "feeadjuster.py")
|
||||
|
||||
|
||||
def test_feeadjuster_starts(node_factory):
|
||||
l1 = node_factory.get_node()
|
||||
# Test dynamically
|
||||
l1.rpc.plugin_start(plugin_path)
|
||||
l1.daemon.wait_for_log("Plugin feeadjuster initialized.*")
|
||||
l1.rpc.plugin_stop(plugin_path)
|
||||
l1.rpc.plugin_start(plugin_path)
|
||||
l1.daemon.wait_for_log("Plugin feeadjuster initialized.*")
|
||||
l1.stop()
|
||||
# Then statically
|
||||
l1.daemon.opts["plugin"] = plugin_path
|
||||
l1.start()
|
||||
# Start at 0 and 're-await' the two inits above. Otherwise this is flaky.
|
||||
l1.daemon.logsearch_start = 0
|
||||
l1.daemon.wait_for_logs(["Plugin feeadjuster initialized.*",
|
||||
"Plugin feeadjuster initialized.*",
|
||||
"Plugin feeadjuster initialized.*"])
|
||||
l1.rpc.plugin_stop(plugin_path)
|
||||
|
||||
# We adjust fees in init
|
||||
l1, l2, l3 = node_factory.line_graph(3, wait_for_announce=True)
|
||||
scid_A = l2.rpc.listpeerchannels(l1.info["id"])["channels"][0]["short_channel_id"]
|
||||
scid_B = l2.rpc.listpeerchannels(l3.info["id"])["channels"][0]["short_channel_id"]
|
||||
l2.rpc.plugin_start(plugin_path)
|
||||
l2.daemon.wait_for_logs([f"Adjusted fees of {scid_A}.*",
|
||||
f"Adjusted fees of {scid_B}.*"])
|
||||
|
||||
|
||||
def get_chan_fees(l, scid):
|
||||
for half in l.rpc.listchannels(scid)["channels"]:
|
||||
if l.info["id"] == half["source"]:
|
||||
return (half["base_fee_millisatoshi"], half["fee_per_millionth"])
|
||||
|
||||
|
||||
def wait_for_fees(l, scids, fees):
|
||||
for scid in scids:
|
||||
wait_for(lambda: get_chan_fees(l, scid) == fees)
|
||||
|
||||
|
||||
def wait_for_not_fees(l, scids, fees):
|
||||
for scid in scids:
|
||||
wait_for(lambda: not get_chan_fees(l, scid) == fees)
|
||||
|
||||
|
||||
def pay(l, ll, amount):
|
||||
label = ''.join(random.choices(string.ascii_letters, k=20))
|
||||
invoice = ll.rpc.invoice(amount, label, "desc")
|
||||
route = l.rpc.getroute(ll.info["id"], amount, riskfactor=0, fuzzpercent=0)
|
||||
l.rpc.sendpay(route["route"], invoice["payment_hash"], payment_secret=invoice.get('payment_secret'))
|
||||
l.rpc.waitsendpay(invoice["payment_hash"])
|
||||
|
||||
|
||||
def sync_gossip(nodes, scids):
|
||||
node = nodes[0]
|
||||
nodes = nodes[1:]
|
||||
for scid in scids:
|
||||
for n in nodes:
|
||||
wait_for(lambda: node.rpc.listchannels(scid) == n.rpc.listchannels(scid))
|
||||
|
||||
|
||||
def test_feeadjuster_adjusts(node_factory):
|
||||
"""
|
||||
A rather simple network:
|
||||
|
||||
A B
|
||||
l1 <========> l2 <=========> l3
|
||||
|
||||
l2 will adjust its configuration-set base and proportional fees for
|
||||
channels A and B as l1 and l3 exchange payments.
|
||||
"""
|
||||
base_fee = 5000
|
||||
ppm_fee = 300
|
||||
l2_opts = {
|
||||
"fee-base": base_fee,
|
||||
"fee-per-satoshi": ppm_fee,
|
||||
"plugin": plugin_path,
|
||||
"feeadjuster-deactivate-fuzz": None,
|
||||
}
|
||||
l1, l2, l3 = node_factory.line_graph(3, opts=[{}, l2_opts, {}],
|
||||
wait_for_announce=True)
|
||||
|
||||
chan_A = l2.rpc.listpeerchannels(l1.info["id"])["channels"][0]
|
||||
chan_B = l2.rpc.listpeerchannels(l3.info["id"])["channels"][0]
|
||||
scid_A = chan_A["short_channel_id"]
|
||||
scid_B = chan_B["short_channel_id"]
|
||||
nodes = [l1, l2, l3]
|
||||
scids = [scid_A, scid_B]
|
||||
|
||||
# Fees don't get updated until there is a forwarding event!
|
||||
assert all([get_chan_fees(l2, scid) == (base_fee, ppm_fee)
|
||||
for scid in scids])
|
||||
|
||||
chan_total = int(chan_A["total_msat"])
|
||||
assert chan_total == int(chan_B["total_msat"])
|
||||
|
||||
# The first payment will trigger fee adjustment, no matter its value
|
||||
amount = int(chan_total * 0.04)
|
||||
pay(l1, l3, amount)
|
||||
wait_for(lambda: all([get_chan_fees(l2, scid) != (base_fee, ppm_fee)
|
||||
for scid in scids]))
|
||||
|
||||
# Send most of the balance to the other side..
|
||||
amount = int(chan_total * 0.8)
|
||||
pay(l1, l3, amount)
|
||||
l2.daemon.wait_for_logs([f'Adjusted fees of {scid_A} with a ratio of 0.2',
|
||||
f'Adjusted fees of {scid_B} with a ratio of 3.'])
|
||||
|
||||
# ..And back
|
||||
sync_gossip(nodes, scids)
|
||||
pay(l3, l1, amount)
|
||||
l2.daemon.wait_for_logs([f'Adjusted fees of {scid_A} with a ratio of 6.',
|
||||
f'Adjusted fees of {scid_B} with a ratio of 0.1'])
|
||||
|
||||
# Sending a payment worth 3% of the channel balance should not trigger
|
||||
# fee adjustment
|
||||
sync_gossip(nodes, scids)
|
||||
fees_before = [get_chan_fees(l2, scid) for scid in [scid_A, scid_B]]
|
||||
amount = int(chan_total * 0.03)
|
||||
pay(l1, l3, amount)
|
||||
sync_gossip(nodes, scids)
|
||||
assert fees_before == [get_chan_fees(l2, scid) for scid in scids]
|
||||
|
||||
# But sending another 3%-worth payment does trigger adjustment (total sent
|
||||
# since last adjustment is >5%)
|
||||
pay(l1, l3, amount)
|
||||
l2.daemon.wait_for_logs([f'Adjusted fees of {scid_A} with a ratio of 4.',
|
||||
f'Adjusted fees of {scid_B} with a ratio of 0.2'])
|
||||
|
||||
|
||||
def test_feeadjuster_imbalance(node_factory):
|
||||
"""
|
||||
A rather simple network:
|
||||
|
||||
A B
|
||||
l1 <========> l2 <=========> l3
|
||||
|
||||
l2 will adjust its configuration-set base and proportional fees for
|
||||
channels A and B as l1 and l3 exchange payments.
|
||||
"""
|
||||
base_fee = 5000
|
||||
ppm_fee = 300
|
||||
l2_opts = {
|
||||
"fee-base": base_fee,
|
||||
"fee-per-satoshi": ppm_fee,
|
||||
"plugin": plugin_path,
|
||||
"feeadjuster-deactivate-fuzz": None,
|
||||
"feeadjuster-imbalance": 0.7, # should be normalized to 30/70
|
||||
}
|
||||
l1, l2, l3 = node_factory.line_graph(3, opts=[{}, l2_opts, {}],
|
||||
wait_for_announce=True)
|
||||
|
||||
chan_A = l2.rpc.listpeerchannels(l1.info["id"])["channels"][0]
|
||||
chan_B = l2.rpc.listpeerchannels(l3.info["id"])["channels"][0]
|
||||
scid_A = chan_A["short_channel_id"]
|
||||
scid_B = chan_B["short_channel_id"]
|
||||
scids = [scid_A, scid_B]
|
||||
default_fees = [(base_fee, ppm_fee), (base_fee, ppm_fee)]
|
||||
|
||||
chan_total = int(chan_A["total_msat"])
|
||||
assert chan_total == int(chan_B["total_msat"])
|
||||
l2.daemon.logsearch_start = 0
|
||||
l2.daemon.wait_for_log('imbalance of 30%/70%')
|
||||
|
||||
# we force feeadjust initially to test this method and check if it applies
|
||||
# default fees when balancing the channel below
|
||||
l2.rpc.feeadjust()
|
||||
l2.daemon.wait_for_logs([
|
||||
f"Adjusted fees.*{scid_A}",
|
||||
f"Adjusted fees.*{scid_B}"
|
||||
])
|
||||
log_offset = len(l2.daemon.logs)
|
||||
wait_for_not_fees(l2, scids, default_fees[0])
|
||||
|
||||
# First bring channel to somewhat of a balance
|
||||
amount = int(chan_total * 0.5)
|
||||
pay(l1, l3, amount)
|
||||
l2.daemon.wait_for_logs([
|
||||
f'Set default fees as imbalance is too low for {scid_A}',
|
||||
f'Set default fees as imbalance is too low for {scid_B}'
|
||||
])
|
||||
wait_for_fees(l2, scids, default_fees[0])
|
||||
|
||||
# Because of the 70/30 imbalance limiter, a 15% payment must not yet trigger
|
||||
# 50% + 15% = 65% .. which is < 70%
|
||||
amount = int(chan_total * 0.15)
|
||||
pay(l1, l3, amount)
|
||||
assert not l2.daemon.is_in_log("Adjusted fees", log_offset)
|
||||
|
||||
# Sending another 20% must now trigger because the imbalance
|
||||
pay(l1, l3, amount)
|
||||
l2.daemon.wait_for_logs([
|
||||
f"Adjusted fees.*{scid_A}",
|
||||
f"Adjusted fees.*{scid_B}"
|
||||
])
|
||||
wait_for_not_fees(l2, scids, default_fees[0])
|
||||
|
||||
# Bringing it back must cause default fees
|
||||
pay(l3, l1, amount)
|
||||
l2.daemon.wait_for_logs([
|
||||
f'Set default fees as imbalance is too low for {scid_A}',
|
||||
f'Set default fees as imbalance is too low for {scid_B}'
|
||||
])
|
||||
wait_for_fees(l2, scids, default_fees[0])
|
||||
|
||||
|
||||
def test_feeadjuster_big_enough_liquidity(node_factory):
|
||||
"""
|
||||
A rather simple network:
|
||||
|
||||
A B
|
||||
l1 <========> l2 <=========> l3
|
||||
|
||||
l2 will adjust its configuration-set base and proportional fees for
|
||||
channels A and B as l1 and l3 exchange payments.
|
||||
"""
|
||||
base_fee = 5000
|
||||
ppm_fee = 300
|
||||
l2_opts = {
|
||||
"fee-base": base_fee,
|
||||
"fee-per-satoshi": ppm_fee,
|
||||
"plugin": plugin_path,
|
||||
"feeadjuster-deactivate-fuzz": None,
|
||||
"feeadjuster-imbalance": 0.5,
|
||||
"feeadjuster-enough-liquidity": "0.001btc",
|
||||
"feeadjuster-threshold-abs": "0.0001btc",
|
||||
}
|
||||
# channels' size: 0.01btc
|
||||
# between 0.001btc and 0.009btc the liquidity is big enough
|
||||
l1, l2, l3 = node_factory.line_graph(3, fundamount=10**6, opts=[{}, l2_opts, {}],
|
||||
wait_for_announce=True)
|
||||
|
||||
chan_A = l2.rpc.listpeerchannels(l1.info["id"])["channels"][0]
|
||||
chan_B = l2.rpc.listpeerchannels(l3.info["id"])["channels"][0]
|
||||
scid_A = chan_A["short_channel_id"]
|
||||
scid_B = chan_B["short_channel_id"]
|
||||
scids = [scid_A, scid_B]
|
||||
default_fees = [(base_fee, ppm_fee), (base_fee, ppm_fee)]
|
||||
|
||||
chan_total = int(chan_A["total_msat"])
|
||||
assert chan_total == int(chan_B["total_msat"])
|
||||
l2.daemon.logsearch_start = 0
|
||||
l2.daemon.wait_for_log('enough_liquidity: 100000000msat')
|
||||
|
||||
# we force feeadjust initially to test this method and check if it applies
|
||||
# default fees when balancing the channel below
|
||||
l2.rpc.feeadjust()
|
||||
l2.daemon.wait_for_logs([
|
||||
f"Adjusted fees.*{scid_A}",
|
||||
f"Adjusted fees.*{scid_B}"
|
||||
])
|
||||
wait_for_not_fees(l2, scids, default_fees[0])
|
||||
|
||||
# Bring channels to beyond big enough liquidity with 0.003btc
|
||||
amount = 300000000
|
||||
pay(l1, l3, amount)
|
||||
l2.daemon.wait_for_logs([
|
||||
f"Adjusted fees of {scid_A} with a ratio of 1.0",
|
||||
f"Adjusted fees of {scid_B} with a ratio of 1.0"
|
||||
])
|
||||
log_offset = len(l2.daemon.logs)
|
||||
wait_for_fees(l2, scids, default_fees[0])
|
||||
|
||||
# Let's move another 0.003btc -> the channels will be at 0.006btc
|
||||
amount = 300000000
|
||||
pay(l1, l3, amount)
|
||||
l2.wait_for_htlcs()
|
||||
assert not l2.daemon.is_in_log("Adjusted fees", log_offset)
|
||||
|
||||
# Sending another 0.0033btc will result in a channel balance of 0.0093btc
|
||||
# It must trigger because the remaining liquidity is not big enough
|
||||
amount = 330000000
|
||||
pay(l1, l3, amount)
|
||||
l2.daemon.wait_for_logs([
|
||||
f"Adjusted fees.*{scid_A}",
|
||||
f"Adjusted fees.*{scid_B}"
|
||||
])
|
||||
wait_for_not_fees(l2, scids, default_fees[0])
|
||||
|
||||
|
||||
def test_feeadjuster_median(node_factory):
|
||||
"""
|
||||
A rather simple network:
|
||||
|
||||
a b c
|
||||
l1 <=======> l2 <=======> l3 <=======> l4
|
||||
|
||||
l2 will adjust its configuration-set base and proportional fees for
|
||||
channels A and B as l1 and l3 exchange payments.
|
||||
l4 is needed so l2 can make a median peers-of-peer calculation on l3.
|
||||
"""
|
||||
opts = {
|
||||
"fee-base": 1337,
|
||||
"fee-per-satoshi": 42,
|
||||
}
|
||||
l2_opts = {
|
||||
"fee-base": 1000,
|
||||
"fee-per-satoshi": 100,
|
||||
"plugin": plugin_path,
|
||||
"feeadjuster-deactivate-fuzz": None,
|
||||
"feeadjuster-imbalance": 0.5,
|
||||
"feeadjuster-feestrategy": "median"
|
||||
}
|
||||
l1, l2, l3, _ = node_factory.line_graph(4, opts=[opts, l2_opts, opts, opts],
|
||||
wait_for_announce=True)
|
||||
|
||||
scid_a = l2.rpc.listpeerchannels(l1.info["id"])["channels"][0]["short_channel_id"]
|
||||
scid_b = l2.rpc.listpeerchannels(l3.info["id"])["channels"][0]["short_channel_id"]
|
||||
|
||||
# we do a manual feeadjust
|
||||
l2.rpc.feeadjust()
|
||||
l2.daemon.wait_for_logs([
|
||||
f"Adjusted fees.*{scid_a}",
|
||||
f"Adjusted fees.*{scid_b}"
|
||||
])
|
||||
|
||||
# since there is only l4 with channel c towards l3, l2 should take that value
|
||||
chan_b = l2.rpc.listpeerchannels(l3.info['id'])['channels'][0]
|
||||
assert chan_b['fee_base_msat'] == 1337
|
||||
assert chan_b['fee_proportional_millionths'] < 42 # we could do the actual ratio math, but meh
|
||||
Reference in New Issue
Block a user