#!/usr/bin/env python3 import random import statistics import time 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.peers = 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_peer_id_for_scid(plugin: Plugin, scid: str): for peer in plugin.peers: for ch in peer['channels']: if ch.get('short_channel_id') == scid: return peer['id'] return None def get_local_channel_for_scid(plugin: Plugin, scid: str): for peer in plugin.peers: for ch in peer['channels']: if ch.get('short_channel_id') == scid: return ch return None def get_chan_fees(plugin: Plugin, scid: str): channel = get_local_channel_for_scid(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_ppm = [ch['fee_per_millionth'] for ch in channels_to_peer] return {"base": plugin.adj_basefee, "ppm": statistics.median(fees_ppm)} def setchannelfee(plugin: Plugin, scid: str, base: int, ppm: int): fees = get_chan_fees(plugin, scid) if fees is None or base == fees['base'] and ppm == fees['ppm']: return False try: plugin.rpc.setchannelfee(scid, base, ppm) 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 = plugin.adj_basefee ppm = plugin.adj_ppmfee # select ideal values per channel fees = plugin.fee_strategy(plugin, scid) if fees is not None: base = fees['base'] ppm = 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: {scid}") 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 setchannelfee(plugin, scid, int(base), int(ppm * ratio)): plugin.log(f"Adjusted fees of {scid} with a ratio of {ratio}") plugin.adj_balances[scid]["last_liquidity"] = our channels_adjusted += 1 return channels_adjusted def get_chan(plugin: Plugin, scid: str): for peer in plugin.peers: for chan in peer["channels"]: if chan.get("short_channel_id") == scid: return chan def maybe_add_new_balances(plugin: Plugin, scids: list): for scid in scids: if scid not in plugin.adj_balances: chan = get_chan(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.peers = plugin.rpc.listpeers()["peers"] 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]) plugin.adj_balances[in_scid]["our"] += forward_event["in_msatoshi"] plugin.adj_balances[out_scid]["our"] -= forward_event["out_msatoshi"] 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. """ plugin.mutex.acquire(blocking=True) plugin.peers = plugin.rpc.listpeers()["peers"] if plugin.fee_strategy == get_fees_median and not plugin.listchannels_by_dst: plugin.channels = plugin.rpc.listchannels()['channels'] channels_adjusted = 0 for peer in plugin.peers: for chan in peer["channels"]: 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): plugin.our_node_id = plugin.rpc.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")) 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) 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 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}") 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.run()