diff --git a/feeadjuster/feeadjuster.py b/feeadjuster/feeadjuster.py index d539e4f..498d4bf 100755 --- a/feeadjuster/feeadjuster.py +++ b/feeadjuster/feeadjuster.py @@ -13,12 +13,53 @@ plugin.our_node_id = None plugin.update_threshold = 0.05 -def get_ratio(plugin: Plugin, our_percentage): +def get_ratio_soft(our_percentage): + """ + Basic algorithm: lesser difference than default + """ + our_percentage = min(1, max(0, our_percentage)) + 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 plugin.ratio_base**(0.5 - our_percentage) + our_percentage = min(1, max(0, our_percentage)) + 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 + """ + our_percentage = min(1, max(0, our_percentage)) + return 100**(0.5 - our_percentage) * (1 - our_percentage) * 2 + + +def get_chan_fees(plugin: Plugin, scid: str): + channels = plugin.rpc.listchannels(scid)["channels"] + for ch in channels: + if ch["source"] == plugin.our_node_id: + return { "base_fee_millisatoshi": ch["base_fee_millisatoshi"], + "fee_per_millionth": ch["fee_per_millionth"] } + + +def maybe_setchannelfee(plugin: Plugin, scid: str, base = None, ppm = None): + base = plugin.adj_basefee if base is None else base + ppm = plugin.adj_ppmfee if ppm is None else ppm + fees = get_chan_fees(plugin, scid) + + if not fees or base == fees["base_fee_millisatoshi"] and ppm == fees["fee_per_millionth"]: + return False + + try: + plugin.rpc.setchannelfee(scid, base, ppm) + return True + except RpcError as e: + plugin.log("Could not adjust fees for channel {}: '{}'".format(scid, e), level="warn") + return False def maybe_adjust_fees(plugin: Plugin, scids: list): @@ -29,6 +70,14 @@ def maybe_adjust_fees(plugin: Plugin, scids: list): percentage = our / total last_percentage = plugin.adj_balances[scid].get("last_percentage") + # reset to normal fees if imbalance is not high enough + if (percentage > plugin.imbalance and percentage < 1 - plugin.imbalance): + if maybe_setchannelfee(plugin, scid): # applies default values + plugin.log("Set default fees as imbalance is too low: {}".format(scid)) + plugin.adj_balances[scid]["last_percentage"] = percentage + channels_adjusted += 1 + continue + # 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 @@ -38,25 +87,13 @@ def maybe_adjust_fees(plugin: Plugin, scids: list): and abs(last_percentage - percentage) < update_threshold): continue - # reset to normal fees if imbalance is not high enough - if (percentage > plugin.imbalance and percentage < 1 - plugin.imbalance): - plugin.rpc.setchannelfee(scid) # applies default values - plugin.log("Set default fees as imbalance is too low: {}".format(scid)) - plugin.adj_balances[scid]["last_percentage"] = percentage - channels_adjusted += 1 - continue - - ratio = get_ratio(plugin, percentage) - try: - plugin.rpc.setchannelfee(scid, int(plugin.adj_basefee * ratio), - int(plugin.adj_ppmfee * ratio)) + ratio = plugin.get_ratio(percentage) + if maybe_setchannelfee(plugin, scid, int(plugin.adj_basefee * ratio), + int(plugin.adj_ppmfee * ratio)): plugin.log("Adjusted fees of {} with a ratio of {}" .format(scid, ratio)) plugin.adj_balances[scid]["last_percentage"] = percentage channels_adjusted += 1 - except RpcError as e: - plugin.log("Could not adjust fees for channel {}: '{}'" - .format(scid, e), level="warn") return channels_adjusted @@ -108,24 +145,27 @@ def forward_event(plugin: Plugin, forward_event: dict, **kwargs): plugin.log("Adjusting fees: " + str(e), level="error") -@plugin.method("forcefeeadjust") -def forcefeeadjust(plugin: Plugin): +@plugin.method("feeadjust") +def feeadjust(plugin: Plugin): """Adjust fees for all existing channels. - Can run effectively once as an initial setup, or after a successful payment. Otherwise, the plugin keeps the fees up-to-date. + 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. """ peers = plugin.rpc.listpeers()["peers"] channels_adjusted = 0 for peer in peers: for chan in peer["channels"]: if chan["state"] == "CHANNELD_NORMAL": - scid = chan['short_channel_id']; - if scid not in plugin.adj_balances: - plugin.adj_balances[scid] = {} - plugin.adj_balances[scid]["our"] = int(chan["to_us_msat"]) - plugin.adj_balances[scid]["total"] = int(chan["total_msat"]) + scid = chan["short_channel_id"]; + plugin.adj_balances[scid] = { + "our": int(chan["to_us_msat"]), + "total": int(chan["total_msat"]) + } channels_adjusted += maybe_adjust_fees(plugin, [scid]) - return "%s channels adjusted" % channels_adjusted + msg = "%s channels adjusted" % channels_adjusted + plugin.log(msg) + return msg @plugin.init() @@ -134,7 +174,11 @@ def init(options: dict, configuration: dict, plugin: Plugin, **kwargs): plugin.deactivate_fuzz = options.get("feeadjuster-deactivate-fuzz", False) plugin.update_threshold = float(options.get("feeadjuster-threshold", "0.05")) plugin.imbalance = float(options.get("feeadjuster-imbalance", 0.5)) - plugin.ratio_base = int(options.get("feeadjuster-ratio-base", "50")) + plugin.get_ratio = get_ratio + if options.get("feeadjuster-adjustment-method", "default") == "soft": + plugin.get_ratio = get_ratio_soft + if options.get("feeadjuster-adjustment-method", "default") == "hard": + plugin.get_ratio = get_ratio_hard config = plugin.rpc.listconfigs() plugin.adj_basefee = config["fee-base"] plugin.adj_ppmfee = config["fee-per-satoshi"] @@ -146,13 +190,15 @@ def init(options: dict, configuration: dict, plugin: Plugin, **kwargs): plugin.imbalance = 1 - plugin.imbalance plugin.log("Plugin feeadjuster initialized ({} base / {} ppm) with an " - "imbalance of {}%/{}%, update_threshold: {}, deactivate_fuzz: {}, ratio_base {}".format(plugin.adj_basefee, - plugin.adj_ppmfee, - int(100*plugin.imbalance), - int(100*(1-plugin.imbalance)), - plugin.update_threshold, - plugin.deactivate_fuzz, - plugin.ratio_base)) + "imbalance of {}%/{}%, update_threshold: {}, deactivate_fuzz: {}, adjustment_method: {}" + .format(plugin.adj_basefee, + plugin.adj_ppmfee, + int(100*plugin.imbalance), + int(100*(1-plugin.imbalance)), + plugin.update_threshold, + plugin.deactivate_fuzz, + plugin.get_ratio)) + feeadjust(plugin) plugin.add_option( @@ -169,10 +215,10 @@ plugin.add_option( "string" ) plugin.add_option( - "feeadjuster-ratio-base", - "50", - "Channel fees will be adjusted by the exponent of this." - "New fee = * feeadjuster-ratio-base**(0.5 - )" + "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( diff --git a/feeadjuster/test_feeadjuster.py b/feeadjuster/test_feeadjuster.py index 11d3ce9..03e94f3 100644 --- a/feeadjuster/test_feeadjuster.py +++ b/feeadjuster/test_feeadjuster.py @@ -33,6 +33,16 @@ def get_chan_fees(l, scid): 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") @@ -41,8 +51,12 @@ def pay(l, ll, amount): l.rpc.waitsendpay(invoice["payment_hash"]) -def sync_gossip(l, ll, scid): - wait_for(lambda: l.rpc.listchannels(scid) == ll.rpc.listchannels(scid)) +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)) @unittest.skipIf(not DEVELOPER, "Too slow without fast gossip") @@ -71,11 +85,12 @@ def test_feeadjuster_adjusts(node_factory): chan_B = l2.rpc.listpeers(l3.info["id"])["peers"][0]["channels"][0] scid_A = chan_A["short_channel_id"] scid_B = chan_B["short_channel_id"] - l2_scids = [scid_A, scid_B] + 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 l2_scids]) + for scid in scids]) chan_total = int(chan_A["total_msat"]) assert chan_total == int(chan_B["total_msat"]) @@ -84,7 +99,7 @@ def test_feeadjuster_adjusts(node_factory): 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 l2_scids])) + for scid in scids])) # Send most of the balance to the other side.. amount = int(chan_total * 0.8) @@ -95,9 +110,7 @@ def test_feeadjuster_adjusts(node_factory): " 3.".format(scid_B)) is not None) # ..And back - for scid in l2_scids: - sync_gossip(l3, l2, scid) - sync_gossip(l1, l2, scid) + sync_gossip(nodes, scids) pay(l3, l1, amount) wait_for(lambda: l2.daemon.is_in_log("Adjusted fees of {} with a ratio of" " 6.".format(scid_A)) is not None) @@ -106,16 +119,12 @@ def test_feeadjuster_adjusts(node_factory): # Sending a payment worth 3% of the channel balance should not trigger # fee adjustment - for scid in l2_scids: - sync_gossip(l3, l2, scid) - sync_gossip(l1, l2, scid) + 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) - for scid in l2_scids: - sync_gossip(l3, l2, scid) - sync_gossip(l1, l2, scid) - assert fees_before == [get_chan_fees(l2, scid) for scid in l2_scids] + 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%) @@ -153,37 +162,43 @@ def test_feeadjuster_imbalance(node_factory): chan_B = l2.rpc.listpeers(l3.info["id"])["peers"][0]["channels"][0] scid_A = chan_A["short_channel_id"] scid_B = chan_B["short_channel_id"] - l2_scids = [scid_A, scid_B] + nodes = [l1, l2, l3] + 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.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_log("Adjusted fees") + l2.daemon.wait_for_log("Adjusted fees") + log_offset = len(l2.daemon.logs) + wait_for_not_fees(l2, scids, default_fees[0]) + # First bring channel to somewhat of a blanance amount = int(chan_total * 0.5) pay(l1, l3, amount) l2.daemon.wait_for_log('Set default fees as imbalance is too low') - for scid in l2_scids: - sync_gossip(l3, l2, scid) - sync_gossip(l1, l2, scid) - fees_before = [get_chan_fees(l2, scid) for scid in [scid_A, scid_B]] - assert fees_before == [(base_fee, ppm_fee), (base_fee, ppm_fee)] + l2.daemon.wait_for_log('Set default fees as imbalance is too low') + 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) - for scid in l2_scids: - sync_gossip(l3, l2, scid) - sync_gossip(l1, l2, scid) - fees_before = [get_chan_fees(l2, scid) for scid in [scid_A, scid_B]] - assert fees_before == [get_chan_fees(l2, scid) for scid in l2_scids] - assert not l2.daemon.is_in_log("Adjusted fees") + 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_log("Adjusted fees") + l2.daemon.wait_for_log("Adjusted fees") + wait_for_not_fees(l2, scids, default_fees[0]) # Bringing it back must cause default fees pay(l3, l1, amount) l2.daemon.wait_for_log('Set default fees as imbalance is too low') + l2.daemon.wait_for_log('Set default fees as imbalance is too low') + wait_for_fees(l2, scids, default_fees[0])