From 96784196ea082f80e683f9a979e49bf223509d20 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Wed, 8 Dec 2021 15:47:51 +0100 Subject: [PATCH 01/15] rebalance: internal pathfinding, economic parties * we now rely on internal pathfinding * liquidity hints: we add hints to know which channels have enough capacity * rebalancing is restructured, the largest change is that we now select the route with the lowest weight, among all counterparty channels * counterparty rebalance channels are selected for resulting a in a future expected gain * the circle command is removed * tests are added for liquidity hints and the new rebalance command --- lndmanage/lib/exceptions.py | 18 +- lndmanage/lib/info.py | 10 +- lndmanage/lib/liquidityhints.py | 271 +++++++++ lndmanage/lib/network.py | 71 +-- lndmanage/lib/node.py | 43 +- lndmanage/lib/pathfinding.py | 118 +--- lndmanage/lib/rating.py | 145 +++-- lndmanage/lib/rebalance.py | 700 +++++++++++------------- lndmanage/lib/routing.py | 317 +++++------ lndmanage/lndmanage.py | 78 +-- lndmanage/settings.py | 3 +- test/graph_definitions/routing_graph.py | 199 +++++++ test/test_circle.py | 249 +++++---- test/test_itest.py | 3 +- test/test_openchannels.py | 3 +- test/test_pathfinding.py | 111 ++++ test/test_rebalance.py | 147 +++-- test/testing_common.py | 63 +++ test/testnetwork.py | 75 --- 19 files changed, 1472 insertions(+), 1152 deletions(-) create mode 100644 lndmanage/lib/liquidityhints.py create mode 100644 test/graph_definitions/routing_graph.py create mode 100644 test/test_pathfinding.py delete mode 100644 test/testnetwork.py diff --git a/lndmanage/lib/exceptions.py b/lndmanage/lib/exceptions.py index 408be91..094117d 100644 --- a/lndmanage/lib/exceptions.py +++ b/lndmanage/lib/exceptions.py @@ -22,23 +22,15 @@ class RebalanceFailure(Exception): pass -class RoutesExhausted(RebalanceFailure): - pass - - -class RebalanceCandidatesExhausted(RebalanceFailure): - pass - - class NoRebalanceCandidates(RebalanceFailure): pass -class RebalancingTrialsExhausted(RebalanceFailure): +class NotEconomic(RebalanceFailure): pass -class MultichannelInboundRebalanceFailure(RebalanceFailure): +class RebalancingTrialsExhausted(RebalanceFailure): pass @@ -76,6 +68,12 @@ def __init__(self, payment): super().__init__() +class TemporaryNodeFailure(PaymentFailure): + def __init__(self, payment): + self.payment = payment + super().__init__() + + class UnknownNextPeer(PaymentFailure): def __init__(self, payment): self.payment = payment diff --git a/lndmanage/lib/info.py b/lndmanage/lib/info.py index 5d07853..4331f44 100644 --- a/lndmanage/lib/info.py +++ b/lndmanage/lib/info.py @@ -1,6 +1,6 @@ import re import datetime -from typing import Tuple +from typing import Tuple, Dict from lndmanage.lib.network_info import NetworkAnalysis from lndmanage.lib import ln_utilities @@ -140,12 +140,11 @@ def parse(self, info: str) -> Tuple[int, str]: return channel_id, node_pub_key - def print_channel_info(self, general_info): + def print_channel_info(self, general_info: Dict): """ Prints the channel info with peer information. :param general_info: information about the channel in the public graph - :type general_info: dict """ logger.info("-------- Channel info --------") @@ -189,8 +188,9 @@ def print_channel_info(self, general_info): logger.info(f"{general_info['node1_alias']:^{COL_WIDTH}} | " f"{general_info['node2_alias']:^{COL_WIDTH}}") - np1 = general_info['node1_policy'] - np2 = general_info['node2_policy'] + policies = general_info['policies'] + np1 = policies[general_info['node1_pub'] > general_info['node2_pub']] + np2 = policies[general_info['node2_pub'] > general_info['node1_pub']] last_update_1 = np1['last_update'] last_update_2 = np2['last_update'] diff --git a/lndmanage/lib/liquidityhints.py b/lndmanage/lib/liquidityhints.py new file mode 100644 index 0000000..47f96bd --- /dev/null +++ b/lndmanage/lib/liquidityhints.py @@ -0,0 +1,271 @@ +from collections import defaultdict +import time +from typing import Set, Dict +from math import inf, log + +import logging +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + +DEFAULT_PENALTY_BASE_MSAT = 1000 # how much base fee we apply for unknown sending capability of a channel +DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH = 100 # how much relative fee we apply for unknown sending capability of a channel +BLACKLIST_DURATION = 3600 # how long (in seconds) a channel remains blacklisted +HINT_DURATION = 3600 # how long (in seconds) a liquidity hint remains valid +ATTEMPTS_TO_FAIL = 4 # if a node fails this often to forward a payment, we won't use it anymore + + +class ShortChannelID(int): + pass + + +class NodeID(str): + pass + + +class LiquidityHint: + """Encodes the amounts that can and cannot be sent over the direction of a + channel and whether the channel is blacklisted. + + A LiquidityHint is the value of a dict, which is keyed to node ids and the + channel. + """ + def __init__(self): + # use "can_send_forward + can_send_backward < cannot_send_forward + cannot_send_backward" as a sanity check? + self._can_send_forward = None + self._cannot_send_forward = None + self._can_send_backward = None + self._cannot_send_backward = None + self.blacklist_timestamp = 0 + self.hint_timestamp = 0 + self._inflight_htlcs_forward = 0 + self._inflight_htlcs_backward = 0 + + def is_hint_invalid(self) -> bool: + now = int(time.time()) + return now - self.hint_timestamp > HINT_DURATION + + @property + def can_send_forward(self): + return None if self.is_hint_invalid() else self._can_send_forward + + @can_send_forward.setter + def can_send_forward(self, amount): + # we don't want to record less significant info + # (sendable amount is lower than known sendable amount): + if self._can_send_forward and self._can_send_forward > amount: + return + self._can_send_forward = amount + # we make a sanity check that sendable amount is lower than not sendable amount + if self._cannot_send_forward and self._can_send_forward > self._cannot_send_forward: + self._cannot_send_forward = None + + @property + def can_send_backward(self): + return None if self.is_hint_invalid() else self._can_send_backward + + @can_send_backward.setter + def can_send_backward(self, amount): + if self._can_send_backward and self._can_send_backward > amount: + return + self._can_send_backward = amount + if self._cannot_send_backward and self._can_send_backward > self._cannot_send_backward: + self._cannot_send_backward = None + + @property + def cannot_send_forward(self): + return None if self.is_hint_invalid() else self._cannot_send_forward + + @cannot_send_forward.setter + def cannot_send_forward(self, amount): + # we don't want to record less significant info + # (not sendable amount is higher than known not sendable amount): + if self._cannot_send_forward and self._cannot_send_forward < amount: + return + self._cannot_send_forward = amount + if self._can_send_forward and self._can_send_forward > self._cannot_send_forward: + self._can_send_forward = None + # if we can't send over the channel, we should be able to send in the + # reverse direction + self.can_send_backward = amount + + @property + def cannot_send_backward(self): + return None if self.is_hint_invalid() else self._cannot_send_backward + + @cannot_send_backward.setter + def cannot_send_backward(self, amount): + if self._cannot_send_backward and self._cannot_send_backward < amount: + return + self._cannot_send_backward = amount + if self._can_send_backward and self._can_send_backward > self._cannot_send_backward: + self._can_send_backward = None + self.can_send_forward = amount + + def can_send(self, is_forward_direction: bool): + # make info invalid after some time? + if is_forward_direction: + return self.can_send_forward + else: + return self.can_send_backward + + def cannot_send(self, is_forward_direction: bool): + # make info invalid after some time? + if is_forward_direction: + return self.cannot_send_forward + else: + return self.cannot_send_backward + + def update_can_send(self, is_forward_direction: bool, amount: int): + self.hint_timestamp = int(time.time()) + if is_forward_direction: + self.can_send_forward = amount + else: + self.can_send_backward = amount + + def update_cannot_send(self, is_forward_direction: bool, amount: int): + self.hint_timestamp = int(time.time()) + if is_forward_direction: + self.cannot_send_forward = amount + else: + self.cannot_send_backward = amount + + def num_inflight_htlcs(self, is_forward_direction: bool) -> int: + if is_forward_direction: + return self._inflight_htlcs_forward + else: + return self._inflight_htlcs_backward + + def add_htlc(self, is_forward_direction: bool): + if is_forward_direction: + self._inflight_htlcs_forward += 1 + else: + self._inflight_htlcs_backward += 1 + + def remove_htlc(self, is_forward_direction: bool): + if is_forward_direction: + self._inflight_htlcs_forward = max(0, self._inflight_htlcs_forward - 1) + else: + self._inflight_htlcs_backward = max(0, self._inflight_htlcs_forward - 1) + + def __repr__(self): + is_blacklisted = False if not self.blacklist_timestamp else int(time.time()) - self.blacklist_timestamp < BLACKLIST_DURATION + return f"forward: can send: {self._can_send_forward} msat, cannot send: {self._cannot_send_forward} msat, htlcs: {self._inflight_htlcs_forward}\n" \ + f"backward: can send: {self._can_send_backward} msat, cannot send: {self._cannot_send_backward} msat, htlcs: {self._inflight_htlcs_backward}\n" \ + f"blacklisted: {is_blacklisted}" + + +class LiquidityHintMgr: + """Implements liquidity hints for channels in the graph. + + This class can be used to update liquidity information about channels in the + graph. Implements a penalty function for edge weighting in the pathfinding + algorithm that favors channels which can route payments and penalizes + channels that cannot. + """ + # TODO: hints based on node pairs only (shadow channels, non-strict forwarding)? + def __init__(self, source_node: str): + self.source_node = source_node + self._liquidity_hints: Dict[ShortChannelID, LiquidityHint] = {} + self._failure_hints: Dict[NodeID, int] = defaultdict(int) + + def get_hint(self, channel_id: ShortChannelID) -> LiquidityHint: + hint = self._liquidity_hints.get(channel_id) + if not hint: + hint = LiquidityHint() + self._liquidity_hints[channel_id] = hint + return hint + + def update_can_send(self, node_from: NodeID, node_to: NodeID, channel_id: ShortChannelID, amount_msat: int): + logger.debug(f" report: can send {amount_msat // 1000} sat over channel {channel_id}") + hint = self.get_hint(channel_id) + hint.update_can_send(node_from < node_to, amount_msat) + + def update_cannot_send(self, node_from: NodeID, node_to: NodeID, channel_id: ShortChannelID, amount: int): + logger.debug(f" report: cannot send {amount // 1000} sat over channel {channel_id}") + self._failure_hints[node_from] += 1 + hint = self.get_hint(channel_id) + hint.update_cannot_send(node_from < node_to, amount) + + def add_htlc(self, node_from: NodeID, node_to: NodeID, channel_id: ShortChannelID): + hint = self.get_hint(channel_id) + hint.add_htlc(node_from < node_to) + + def remove_htlc(self, node_from: NodeID, node_to: NodeID, channel_id: ShortChannelID): + hint = self.get_hint(channel_id) + hint.remove_htlc(node_from < node_to) + + def penalty(self, node_from: NodeID, node_to: NodeID, edge: Dict, amount_msat: int, fee_rate_milli_msat: int) -> float: + """Gives a penalty when sending from node1 to node2 over channel_id with an + amount in units of millisatoshi. + + The penalty depends on the can_send and cannot_send values that was + possibly recorded in previous payment attempts. + + A channel that can send an amount is assigned a penalty of zero, a + channel that cannot send an amount is assigned an infinite penalty. + If the sending amount lies between can_send and cannot_send, there's + uncertainty and we give a default penalty. The default penalty + serves the function of giving a positive offset (the Dijkstra + algorithm doesn't work with negative weights), from which we can discount + from. There is a competition between low-fee channels and channels where + we know with some certainty that they can support a payment. The penalty + ultimately boils down to: how much more fees do we want to pay for + certainty of payment success? This can be tuned via DEFAULT_PENALTY_BASE_MSAT + and DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH. A base _and_ relative penalty + was chosen such that the penalty will be able to compete with the regular + base and relative fees. + """ + # we assume that our node can always route: + if self.source_node in [node_from, node_to]: + return 0 + # we only evaluate hints here, so use dict get (to not create many hints with self.get_hint) + hint = self._liquidity_hints.get(edge['channel_id']) + if not hint: + can_send, cannot_send, num_inflight_htlcs = None, None, 0 + else: + can_send = hint.can_send(node_from < node_to) + cannot_send = hint.cannot_send(node_from < node_to) + + if can_send is None: + can_send = 0 + if cannot_send is None: + cannot_send = edge['capacity'] * 1000 + if amount_msat >= cannot_send: + return inf + if amount_msat <= can_send: + return 0 + + log_penalty = - log((cannot_send - (amount_msat - can_send)) / cannot_send) + # we give a base penalty if we haven't tried the channel yet + penalty = fee_rate_milli_msat * amount_msat // 1_000_000 + # all the so-far successful channels would be tried over and over until all + # of the last successful node's channels are explored + # as a tie-breaker, we add another penalty that increases with the number of + # failures + failure_fee = self._failure_hints[node_from] * penalty // ATTEMPTS_TO_FAIL + + return 5 * (log_penalty * penalty + failure_fee) + + def add_to_blacklist(self, channel_id: ShortChannelID): + hint = self.get_hint(channel_id) + now = int(time.time()) + hint.blacklist_timestamp = now + + def get_blacklist(self) -> Set[ShortChannelID]: + now = int(time.time()) + return set(k for k, v in self._liquidity_hints.items() if now - v.blacklist_timestamp < BLACKLIST_DURATION) + + def clear_blacklist(self): + for k, v in self._liquidity_hints.items(): + v.blacklist_timestamp = 0 + + def reset_liquidity_hints(self): + for k, v in self._liquidity_hints.items(): + v.hint_timestamp = 0 + + def __repr__(self): + string = "liquidity hints:\n" + if self._liquidity_hints: + for k, v in self._liquidity_hints.items(): + string += f"{k}: {v}\n" + return string diff --git a/lndmanage/lib/network.py b/lndmanage/lib/network.py index f7401b3..a1126e5 100644 --- a/lndmanage/lib/network.py +++ b/lndmanage/lib/network.py @@ -5,6 +5,8 @@ import networkx as nx from lndmanage.lib.ln_utilities import convert_channel_id_to_short_channel_id +from lndmanage.lib.liquidityhints import LiquidityHintMgr +from lndmanage.lib.rating import ChannelRater from lndmanage import settings import logging @@ -12,7 +14,7 @@ logger.addHandler(logging.NullHandler()) -class Network(object): +class Network: """ Contains the network graph. @@ -26,8 +28,10 @@ def __init__(self, node): logger.info("Initializing network graph.") self.node = node self.edges = {} - self.graph = nx.MultiDiGraph() + self.graph = nx.MultiGraph() self.cached_reading_graph_edges() + self.liquidity_hints = LiquidityHintMgr(self.node.pub_key) + self.channel_rater = ChannelRater(self) def cached_reading_graph_edges(self): """ @@ -90,34 +94,25 @@ def set_graph_and_edges(self): 'last_update': e.last_update, 'channel_id': e.channel_id, 'chan_point': e.chan_point, - 'node1_policy': { - 'time_lock_delta': e.node1_policy.time_lock_delta, - 'fee_base_msat': e.node1_policy.fee_base_msat, - 'fee_rate_milli_msat': e.node1_policy.fee_rate_milli_msat, - 'last_update': e.node1_policy.last_update, - 'disabled': e.node1_policy.disabled - }, - 'node2_policy': { - 'time_lock_delta': e.node2_policy.time_lock_delta, - 'fee_base_msat': e.node2_policy.fee_base_msat, - 'fee_rate_milli_msat': e.node2_policy.fee_rate_milli_msat, - 'last_update': e.node2_policy.last_update, - 'disabled': e.node2_policy.disabled - }} + 'policies': { + e.node1_pub > e.node2_pub: { + 'time_lock_delta': e.node1_policy.time_lock_delta, + 'fee_base_msat': e.node1_policy.fee_base_msat, + 'fee_rate_milli_msat': e.node1_policy.fee_rate_milli_msat, + 'last_update': e.node1_policy.last_update, + 'disabled': e.node1_policy.disabled + }, + e.node2_pub > e.node1_pub: { + 'time_lock_delta': e.node2_policy.time_lock_delta, + 'fee_base_msat': e.node2_policy.fee_base_msat, + 'fee_rate_milli_msat': e.node2_policy.fee_rate_milli_msat, + 'last_update': e.node2_policy.last_update, + 'disabled': e.node2_policy.disabled + } + } + } # add vertices to network graph for edge-based lookups - self.graph.add_edge( - e.node2_pub, - e.node1_pub, - channel_id=e.channel_id, - last_update=e.last_update, - capacity=e.capacity, - fees={ - 'time_lock_delta': e.node2_policy.time_lock_delta, - 'fee_base_msat': e.node2_policy.fee_base_msat, - 'fee_rate_milli_msat': e.node2_policy.fee_rate_milli_msat, - 'disabled': e.node2_policy.disabled - }) self.graph.add_edge( e.node1_pub, e.node2_pub, @@ -125,10 +120,20 @@ def set_graph_and_edges(self): last_update=e.last_update, capacity=e.capacity, fees={ - 'time_lock_delta': e.node1_policy.time_lock_delta, - 'fee_base_msat': e.node1_policy.fee_base_msat, - 'fee_rate_milli_msat': e.node1_policy.fee_rate_milli_msat, - 'disabled': e.node1_policy.disabled + e.node2_pub > e.node1_pub: + { + 'time_lock_delta': e.node2_policy.time_lock_delta, + 'fee_base_msat': e.node2_policy.fee_base_msat, + 'fee_rate_milli_msat': e.node2_policy.fee_rate_milli_msat, + 'disabled': e.node2_policy.disabled + }, + e.node1_pub > e.node2_pub: + { + 'time_lock_delta': e.node1_policy.time_lock_delta, + 'fee_base_msat': e.node1_policy.fee_base_msat, + 'fee_rate_milli_msat': e.node1_policy.fee_rate_milli_msat, + 'disabled': e.node1_policy.disabled + }, }) def number_channels(self, node_pub_key): @@ -254,6 +259,6 @@ def nodes_in_neighborhood_of_nodes(self, nodes, blacklist_nodes, nnodes=100): logging.config.dictConfig(settings.logger_config) from lndmanage.lib.node import LndNode - nd = LndNode() + nd = LndNode('') print(f"Graph size: {nd.network.graph.size()}") print(f"Number of channels: {len(nd.network.edges.keys())}") diff --git a/lndmanage/lib/node.py b/lndmanage/lib/node.py index 53bf7cc..8b0cc58 100644 --- a/lndmanage/lib/node.py +++ b/lndmanage/lib/node.py @@ -61,7 +61,6 @@ def __init__(self): self.total_private_channels = 0 self.total_active_channels = 0 self.blockheight = 0 - self.channels = [] class LndNode(Node): @@ -90,9 +89,10 @@ def __init__(self, config_file: Optional[str] = None, self._routerrpc = None self.connect_rpcs() + self.set_info() self.network = Network(self) + self.set_channel_summary() self.update_blockheight() - self.set_info() def connect_rpcs(self): """ @@ -252,6 +252,8 @@ def send_to_route(self, route: 'Route', payment_hash: bytes, raise OurNodeFailure("Not enough funds?") if failure.code == 15: raise exceptions.TemporaryChannelFailure(payment) + elif failure.code == 19: + raise exceptions.TemporaryNodeFailure(payment) elif failure.code == 14: raise exceptions.ChannelDisabled(payment) elif failure.code == 18: @@ -296,6 +298,7 @@ def set_info(self): self.num_active_channels = raw_info.num_active_channels self.num_peers = raw_info.num_peers + def set_channel_summary(self): # TODO: remove the following code and implement an advanced status all_channels = self.get_open_channels( active_only=False, public_only=False) @@ -353,23 +356,28 @@ def get_open_channels(self, active_only=False, public_only=False) \ # age could be zero right after channel becomes pending sent_received_per_week = 0 - # determine policy + # determine policy: try: edge_info = self.network.edges[c.chan_id] # interested in node2 + policies = edge_info['policies'] if edge_info['node1_pub'] == self.pub_key: - policy_peer = edge_info['node2_policy'] - policy_local = edge_info['node1_policy'] + policy_peer = policies[edge_info['node2_pub'] > edge_info['node1_pub']] + policy_local = policies[edge_info['node1_pub'] > edge_info['node2_pub']] else: # interested in node1 - policy_peer = edge_info['node1_policy'] - policy_local = edge_info['node2_policy'] + policy_peer = policies[edge_info['node1_pub'] > edge_info['node2_pub']] + policy_local = policies[edge_info['node2_pub'] > edge_info['node1_pub']] except KeyError: # if channel is unknown in describegraph # we need to set the fees to some error value - policy_peer = {'fee_base_msat': float(-999), - 'fee_rate_milli_msat': float(999)} - policy_local = {'fee_base_msat': float(-999), - 'fee_rate_milli_msat': float(999)} + policy_peer = { + 'fee_base_msat': float(-999), + 'fee_rate_milli_msat': float(999) + } + policy_local = { + 'fee_base_msat': float(-999), + 'fee_rate_milli_msat': float(999) + } # calculate last update (days ago) def convert_to_days_ago(timestamp): @@ -410,6 +418,8 @@ def convert_to_days_ago(timestamp): 'peer_fee_rate': policy_peer['fee_rate_milli_msat'], 'local_base_fee': policy_local['fee_base_msat'], 'local_fee_rate': policy_local['fee_rate_milli_msat'], + 'local_chan_reserve_sat': c.local_chan_reserve_sat, + 'remote_chan_reserve_sat': c.remote_chan_reserve_sat, 'initiator': c.initiator, 'last_update': last_update, 'last_update_local': last_update_local, @@ -450,7 +460,7 @@ def get_inactive_channels(self): channels = self.get_open_channels(public_only=False, active_only=False) return {k: c for k, c in channels.items() if not c['active']} - def get_all_channels(self): + def get_all_channels(self, excluded_channels: List[int] = None): """ Returns all active and inactive channels. @@ -459,7 +469,7 @@ def get_all_channels(self): channels = self.get_open_channels(public_only=False, active_only=False) return channels - def get_unbalanced_channels(self, unbalancedness_greater_than=0.0): + def get_unbalanced_channels(self, unbalancedness_greater_than=0.0, excluded_channels: List[int] = None, public_only=True, active_only=True): """ Gets all channels which have an absolute unbalancedness (-1...1, -1 for outbound unbalanced, 1 for inbound unbalanced) @@ -469,12 +479,13 @@ def get_unbalanced_channels(self, unbalancedness_greater_than=0.0): :return: all channels which are more unbalanced than the specified interval """ self.public_active_channels = \ - self.get_open_channels(public_only=True, active_only=True) - unbalanced_channels = { + self.get_open_channels(public_only=public_only, active_only=active_only) + channels = { k: c for k, c in self.public_active_channels.items() if abs(c['unbalancedness']) >= unbalancedness_greater_than } - return unbalanced_channels + channels = {k: v for k, v in channels.items() if k not in (excluded_channels if excluded_channels else [])} + return channels def get_channel_fee_policies(self): """ diff --git a/lndmanage/lib/pathfinding.py b/lndmanage/lib/pathfinding.py index 00d3e40..125ba56 100644 --- a/lndmanage/lib/pathfinding.py +++ b/lndmanage/lib/pathfinding.py @@ -1,4 +1,4 @@ -import queue +from typing import Callable, List import networkx as nx @@ -9,115 +9,15 @@ logger.addHandler(logging.NullHandler()) -def ksp_discard_high_cost_paths(graph, source, target, num_k, weight): - """ - Wrapper for calculating k shortest paths given a weight function and discards paths with penalties. +def dijkstra(graph: nx.Graph, source: str, target: str, weight: Callable) -> List[str]: + """Wrapper for calculating a shortest path given a weight function. :param graph: networkx graph - :param source: pubkey - :param target: pubkey - :param num_k: number of paths + :param source: find a path from this key + :param target: to this key :param weight: weight function, takes u (pubkey from), v (pubkey to), e (edge information) as arguments - :return: num_k lists of node pub_keys defining the path - """ - final_routes = [] - routes, route_costs = ksp(graph, source, target, num_k, weight) - logger.debug("Approximate costs [msat] of routes:") - for r, rc in zip(routes, route_costs): - if rc < settings.PENALTY: - logger.debug(f" {rc} msat: {r}") - final_routes.append(r) - return final_routes - - -# ksp algorithm is based on https://gist.github.com/ALenfant/5491853 -def path_cost(graph, path, weight=None): - pathcost = 0 - # print(path) - for i in range(len(path)): - if i > 0: - edge = (path[i-1], path[i]) - if callable(weight): - try: - e = graph[path[i-1]][path[i]] - except KeyError: - e = None - pathcost += weight(path[i-1], path[i], e) - else: - if weight != None: - pathcost += graph.get_edge_data(*edge)[weight] - else: - pathcost += 1 - #print("pathcost", pathcost) - #print() - return pathcost - - -# ksp algorithm is based on https://gist.github.com/ALenfant/5491853 -def ksp(graph, source, target, num_k, weight=None): - graph_copy = graph.copy() - # Shortest path from the source to the target - A = [nx.shortest_path(graph_copy, source, target, weight=weight)] - A_costs = [path_cost(graph_copy, A[0], weight)] - - # Initialize the heap to store the potential kth shortest path - B = queue.PriorityQueue() - - for k in range(1, num_k): - # The spur node ranges from the first node to the next to last node in the shortest path - try: - for i in range(len(A[k-1])-1): - # Spur node is retrieved from the previous k-shortest path, k - 1 - spurNode = A[k-1][i] - # The sequence of nodes from the source to the spur node of the previous k-shortest path - rootPath = A[k-1][:i] - # We store the removed edges - removed_edges = [] - - for path in A: - if len(path) - 1 > i and rootPath == path[:i]: - # Remove the links that are part of the previous shortest paths which share the same root path - edge = (path[i], path[i+1]) - if not graph_copy.has_edge(*edge): - continue - removed_edges.append((edge, graph_copy.get_edge_data(*edge))) - graph_copy.remove_edge(*edge) - - # Calculate the spur path from the spur node to the sink - try: - spurPath = nx.shortest_path(graph_copy, spurNode, target, weight=weight) - - # Entire path is made up of the root path and spur path - totalPath = rootPath + spurPath - totalPathCost = path_cost(graph_copy, totalPath, weight) - # Add the potential k-shortest path to the heap - B.put((totalPathCost, totalPath)) - - except nx.NetworkXNoPath: - pass - - #Add back the edges that were removed from the graph - #for removed_edge in removed_edges: - # print(removed_edge) - # graph_copy.add_edge( - # *removed_edge[0], - # **removed_edge[1] - # ) - - # Sort the potential k-shortest paths by cost - # B is already sorted - # Add the lowest cost path becomes the k-shortest path. - while True: - try: - cost_, path_ = B.get(False) - if path_ not in A: - A.append(path_) - A_costs.append(cost_) - break - except queue.Empty: - break - except IndexError: - pass - - return A, A_costs + :return: hops in terms of the node keys + """ + path = nx.shortest_path(graph, source, target, weight=weight) + return path diff --git a/lndmanage/lib/rating.py b/lndmanage/lib/rating.py index 2203df2..90a5886 100644 --- a/lndmanage/lib/rating.py +++ b/lndmanage/lib/rating.py @@ -1,24 +1,33 @@ +import math +from typing import TYPE_CHECKING + from lndmanage import settings import logging + logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) +if TYPE_CHECKING: + from lndmanage.lib.network import Network -class ChannelRater(object): - """ - The purpose of this class is to hold information about the balancedness of channels. - It also defines the cost function of a channel - with the function :func:`ChannelRater.lightning_channel_weight`. + +class ChannelRater: + """The purpose of this class is to hold information about the balancedness of + channels. It also defines the cost function of a channel with the function + :func:`ChannelRater.lightning_channel_weight`. """ - def __init__(self): - self.bad_channels = {} - self.bad_nodes = [] + def __init__(self, network: "Network", source: str = None): + self.blacklisted_channels = {} + self.blacklisted_nodes = [] + self.source = source + self.network = network + self.reference_fee_rate_milli_msat = 10 def node_to_node_weight(self, u, v, e, amt_msat): - """ - Is used to assign a weight for a channel. It is calculated from the fee policy of that channel: + """Is used to assign a weight for a channel. It is calculated from the fee + policy of that channel: base fee + proportional fee + path penalty + capacity penalty + blacklist penalty :param u: source node @@ -28,86 +37,64 @@ def node_to_node_weight(self, u, v, e, amt_msat): :return: cost of the channel in msat """ node_penalty = 0 - if u in self.bad_nodes or v in self.bad_nodes: + if u in self.blacklisted_nodes or v in self.blacklisted_nodes: node_penalty = settings.PENALTY - costs = [node_penalty + self.channel_weight(eattr, amt_msat) for eattr in e.values()] + costs = [ + node_penalty + self.channel_weight(u, v, edge_properties, amt_msat) + for edge_properties in e.values() + ] return min(costs) - def channel_weight(self, e, amt_msat): - long_path_penalty = 0 - if settings.PREFER_SHORT_PATHS: - long_path_penalty = settings.LONG_PATH_PENALTY_MSAT - - cost = (long_path_penalty - + e.get('fees')['fee_base_msat'] - + amt_msat * e.get('fees')['fee_rate_milli_msat'] // 1000000 - + self.capacity_penalty(amt_msat, e.get('capacity')) - + self.disabled_penalty(e.get('fees')) - + self.already_failed_penalty(e.get('channel_id'))) - return cost - - def add_bad_channel(self, channel, source, target): - """ - Adds a channel to the blacklist dict. + def channel_weight(self, u, v, e, amt_msat): + # check if channel is blacklisted + if self.blacklisted_channels.get(e["channel_id"]) == {"source": u, "target": v}: + return math.inf + # we don't send if the channel cannot carry the payment + if amt_msat // 1000 > e["capacity"]: + return math.inf + # we don't send over channel if it is disabled + policy = e.get("fees")[u > v] + if policy["disabled"]: + return math.inf + # we don't pay fees if we own the channel and are sending over it + if self.source and u == self.source: + return 0 + # compute liquidity penalty + liquidity_penalty = self.network.liquidity_hints.penalty( + u, v, e, amt_msat, self.reference_fee_rate_milli_msat + ) + # compute fees and add penalty + fees = ( + policy["fee_base_msat"] + + amt_msat + * ( + abs(policy["fee_rate_milli_msat"] - self.reference_fee_rate_milli_msat) + + self.reference_fee_rate_milli_msat + ) + // 1_000_000 + ) + + return liquidity_penalty + fees + + def blacklist_add_channel(self, channel: int, source: str, target: str): + """Adds a channel to the blacklist dict. :param channel: channel_id :param source: pubkey :param target: pubkey """ - self.bad_channels[channel] = { - 'source': source, - 'target': target, + self.blacklisted_channels[channel] = { + "source": source, + "target": target, } - logger.debug(f"bad channels so far: {self.get_bad_channels()}") - def add_bad_node(self, node_pub_key): - """ - Adds a node public key to the blacklist. + def reset_channel_blacklist(self): + self.blacklisted_channels = {} - :param node_pub_key: str - """ - self.bad_nodes.append(node_pub_key) - logger.debug(f"bad nodes so far: {self.bad_nodes}") - - def get_bad_channels(self): - return self.bad_channels.keys() + def blacklist_add_node(self, node_pub_key): + """Adds a node public key to the blacklist. - def already_failed_penalty(self, channel_id): - """ - Determines if the channel already failed at some point and penalizes it. - - :param channel_id: - """ - # TODO: consider also direction - if channel_id in self.get_bad_channels(): - return settings.PENALTY - else: - return 0 - - @staticmethod - def capacity_penalty(amt_msat, capacity_sat): - """ - Gives a penalty to channels which have too low capacity. - - :param amt_msat - :param capacity_sat in sat - :return: penalty - """ - if capacity_sat < 0.50 * amt_msat // 1000 : - return settings.PENALTY - else: - return 0 - - @staticmethod - def disabled_penalty(policy): - """ - Gives a penalty to channels which are not active. - - :param policy: policy of the channel, contains state - :return: high penalty + :param node_pub_key: str """ - if policy['disabled']: - return settings.PENALTY - else: - return 0 + self.blacklisted_nodes.append(node_pub_key) diff --git a/lndmanage/lib/rebalance.py b/lndmanage/lib/rebalance.py index 16b1147..90fb421 100644 --- a/lndmanage/lib/rebalance.py +++ b/lndmanage/lib/rebalance.py @@ -1,6 +1,7 @@ +"""Module for channel rebalancing.""" import logging import math -from typing import TYPE_CHECKING, Optional, List, Tuple +from typing import TYPE_CHECKING, Optional, Tuple, Dict from lndmanage.lib.routing import Router from lndmanage.lib import exceptions @@ -8,13 +9,11 @@ RebalanceFailure, NoRoute, NoRebalanceCandidates, - RebalanceCandidatesExhausted, + NotEconomic, RebalancingTrialsExhausted, DryRun, PaymentTimeOut, TooExpensive, - DuplicateRoute, - MultichannelInboundRebalanceFailure, ) from lndmanage import settings @@ -24,48 +23,55 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) +DEFAULT_MAX_FEE_RATE = 0.001000 +DEFAULT_AMOUNT_SAT = 500000 +# MIN_FEE_RATE_AFFORDABLE is the minimal effective fee rate a rebalance attempt can cost +RESERVED_REBALANCE_FEE_RATE = 50 + + class Rebalancer(object): - """ - Implements methods for rebalancing. - - A max_effective_fee_rate can be set, which limits the fee rate paid for - a rebalance. The effective fee rate is (base_fee + fee_rate * amt)/amt. - A fee cap can be defined by budget_sat. Individual and total rebalancing - are not allowed to go over this amount. - """ - def __init__(self, node: 'LndNode', max_effective_fee_rate: float, budget_sat: int): + """Implements methods for rebalancing.""" + + def __init__(self, node: 'LndNode', max_effective_fee_rate: float, budget_sat: int, force=False): """ :param node: node instance - :param max_effective_fee_rate: maximum effective fee rate paid - :param budget_sat: rebalancing budget + :param max_effective_fee_rate: maximum effective fee rate (base_fee + fee_rate * amt)/amt paid + :param budget_sat: maximal rebalancing budget + :param force: allow uneconomic routes / rebalance candidates """ self.node = node - self.channel_list = {} + self.channels = {} self.router = Router(self.node) - self.router.channel_rater.add_bad_node(self.node.pub_key) + # we don't want to route over our node, so blacklist it: + self.node.network.channel_rater.blacklist_add_node(self.node.pub_key) self.max_effective_fee_rate = max_effective_fee_rate self.budget_sat = budget_sat - - def rebalance_two_channels( - self, channel_id_from: int, channel_id_to: int, amt_sat: int, - payment_hash: bytes, payment_address: bytes, budget_sat: int, - dry=False + self.force = force + + def _rebalance( + self, + send_channels: Dict[int, dict], + receive_channels: Dict[int, dict], + amt_sat: int, + payment_hash: bytes, + payment_address: bytes, + budget_sat: int, + dry=False, ) -> int: - """ - Rebalances from channel_id_from to channel_id_to with an amount of + """Rebalances liquidity from send_channels to receive_channels with an amount of amt_sat. A prior created payment_hash has to be given. The budget_sat sets the maxmimum fees in sat that will be paid. A dry run can be done. - :param channel_id_from: channel sending - :param channel_id_to: channel receiving + :param send_channels: channels for sending with info + :param receive_channels: channels for receiving with info :param amt_sat: amount to be sent in sat :param payment_hash: payment hash - :param payment_address: payment hash - :param budget_sat: budget for the rebalance - :param dry: specifies if dry run + :param payment_address: payment secret + :param budget_sat: budget for the rebalance in sat + :param dry: specifies, if it is a dry run :return: total fees for the whole rebalance in msat @@ -76,77 +82,90 @@ def rebalance_two_channels( :raises TooExpensive: the circular payment would exceed the fee limits :raises PaymentTimeOut: the payment timed out :raises DryRun: attempt was just a dry run + :raises NotEconomic: we would effectively loose money due to not enough expected + earnings in the future """ + # be up to date with the blockheight, otherwise could lead to cltv errors + self.node.update_blockheight() amt_msat = amt_sat * 1000 - previous_route_channel_hops = None count = 0 while True: - # only attempt a fixed number of times count += 1 if count > settings.REBALANCING_TRIALS: raise RebalancingTrialsExhausted - # method is set to external-mc to use mission control based - # pathfinding - routes = self.router.get_routes_for_rebalancing( - channel_id_from, channel_id_to, amt_msat, method='external-mc') - - if not routes: + route = self.router.get_route(send_channels, receive_channels, amt_msat) + if not route: raise NoRoute - else: - # take only the first route from routes - r = routes[0] - - if previous_route_channel_hops == r.channel_hops: - raise DuplicateRoute("Have tried this route already.") - previous_route_channel_hops = r.channel_hops + effective_fee_rate = route.total_fee_msat / route.total_amt_msat logger.info( - f"Next route: total fee: {r.total_fee_msat / 1000:3.3f} sat, " - f"fee rate: {r.total_fee_msat / r.total_amt_msat:1.6f}, " - f"hops: {len(r.channel_hops)}") - logger.info(f" Channel hops: {r.channel_hops}") - - rate = r.total_fee_msat / r.total_amt_msat - if rate > self.max_effective_fee_rate: - logger.info( - f" Channel is too expensive (rate too high). Rate: {rate:.6f}, " - f"requested max rate: {self.max_effective_fee_rate:.6f}") - raise TooExpensive - - if r.total_fee_msat > budget_sat * 1000: - logger.info( - f" Channel is too expensive (budget exhausted). Total fee of route: " - f"{r.total_fee_msat / 1000:.3f} sat, budget: {budget_sat:.3f} sat") - raise TooExpensive + f">>> Route summary: amount: {(route.total_amt_msat - route.total_fee_msat) / 1000:3.3f} " + f"sat, total fee: {route.total_fee_msat / 1000:3.3f} sat, " + f"fee rate: {effective_fee_rate:1.6f}, " + f"number of hops: {len(route.channel_hops)}") + logger.debug(f" Channel hops: {route.channel_hops}") + + # check if route makes sense + illiquid_channel_id = route.channel_hops[-1] + illiquid_channel = self.channels[illiquid_channel_id] + liquid_channel_id = route.channel_hops[0] + liquid_channel = self.channels[liquid_channel_id] + assert illiquid_channel_id in list(receive_channels.keys()), "receiving channel should be in receive list" + assert liquid_channel_id in list(send_channels.keys()), "sending channel should be in send list" + + # check economics + fee_rate_margin = (illiquid_channel['local_fee_rate'] - liquid_channel['local_fee_rate']) / 1_000_000 + logger.info(f" expected gain: {(fee_rate_margin - effective_fee_rate) * amt_sat:3.3f} sat") + if (effective_fee_rate > fee_rate_margin) and not self.force: + raise NotEconomic("This rebalance attempt doesn't lead to enough expected earnings.") + + if effective_fee_rate > self.max_effective_fee_rate: + raise TooExpensive(f"Route is too expensive (rate too high). Rate: {effective_fee_rate:.6f}, " + f"requested max rate: {self.max_effective_fee_rate:.6f}") + + if route.total_fee_msat > budget_sat * 1000: + raise TooExpensive(f"Route is too expensive (budget exhausted). Total fee of route: " + f"{route.total_fee_msat / 1000:.3f} sat, budget: {budget_sat:.3f} sat") + + def report_success_up_to_failed_hop(failed_hop_index: Optional[int]): + """Reports routing success to liquidity hints up to failed index, exclusively.""" + for hop, channel in enumerate(route.hops): + if failed_hop_index and hop == failed_hop_index: + break + source_node = route.node_hops[hop] + target_node = route.node_hops[hop + 1] + self.node.network.liquidity_hints.update_can_send(source_node, target_node, channel['chan_id'], amt_msat) - result = None - failed_hop = None if not dry: try: - result = self.node.send_to_route(r, payment_hash, payment_address) + result = self.node.send_to_route(route, payment_hash, payment_address) except PaymentTimeOut: raise PaymentTimeOut + # TODO: check whether the failure source is correctly attributed except exceptions.TemporaryChannelFailure as e: failed_hop = int(e.payment.failure.failure_source_index) + except exceptions.TemporaryNodeFailure as e: + failed_hop = int(e.payment.failure.failure_source_index) except exceptions.UnknownNextPeer as e: - failed_hop = int(e.payment.failure.failure_source_index + 1) + failed_hop = int(e.payment.failure.failure_source_index) except exceptions.ChannelDisabled as e: - failed_hop = int(e.payment.failure.failure_source_index + 1) + failed_hop = int(e.payment.failure.failure_source_index) except exceptions.FeeInsufficient as e: failed_hop = int(e.payment.failure.failure_source_index) else: logger.debug(f"Preimage: {result.preimage.hex()}") logger.info("Success!\n") - return r.total_fee_msat + report_success_up_to_failed_hop(failed_hop_index=None) + return route.total_fee_msat if failed_hop: - failed_channel_id = r.hops[failed_hop]['chan_id'] - failed_node_source = r.node_hops[failed_hop] - failed_node_target = r.node_hops[failed_hop + 1] + failed_channel_id = route.hops[failed_hop]['chan_id'] + failed_source = route.node_hops[failed_hop] + failed_target = route.node_hops[failed_hop + 1] - if failed_channel_id in [channel_id_from, channel_id_to]: + if failed_channel_id in [send_channels, receive_channels]: raise RebalanceFailure( f"Own channel failed. Something is wrong. " f"This is likely due to a wrong accounting " @@ -156,119 +175,81 @@ def rebalance_two_channels( f"Failing channel: {failed_channel_id}") # determine the nodes involved in the channel - logger.info(f" Failed channel: {failed_channel_id}") - logger.debug( - f" Failed channel between nodes " - f"{failed_node_source} and " - f"{failed_node_target}") - logger.debug(f" Node hops {r.node_hops}") - logger.debug(f" Channel hops {r.channel_hops}") - - # remember the bad channel for next routing - self.router.channel_rater.add_bad_channel( - failed_channel_id, failed_node_source, - failed_node_target) - else: # dry + logger.info(f" Failed: hop: {failed_hop + 1}, channel: {failed_channel_id}") + logger.info(f" Could not reach {failed_target} ({self.node.network.node_alias(failed_target)})\n") + logger.debug(f" Node hops {route.node_hops}") + logger.debug(f" Channel hops {route.channel_hops}") + + # report that channel could not route the amount to liquidity hints + self.node.network.liquidity_hints.update_cannot_send( + failed_source, failed_target, failed_channel_id, amt_msat) + + # report all the previous hops that they could route the amount + report_success_up_to_failed_hop(failed_hop) + else: raise DryRun - def _get_rebalance_candidates(self, channel_id: int, local_balance_change: int, - allow_unbalancing=False, strategy=Optional[str]): + def _get_rebalance_candidates( + self, + channel_id: int, + channel_fee_rate_milli_msat: int, + local_balance_change: int, + ) -> Dict: """ Determines channels, which can be used to rebalance a channel. If the local_balance_change is negative, the local balance of the to be balanced channel is tried to be reduced. This method determines - channels with which we can rebalance by ideally balancing also the - counterparty. However, this is not always possible so one can also - specify to allow unbalancing until an unbalancedness of - UNBALANCED_CHANNEL and no more. One can also specify a strategy, - which determines the order of channels of the rebalancing process. + channels with which we can rebalance by considering channels which have enough + funds and for which rebalancing makes economically sense based on fee rates. :param channel_id: the channel id of the to be rebalanced channel + :param channel_fee_rate_milli_msat: the local fee rate of the to be rebalanced + channel :param local_balance_change: amount by which the local balance of channel should change in sat - :param allow_unbalancing: if unbalancing of channels should be allowed - :param strategy: - None: By default, counterparty channels are sorted such that the - ones unbalanced in the opposite direction are chosen first, - such that they also get balanced. After them also the other - channels, unbalanced in the non-ideal direction are tried - (if allowed by allow_unbalancing). - 'feerate': Channels are sorted by increasing peer fee rate. - 'affordable': Channels are sorted by the affordable amount. - :type strategy: str - - :return: list of channels - :rtype: list - """ - rebalance_candidates = [] - - # select the proper lower bound of unbalancedness we allow for - # the rebalance candidate channel (allows for unbalancing a channel) - if allow_unbalancing: - # target is a bit into the non-ideal direction - lower_bound = -settings.UNBALANCED_CHANNEL - else: - # target is perfect balance - lower_bound = 0 - # TODO: allow for complete depletion - - # determine the direction we use the rebalance candidate for: - # -1: rebalance channel is sending, 1: rebalance channel is receiving - direction = -math.copysign(1, local_balance_change) - - # logic to shift the bounds accordingly into the different - # rebalancing directions - for k, c in self.channel_list.items(): - if direction * c['unbalancedness'] > lower_bound: - if allow_unbalancing: - c['amt_affordable'] = int( - c['amt_to_balanced'] + - direction * settings.UNBALANCED_CHANNEL - * c['capacity'] / 2) - else: - c['amt_affordable'] = c['amt_to_balanced'] - rebalance_candidates.append(c) - - # filter channels, which can't afford a rebalance - rebalance_candidates = [ - c for c in rebalance_candidates if not c['amt_affordable'] == 0] - - # filters by max_effective_fee_rate, as this is the minimal fee rate - # to be paid - rebalance_candidates = [ - c for c in rebalance_candidates - if self._effective_fee_rate( - c['amt_affordable'], c['peer_base_fee'], c['peer_fee_rate']) - < self.max_effective_fee_rate] - - # need to make sure we don't rebalance with the same channel - rebalance_candidates = [ - c for c in rebalance_candidates if c['chan_id'] != channel_id] - - # need to remove multiply connected nodes, if the counterparty channel - # should receive (can't control last hop) - if local_balance_change < 0: - rebalance_candidates = [ - c for c in rebalance_candidates - if not self._node_is_multiple_connected(c['remote_pubkey'])] - - if strategy == 'most-affordable-first': - rebalance_candidates.sort( - key=lambda x: direction * x['amt_affordable'], reverse=True) - elif strategy == 'lowest-feerate-first': - rebalance_candidates.sort( - key=lambda x: x['peer_fee_rate']) - elif strategy == 'match-unbalanced': - rebalance_candidates.sort( - key=lambda x: -direction * x['unbalancedness']) - else: - rebalance_candidates.sort( - key=lambda x: direction * x['amt_to_balanced'], reverse=True) - # TODO: for each rebalance candidate calculate the shortest path - # with absolute fees and sort - return rebalance_candidates + :return: rebalance candidates with information + """ + # update the channel list to reflect up-to-date balances + self.channels = self.node.get_unbalanced_channels() + + # need to make sure we don't rebalance with the same channel or other channels + # of the same node + map_channel_id_node_id = self.node.get_channel_id_to_node_id() + rebalance_node_id = map_channel_id_node_id[channel_id] + removed_channels = [cid for cid, nid in map_channel_id_node_id.items() if nid == rebalance_node_id] + rebalance_candidates = { + k: c for k, c in self.channels.items() if k not in removed_channels} + + # filter channels, which can't receive/send the amount + candidate_balance = 'remote_balance' if local_balance_change < 0 else 'local_balance' + # TODO: include channel reserve + rebalance_candidates = {k: c for k, c in rebalance_candidates.items() if c[candidate_balance] > abs(local_balance_change)} + + # We only include rebalance candidates for which it makes economically sense to + # rebalance. This is determined by the difference in fee rates: + # For an increase in the local balance of a channel, we need to decrease the + # local balance in another one: + # the fee rate margin is: + # fee_rate[rebalance_channel] - fee_rate[candidate_channel], + # it always needs to be positive at least + rebalance_candidates_filtered = {} + for k, c in rebalance_candidates.items(): + if not self.force: # we allow only economic candidates + if local_balance_change < 0: # we take liquidity out of the channel + fee_rate_margin = c['local_fee_rate'] - channel_fee_rate_milli_msat + else: # putting liquidity into the channel + fee_rate_margin = channel_fee_rate_milli_msat - c['local_fee_rate'] + # We enforce a mimimum amount of an acceptable fee rate, + # because we need to also pay for rebalancing. + if fee_rate_margin > RESERVED_REBALANCE_FEE_RATE: + c['fee_rate_margin'] = fee_rate_margin / 1_000_000 + rebalance_candidates_filtered[k] = c + else: # otherwise, we can afford very high fee rates + c['fee_rate_margin'] = float('inf') + rebalance_candidates_filtered[k] = c + return rebalance_candidates_filtered @staticmethod def _effective_fee_rate(amt_sat: int, base_fee: float, fee_rate: float) -> float: @@ -287,7 +268,7 @@ def _effective_fee_rate(amt_sat: int, base_fee: float, fee_rate: float) -> float return fee_rate @staticmethod - def _print_rebalance_candidates(rebalance_candidates: List[dict]): + def _debug_rebalance_candidates(rebalance_candidates: Dict[int, dict]): """ Prints rebalance candidates. @@ -301,66 +282,31 @@ def _print_rebalance_candidates(rebalance_candidates: List[dict]): "aaf: amount affordable [sat]\n" "l: local balance [sat]\n" "r: remote balance [sat]\n" - "bf: peer base fee [msat]\n" - "fr: peer fee rate\n" + "lbf: local base fee [msat]\n" + "lfr: local fee rate\n" + "frm: fee rate margin\n" "a: alias" ) logger.debug(f"-------- Candidates in order of rebalance attempts " f"--------") - for c in rebalance_candidates: + for c in rebalance_candidates.values(): logger.debug( f"cid:{c['chan_id']} " f"ub:{c['unbalancedness']: 4.2f} " - f"atb:{c['amt_to_balanced']: 9d} " - f"aaf:{c['amt_affordable']: 9d} " f"l:{c['local_balance']: 9d} " f"r:{c['remote_balance']: 9d} " - f"bf:{c['peer_base_fee']: 6d} " - f"fr:{c['peer_fee_rate']/1E6: 1.6f} " + f"lbf:{c['local_base_fee']: 6d} " + f"lfr:{c['local_fee_rate']/1E6: 1.6f} " + f"fra:{c.get('fee_rate_margin'): 1.6f} " f"a:{c['alias']}") - def _extract_channel_info(self, chan_id: int) -> dict: - """ - Gets the channel info (policy, capacity, nodes) from the graph. - :param chan_id: channel id - - :return: channel information - """ - # TODO: make more pythonic - channel_info = None - for k, c in self.channel_list.items(): - if c['chan_id'] == chan_id: - channel_info = c - if channel_info is None: - raise KeyError("Channel not found (already closed?)") - return channel_info - @staticmethod - def _get_source_and_target_channels(channel_one: int, channel_two: int, - rebalance_direction: float) -> Tuple[int, int]: - """ - Determines what the sending and receiving channel ids are. - - :param channel_one: first channel - :param channel_two: second channel - :param rebalance_direction: positive, if receiving, negative if sending - :return: sending and receiving channel - """ - if rebalance_direction < 0: - source = channel_one - target = channel_two - else: - source = channel_two - target = channel_one - - return source, target - - @staticmethod - def _maximal_local_balance_change(unbalancedness_target: float, - unbalanced_channel_info: dict) -> int: - """ - Tries to find out the amount to maximally send/receive given the + def _maximal_local_balance_change( + unbalancedness_target: float, + unbalanced_channel_info: dict + ) -> int: + """Tries to find out the amount to maximally send/receive given the relative target and channel reserve constraints for channel balance candidates. @@ -435,15 +381,14 @@ def _maximal_local_balance_change(unbalancedness_target: float, return amt_target_original def _node_is_multiple_connected(self, pub_key: str) -> bool: - """ - Checks if the node is connected to us via several channels. + """Checks if the node is connected to us via several channels. :param pub_key: node public key :return: true if number of channels to the node is larger than 1 """ n_channels = 0 - for pk, chan_info in self.channel_list.items(): + for pk, chan_info in self.channels.items(): if chan_info['remote_pubkey'] == pub_key: n_channels += 1 if n_channels > 1: @@ -451,32 +396,31 @@ def _node_is_multiple_connected(self, pub_key: str) -> bool: else: return False - def rebalance(self, channel_id: int, dry=False, chunksize=1.0, target=Optional[float], - allow_unbalancing=False, strategy=Optional[str]): - """ - Automatically rebalances a selected channel with a fee cap of + def rebalance( + self, + channel_id: int, + dry=False, + target: float = None, + amount_sat: int = None, + ) -> int: + """Automatically rebalances a selected channel with a fee cap of self.budget_sat and self.max_effective_fee_rate. - Rebalancing candidates are selected among all channels which are - unbalanced in the other direction. - Uses :func:`self.rebalance_two_channels` for rebalancing a pairs - of channels. - - At the moment, rebalancing channels are tried one at a time, - so it is not yet optimized for the lowest possible fees. + Rebalancing candidates are selected among all channels that support the + rebalancing operation (liquidity-wise), which are economically viable + (controlled through self.force) determined by fees rates of + counterparty channels. - The chunksize allows for partitioning of the individual rebalancing - attempts into smaller pieces than would maximally possible. The smaller - the chunksize the higher is the success rate, but the rebalancing - cost increases. + The rebalancing operation is carried out in these steps: + 0. determine balancing amount, determine rebalance direction + 1. determine counterparty channels for the balancing amount + 2. try to rebalance with the cheapest route (taking into account different metrics including fees) + 3. if it doesn't work for several attempts, go to 1. with a reduced amount - :param channel_id: - :param dry: if set, then there's a dry run - :param chunksize: a number between 0 and 1 + :param channel_id: the id of the channel to be rebalanced + :param dry: if set, it's a dry run :param target: specifies unbalancedness after rebalancing in [-1, 1] - :param allow_unbalancing: allows counterparty channels - to get a little bit unbalanced - :param strategy: lets you select a strategy for rebalancing order + :param amount_sat: rebalance amount (target takes precedence) :return: fees in msat paid for rebalancing @@ -488,11 +432,52 @@ def rebalance(self, channel_id: int, dry=False, chunksize=1.0, target=Optional[f candidates :raises TooExpensive: the rebalance became too expensive """ - if not (0.0 <= chunksize <= 1.0): - raise ValueError("Chunk size must be between 0.0 and 1.0.") - if not (-1.0 <= target <= 1.0): + if target and not (-1.0 <= target <= 1.0): raise ValueError("Target must be between -1.0 and 1.0.") + # get a fresh channel list + self.channels = self.node.get_unbalanced_channels() + try: + unbalanced_channel_info = self.channels[channel_id] + except KeyError: + raise RebalanceFailure("Channel not known or inactive.") + + # 0. determine the amount we want to send/receive on the channel + if target is not None: + # if a target is given and it is set close to -1 or 1, + # then we need to think about the channel reserve + # TODO: check maximal local balance change + initial_local_balance_change = self._maximal_local_balance_change( + target, unbalanced_channel_info) + if abs(initial_local_balance_change) <= 10_000: + logger.info(f"Channel already balanced.") + return 0 + # if no target is given, we enforce some default amount + else: + if not amount_sat: + amount_sat = DEFAULT_AMOUNT_SAT + initial_local_balance_change = int(math.copysign(1, unbalanced_channel_info['unbalancedness']) * amount_sat) + + # determine budget and fee rate from local balance change: + # budget fee_rate result + # x 0 set fee rate + # 0 x set budget + # x x max(budget, budget from fee rate): set fee rate and budget + # 0 0 set defaults from a fee rate + net_change = abs(initial_local_balance_change) + if self.budget_sat and not self.max_effective_fee_rate: + self.max_effective_fee_rate = self.budget_sat / net_change + elif not self.budget_sat and self.max_effective_fee_rate: + self.budget_sat = int(self.max_effective_fee_rate * net_change) + elif self.budget_sat and self.max_effective_fee_rate: + budget_from_fee_rate = int(net_change * self.max_effective_fee_rate) + budget = max(budget_from_fee_rate, self.budget_sat) + self.budget_sat = budget + self.max_effective_fee_rate = budget / net_change + else: + self.budget_sat = int(DEFAULT_MAX_FEE_RATE * net_change) + self.max_effective_fee_rate = DEFAULT_MAX_FEE_RATE + logger.info(f">>> Trying to rebalance channel {channel_id} " f"with a max rate of {self.max_effective_fee_rate} " f"and a max fee of {self.budget_sat} sat.") @@ -500,30 +485,9 @@ def rebalance(self, channel_id: int, dry=False, chunksize=1.0, target=Optional[f if dry: logger.info(f">>> This is a dry run, nothing to fear.") - # get a fresh channel list - self.channel_list = self.node.get_unbalanced_channels() - unbalanced_channel_info = self.channel_list[channel_id] - - # if a target is given and it is set close to -1 or 1, - # then we need to think about the channel reserve - initial_local_balance_change = self._maximal_local_balance_change( - target, unbalanced_channel_info) - - if initial_local_balance_change == 0: - logger.info(f"Channel already balanced.") - return 0 - - # copy the original target + # copy the original target for bookkeeping local_balance_change_left = initial_local_balance_change - # if chunksize is set, rebalance in portions of chunked_amount - chunked_amount = int(initial_local_balance_change * chunksize) - logger.info(f">>> Chunk size is set to {chunksize}. " - f"Results in chunksize of {chunked_amount} sat.") - - # determine rebalance direction 1: receive, -1: send (of channel_id) - rebalance_direction = math.copysign(1, initial_local_balance_change) - logger.info( f">>> The channel status before rebalancing is " f"lb:{unbalanced_channel_info['local_balance']} sat " @@ -533,153 +497,129 @@ def rebalance(self, channel_id: int, dry=False, chunksize=1.0, target=Optional[f f">>> Commit fee {unbalanced_channel_info['commit_fee']} " f"sat. We opened channel: {unbalanced_channel_info['initiator']}. " f"Channel reserve: " - f"{int(unbalanced_channel_info['capacity'] * 0.01)} sat.") + f"{unbalanced_channel_info['local_chan_reserve_sat']} sat.") logger.debug( f">>> The change in local balance towards the requested " f"target (ub={target if target else 0.0:3.2f})" f" is {initial_local_balance_change} sat.") - # a commit fee is only accounted for, if we opened the channel - commit_fee = 0 - if unbalanced_channel_info['initiator']: - commit_fee = unbalanced_channel_info['commit_fee'] + # the fee rate that is charged by the to-be-rebalanced channel + fee_rate_milli_msat = unbalanced_channel_info['local_fee_rate'] + + # a channel reserve is only accounted for, if we opened the channel + reserve_sat = 0 if not unbalanced_channel_info['initiator'] else unbalanced_channel_info['local_chan_reserve_sat'] expected_target = -2 * (( unbalanced_channel_info['local_balance'] + - initial_local_balance_change + commit_fee) / + initial_local_balance_change + reserve_sat) / float(unbalanced_channel_info['capacity'])) + 1 - logger.info( - f">>> Trying to change the local balance by " - f"{initial_local_balance_change} sat.\n" - f" The expected unbalancedness target is " - f"{expected_target:3.2f} (respecting channel reserve), " - f"requested target is {0 if not target else target:3.2f}.") + info_str = f">>> Trying to change the local balance by " \ + f"{initial_local_balance_change} sat.\n" \ + f" The expected unbalancedness target is " \ + f"{expected_target:3.2f} (respecting channel reserve)" + if target is not None: + info_str += f", requested target is {target:3.2f}." + logger.info(info_str) node_is_multiple_connected = self._node_is_multiple_connected( unbalanced_channel_info['remote_pubkey']) if initial_local_balance_change > 0 and node_is_multiple_connected: - # TODO: this might be too strict, figure out exact behavior - raise MultichannelInboundRebalanceFailure( - "Receiving-rebalancing of multiple " - "connected node channel not supported.\n" - "The reason is that the last hop " - "(channel) can't be controlled by us.\n" - "See https://github.com/lightningnetwork/" - "lnd/issues/2966 and \n" - "https://github.com/lightningnetwork/" - "lightning-rfc/blob/master/" - "04-onion-routing.md#non-strict-forwarding.\n" - "Tip: keep only the best channel to " - "the node and then rebalance.") - - rebalance_candidates = self._get_rebalance_candidates( - channel_id, local_balance_change_left, - allow_unbalancing=allow_unbalancing, strategy=strategy) - if len(rebalance_candidates) == 0: - raise NoRebalanceCandidates( - "Didn't find counterparty rebalance candidates.") - - logger.info( - f">>> There are {len(rebalance_candidates)} channels with which " - f"we can rebalance (look at logs).") - - self._print_rebalance_candidates(rebalance_candidates) - - logger.info( - f">>> We will try to rebalance with them one after another.") - logger.info( - f">>> NOTE: only individual rebalance requests " - f"are optimized for fees:\n" - f" this means that there can be rebalances with " - f"less fees afterwards,\n" - f" so take a look at the dry runs first, " - f"i.e. without the --reckless flag,\n" - f" and set --max-fee-sat and --max-fee-rate accordingly.\n" - f" You may also specify a rebalancing strategy " - f"by the --strategy flag.") - logger.info( - f">>> Rebalancing can take some time. Please be patient!\n") - - total_fees_msat = 0 - - # loop over the rebalancing candidates - for c in rebalance_candidates: - - if total_fees_msat >= self.budget_sat * 1000: + logger.info( + ">>> Note: You try to send liquidity to a channel of a node you are\n" + " connected to with multiple channels. We cannot control over which\n" + " channel the funds are sent back to us due to so-called non-strict\n" + " forwarding, see:\n" + " https://github.com/lightningnetwork/lightning-rfc/blob/master/" + "04-onion-routing.md#non-strict-forwarding.\n") + + logger.info(f">>> Rebalancing can take some time. Please be patient!\n") + + total_fees_paid_msat = 0 + # amount_sat is the amount we try to rebalance with, which is gradually reduced + # to improve in rebalance success probability + amount_sat = local_balance_change_left + budget_sat = self.budget_sat + + # we try to rebalance with amount_sat until we reach the desired total local + # balance change + while abs(local_balance_change_left) >= 0: + # 1. determine counterparty rebalance candidates + rebalance_candidates = self._get_rebalance_candidates( + channel_id, fee_rate_milli_msat, amount_sat) + + if len(rebalance_candidates) == 0: + raise NoRebalanceCandidates( + "Didn't find counterparty rebalance candidates.") + + self._debug_rebalance_candidates(rebalance_candidates) + + if total_fees_paid_msat >= self.budget_sat * 1000: raise TooExpensive( f"Fee budget exhausted. " - f"Total fees {total_fees_msat / 1000:.3f} sat.") - - source_channel, target_channel = \ - self._get_source_and_target_channels( - channel_id, c['chan_id'], rebalance_direction) - - # counterparty channel affords less than total rebalance amount - if abs(local_balance_change_left) > abs(c['amt_affordable']): - sign = math.copysign(1, c['amt_affordable']) - minimal_amount = sign * min( - abs(chunked_amount), abs(c['amt_affordable'])) - amt = -int(rebalance_direction * minimal_amount) - # counterparty channel affords more than total rebalance amount - else: - sign = math.copysign(1, local_balance_change_left) - minimal_amount = sign * min( - abs(local_balance_change_left), abs(chunked_amount)) - amt = int(rebalance_direction * minimal_amount) - # amt must be always positive - if amt < 0: - raise RebalanceCandidatesExhausted( - f"Amount should not be negative! amt:{amt} sat") + f"Total fees {total_fees_paid_msat / 1000:.3f} sat.") logger.info( - f"-------- Rebalance from {source_channel} to {target_channel}" - f" with {amt} sat --------") - logger.info( - f"Need to still change the local balance by " + f">>> Need to still change the local balance by " f"{local_balance_change_left} sat to reach the goal " f"of {initial_local_balance_change} sat. " - f"Fees paid up to now: {total_fees_msat / 1000:.3f} sat.") + f"Fees paid up to now: {total_fees_paid_msat / 1000:.3f} sat.") - # for each rebalance attempt, get a new invoice + # for each rebalance amount, get a new invoice invoice = self.node.get_invoice( - amt_msat=amt*1000, + amt_msat=abs(amount_sat) * 1000, memo=f"lndmanage: Rebalance of channel {channel_id}.") payment_hash, payment_address = invoice.r_hash, invoice.payment_addr + + # set sending and receiving channels + if amount_sat < 0: # we send over the channel + send_channels = {channel_id: unbalanced_channel_info} + receive_channels = rebalance_candidates + else: # we receive over the channel + send_channels = rebalance_candidates + # TODO: we could also extend the receive channels to all channels with the node + receive_channels = {channel_id: unbalanced_channel_info} + # attempt the rebalance try: - # be up to date with the blockheight, otherwise could lead - # to cltv errors - self.node.update_blockheight() - total_fees_msat += self.rebalance_two_channels( - source_channel, target_channel, amt, payment_hash, payment_address, - self.budget_sat, dry=dry) - local_balance_change_left -= int(rebalance_direction * amt) + rebalance_fees_msat = self._rebalance( + send_channels, receive_channels, abs(amount_sat), payment_hash, + payment_address, budget_sat, dry=dry) + + # account for running costs / target + budget_sat -= rebalance_fees_msat // 1000 + total_fees_paid_msat += rebalance_fees_msat + local_balance_change_left -= amount_sat relative_amt_to_go = (local_balance_change_left / initial_local_balance_change) + # if we succeeded to rebalance, we start again at a higher amount + amount_sat = local_balance_change_left + # perfect rebalancing is not always possible, # so terminate if at least 90% of amount was reached if relative_amt_to_go <= 0.10: logger.info( f"Goal is reached. Rebalancing done. " - f"Total fees were {total_fees_msat / 1000:.3f} sat.") - return total_fees_msat - except NoRoute: - logger.error( - "There was no reliable route with enough capacity.\n") + f"Total fees were {total_fees_paid_msat / 1000:.3f} sat.") + return total_fees_paid_msat except DryRun: logger.info( "Would have tried this route now, but it was a dry run.\n") - except TooExpensive: - logger.error( - "Too expensive, check --max-fee-rate and --max-fee-sat.\n") - except RebalanceFailure: - logger.error( - "Failed to rebalance with this channel.\n") - - raise RebalanceCandidatesExhausted( - "There are no further counterparty rebalance channel candidates " - "for this channel.\n") + return 0 + except (RebalancingTrialsExhausted, NoRoute, TooExpensive) as e: + logger.info(e) + # We have attempted to rebalance a lot of times or didn't find a route + # with the current amount. To improve the success rate, we split the + # amount. + amount_sat //= 2 + if abs(amount_sat) < 30_000: + raise RebalanceFailure( + "It is unlikely we can rebalance the channel. Attempts with " + "small amounts already failed.\n") + logger.info( + f"Could not rebalance with this amount. Decreasing amount to " + f"{amount_sat} sat.\n" + ) diff --git a/lndmanage/lib/routing.py b/lndmanage/lib/routing.py index d70fe04..198c111 100644 --- a/lndmanage/lib/routing.py +++ b/lndmanage/lib/routing.py @@ -1,8 +1,12 @@ -from lndmanage.lib.rating import ChannelRater +from typing import List, Dict, TYPE_CHECKING, Tuple + from lndmanage.lib.exceptions import RouteWithTooSmallCapacity, NoRoute -from lndmanage.lib.pathfinding import ksp_discard_high_cost_paths +from lndmanage.lib.pathfinding import dijkstra from lndmanage import settings +if TYPE_CHECKING: + from lndmanage.lib.node import LndNode + import logging logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) @@ -13,16 +17,18 @@ def calculate_fees_on_policy(amt_msat, policy): class Route(object): + """Deals with the onion route construction from list of channels. Calculates fees + and cltvs. """ - Deals with the onion route construction from list of channels. Calculates fees and cltvs. - :param node: :class:`lib.node.Node` instance - :param channel_hops: list of chan_ids along which the route shall be constructed - :param node_dest: pub_key of destination node - :param amt_msat: amount to send in msat - """ + def __init__(self, node: 'LndNode', channel_hops: List[int], node_dest: str, amt_msat: int): + """ + :param node: :class:`lib.node.Node` instance + :param channel_hops: list of chan_ids along which the route shall be constructed + :param node_dest: pub_key of destination node + :param amt_msat: amount to send in msat + """ - def __init__(self, node, channel_hops, node_dest, amt_msat): self.node = node self.blockheight = node.blockheight logger.debug(f"Blockheight: {self.blockheight}") @@ -41,35 +47,37 @@ def __init__(self, node, channel_hops, node_dest, amt_msat): # hops are traversed in backwards direction to accumulate fees and cltvs for ichannel, channel_id in enumerate(reversed(channel_hops)): channel_data = self.node.network.edges[channel_id] + # TODO: add private channels for sending if amt_msat // 1000 > channel_data['capacity']: logger.debug(f"Discovered a channel {channel_id} with too small capacity.") raise RouteWithTooSmallCapacity(f"Amount too large for channel.") + policies = channel_data['policies'] if node_right == channel_data['node2_pub']: try: - policy = channel_data['node1_policy'] + policy = policies[channel_data['node1_pub'] > channel_data['node2_pub']] node_left = channel_data['node1_pub'] except KeyError: logger.exception(f"No channel {channel_data}") else: - policy = channel_data['node2_policy'] + policy = policies[channel_data['node2_pub'] > channel_data['node1_pub']] node_left = channel_data['node2_pub'] self._node_hops.append(node_left) - - logger.debug(f"Route from {node_left}") - logger.debug(f" to {node_right}") - logger.debug(f"Policy of forwarding node: {policy}") + hop = len(channel_hops) - ichannel + logger.info(f" Hop {hop}: {channel_id} (cap: {channel_data['capacity']} sat): " + f"{self.node.network.node_alias(node_right)} <- {self.node.network.node_alias(node_left)} ") + logger.debug(f" Policy of forwarding node: {policy}") fees_msat = policy['fee_base_msat'] + policy['fee_rate_milli_msat'] * forward_msat // 1000000 forward_msat = amt_msat + sum(fees_msat_container[:ichannel]) fees_msat_container.append(fees_msat) - logger.debug(f"Hop: {len(channel_hops) - ichannel}") - logger.debug(f" Fees: {fees_msat}") - logger.debug(f" Fees container {fees_msat_container}") - logger.debug(f" Forward: {forward_msat}") + logger.info(f" Fees: {fees_msat / 1000 if not hop == 1 else 0:3.3f} sat") + logger.debug(f" Fees container {fees_msat_container}") + logger.debug(f" Forward: {forward_msat / 1000:3.3f} sat") + logger.info(f" Liquidity penalty: {self.node.network.liquidity_hints.penalty(node_left, node_right, channel_data, amt_msat, self.node.network.channel_rater.reference_fee_rate_milli_msat) / 1000: 3.3f} sat") self._hops.append({ 'chan_id': channel_data['channel_id'], @@ -91,9 +99,7 @@ def __init__(self, node, channel_hops, node_dest, amt_msat): self.total_time_lock = sum(cltv_delta[:-1]) + self.blockheight + final_cltv def _debug_route(self): - """ - Prints detailed information of the route. - """ + """Prints detailed information of the route.""" logger.debug("Debug route:") for h in self.hops: logger.debug(f"c:{h['chan_id']} a:{h['amt_to_forward']} f:{h['fee_msat']}" @@ -107,19 +113,14 @@ def _debug_route(self): class Router(object): - """ - Contains utilities for constructing routes. + """Contains utilities for constructing routes.""" - :param node: :class:`lib.node.Node` instance - """ - - def __init__(self, node): + def __init__(self, node: 'LndNode'): self.node = node - self.channel_rater = ChannelRater() - def _node_route_to_channel_route(self, node_route, amt_msat): - """ - Takes a route in terms of a list of nodes and translates it into a list of channels. + def _node_route_to_channel_route(self, node_route: List[str], amt_msat: int) -> List[int]: + """Takes a route in terms of a list of nodes and translates it into a list of + channels. :param node_route: list of pubkeys :param amt_msat: amount to send in sat @@ -128,35 +129,32 @@ def _node_route_to_channel_route(self, node_route, amt_msat): channels = [] for p in range(len(node_route) - 1): channels.append( - self._determine_cheapest_channel_between_two_nodes( + self._determine_channel( node_route[p], node_route[p + 1], amt_msat)[1]) return channels - def get_routes_from_to_nodes(self, node_from, node_to, amt_msat, number_of_routes=10): - """ - Determines number_of_routes shortest paths between node_from and node_to for an amount of amt_msat. + def get_route_from_to_nodes(self, node_from: str, node_to: str, amt_msat: int) -> List[str]: + """Determines number_of_routes shortest paths between node_from and node_to for + an amount of amt_msat. :param node_from: pubkey :param node_to: pubkey :param amt_msat: amount to send in msat - :param number_of_routes: int - :return: number_of_routes lists of node pubkeys + :return: route """ - self.channel_rater.bad_nodes.append(self.node.pub_key) # excludes self-loops + self.node.network.channel_rater.blacklisted_nodes.append(self.node.pub_key) # excludes self-loops - weight_function = lambda v, u, e: self.channel_rater.node_to_node_weight(v, u, e, amt_msat) - routes = ksp_discard_high_cost_paths( - self.node.network.graph, node_from, node_to, - num_k=number_of_routes, weight=weight_function) + weight_function = lambda v, u, e: self.node.network.channel_rater.node_to_node_weight(v, u, e, amt_msat) + route = dijkstra(self.node.network.graph, node_from, node_to, weight=weight_function) - if not routes: + if not route: raise NoRoute - return routes + return route - def _determine_cheapest_channel_between_two_nodes(self, node_from, node_to, amt_msat): - """ - Determines the cheapest channel between nodes node_from and node_to for an amount of amt_msat. + def _determine_channel(self, node_from: str, node_to: str, amt_msat: int): + """Determines the cheapest channel between nodes node_from and node_to for an + amount of amt_msat. :param node_from: pubkey :param node_to: pubkey @@ -167,155 +165,116 @@ def _determine_cheapest_channel_between_two_nodes(self, node_from, node_to, amt_ channels_with_calculated_fees = [] for n in range(number_edges): edge = self.node.network.graph.get_edge_data(node_from, node_to, n) - fees = self.channel_rater.channel_weight(edge, amt_msat) + fees = self.node.network.channel_rater.channel_weight(node_from, node_to, edge, amt_msat) channels_with_calculated_fees.append([fees, edge['channel_id']]) sorted_channels = sorted(channels_with_calculated_fees, key=lambda k: k[0]) - return sorted_channels[0] - - def _determine_cheapest_fees_between_two_nodes(self, node_from, node_to, amt): - return self._determine_cheapest_channel_between_two_nodes(node_from, node_to, amt)[0] - - def get_route_channel_hops_from_to_node_internal(self, source_pubkey, target_pubkey, amt_msat): - """ - Find routes internally, using networkx to construct a route from a source node to a target node. - - :param source_pubkey: str - :param target_pubkey: str - :param amt_msat: int - :return: - """ - logger.debug(f"Internal route finding:") + best_channel = sorted_channels[0] + # we check that we don't encounter a hop which is blacklisted + if best_channel[0] == float('inf'): + raise NoRoute('channels graph exhausted') + return best_channel + + def _determine_cheapest_fees_between_two_nodes(self, node_from, node_to, amt_msat): + return self._determine_channel(node_from, node_to, amt_msat)[0] + + def get_route_channel_hops_from_to_node_internal( + self, + source_pubkey: str, + target_pubkey: str, + amt_msat: int + ) -> List[int]: + """Find routes internally, using networkx to construct a route from a source + node to a target node.""" + logger.debug(f"Internal pathfinding:") logger.debug(f"from {source_pubkey}") logger.debug(f" to {target_pubkey}") - node_routes = self.get_routes_from_to_nodes( - source_pubkey, target_pubkey, amt_msat, number_of_routes=1) + node_route = self.get_route_from_to_nodes( + source_pubkey, target_pubkey, amt_msat) - hops = self._node_route_to_channel_route(node_routes[0], amt_msat) + return self._node_route_to_channel_route(node_route, amt_msat) - # logger.debug(f"(Intermediate) route as channel hops: {hops}") - return [hops] + def get_route( + self, + send_channels: Dict[int, dict], + receive_channels: Dict[int, dict], + amt_msat: int + ) -> Route: + """Calculates a route from send_channels to receive_channels. - def get_route_channel_hops_from_to_node_external( - self, source_pubkey, target_pubkey, amt_msat, use_mc=False): - """ - Find routes externally (relying on the node api) to construct a route - from a source node to a target node. - - :param source_pubkey: source public key - :type source_pubkey: str - :param target_pubkey: target public key - :type target_pubkey: str - :param amt_msat: amount to send in msat - :type amt_msat: int - :param use_mc: true if mission control based pathfinding is used - :type use_mc: bool - :return: list of hops - :rtype: list[list[int]] - """ - logger.debug(f"External pathfinding, using mission control: {use_mc}.") - logger.debug(f"from {source_pubkey}") - logger.debug(f" to {target_pubkey}") - ignored_nodes = self.channel_rater.bad_nodes - - # we don't need to give blacklisted channels to the queryroute command - # as all of this is done by mission control - if use_mc: - ignored_channels = {} - else: - ignored_channels = self.channel_rater.bad_channels - - hops = self.node.queryroute_external( - source_pubkey, target_pubkey, amt_msat, - ignored_channels=ignored_channels, - ignored_nodes=ignored_nodes, - use_mc=use_mc, - ) - - return [hops] - - def get_routes_for_rebalancing( - self, chan_id_from, chan_id_to, amt_msat, method='external'): - """ - Calculates several routes for channel_id_from to chan_id_to - and optimizes for fees for an amount amt. - - :param chan_id_from: short channel id of the from node - :type chan_id_from: int - :param chan_id_to: short channel id of the to node - :type chan_id_to: int + :param send_channels: channel ids to send from + :param receive_channels: channel ids to receive to :param amt_msat: payment amount in msat - :type amt_msat: int - :param method: specifies if 'internal', or 'external' - method of route computation should be used - :type method: string - :return: list of :class:`lib.routing.Route` instances - :rtype: list[lndmanage.lib.routing.Route] - """ - - try: - channel_from = self.node.network.edges[chan_id_from] - channel_to = self.node.network.edges[chan_id_to] - except KeyError: - logger.exception( - "Channel was not found in network graph, but is present in " - "listchannels. Channel needs 6 confirmations to be usable.") - raise NoRoute - - this_node = self.node.pub_key - # find the correct node_pubkeys between which we want to route - # fist hop:start-end ----- last hop: start-end - if channel_from['node1_pub'] == this_node: - first_hop_end = channel_from['node2_pub'] - else: - first_hop_end = channel_from['node1_pub'] - - if channel_to['node1_pub'] == this_node: - last_hop_start = channel_to['node2_pub'] + :return: a route for rebalancing + """ + this_node = self.node.pub_key # TODO: make this a parameter for general route calculation + self.node.network.channel_rater.reset_channel_blacklist() + + # We will ask for a route from source to target. + # we send via a send channel and receive over other channels: + # this_node -(send channel)-> source -> ... -> receiver neighbors -(receive channels)-> target (this_node) + if len(send_channels) == 1: + send_channel = list(send_channels.values())[0] + source = send_channel['remote_pubkey'] + target = this_node + + # we don't want to go backwards via the send_channel (and other parallel channels) + channels_source_target = self.node.network.graph[source][target] + for channel in channels_source_target.values(): + self.node.network.channel_rater.blacklist_add_channel(channel['channel_id'], source, target) + + # we want to use the receive channels for receiving only, so don't receive over other channels + excluded_receive_channels = self.node.get_unbalanced_channels( + excluded_channels=[k for k in receive_channels.keys()], public_only=False, active_only=False) + for channel_id, channel in excluded_receive_channels.items(): + receiver_neighbor = channel['remote_pubkey'] + self.node.network.channel_rater.blacklist_add_channel(channel_id, receiver_neighbor, target) + + # we send via several channels and receive over a single one: + # this_node (source) -(send channels)-> ... -> receiver neighbor (target) -(receive channel)-> this_node + elif len(receive_channels) == 1: + receive_channel = list(receive_channels.values())[0] + source = this_node + target = receive_channel['remote_pubkey'] + + # we want to block the receiving channel (and parallel ones) from sending + channels_source_target = self.node.network.graph[source][target] + for channel in channels_source_target.values(): + self.node.network.channel_rater.blacklist_add_channel(channel['channel_id'], source, target) + + # we want to use the send channels for sending only, so don't send over other channels + excluded_send_channels = self.node.get_unbalanced_channels( + excluded_channels=[k for k in send_channels.keys()], public_only=False, active_only=False) + for channel_id, channel in excluded_send_channels.items(): + sender_neighbor = channel['remote_pubkey'] + self.node.network.channel_rater.blacklist_add_channel(channel_id, source, sender_neighbor) else: - last_hop_start = channel_to['node1_pub'] + raise ValueError("One of the two channel sets should be singular.") # determine inner channel hops # internal method uses networkx dijkstra, # this is more independent, but slower - if method == 'internal': - routes_channel_hops = \ - self.get_route_channel_hops_from_to_node_internal( - first_hop_end, last_hop_start, amt_msat) - # rely on external pathfinding with internal blacklisting - elif method == 'external': - routes_channel_hops = \ - self.get_route_channel_hops_from_to_node_external( - first_hop_end, last_hop_start, amt_msat, use_mc=False) - # rely on external pathfinding using mission control - elif method == 'external-mc': - routes_channel_hops = \ - self.get_route_channel_hops_from_to_node_external( - first_hop_end, last_hop_start, amt_msat, use_mc=True) + route_channel_hops = \ + self.get_route_channel_hops_from_to_node_internal( + source, target, amt_msat) + + final_channel_hops = [] + if len(send_channels) == 1: + final_channel_hops.append(send_channel['chan_id']) + final_channel_hops.extend(route_channel_hops) else: - raise ValueError( - f"Method must be either internal, external or external-mc, " - f"is {method}.") - - # pre- and append the outgoing and incoming channels to the route - routes_channel_hops_final = [] - for r in routes_channel_hops: - r.insert(0, chan_id_from) - r.append(chan_id_to) - logger.debug("Channel hops:") - logger.debug(r) - routes_channel_hops_final.append(r) + final_channel_hops.extend(route_channel_hops) + final_channel_hops.append(receive_channel['chan_id']) + + # TODO: add some consistency checks, route shouldn't contain self-loops + logger.debug("Channel hops:") + logger.debug(final_channel_hops) # initialize Route objects with appropriate fees and expiries - routes = [] - for h in routes_channel_hops: - try: - route = Route(self.node, h, this_node, amt_msat) - routes.append(route) - except RouteWithTooSmallCapacity: - continue - return routes + route = Route(self.node, final_channel_hops, this_node, amt_msat) + + return route if __name__ == '__main__': diff --git a/lndmanage/lndmanage.py b/lndmanage/lndmanage.py index a743acb..6a87994 100755 --- a/lndmanage/lndmanage.py +++ b/lndmanage/lndmanage.py @@ -20,7 +20,7 @@ from lndmanage.lib.lncli import Lncli from lndmanage.lib.node import LndNode from lndmanage.lib.openchannels import ChannelOpener -from lndmanage.lib.rebalance import Rebalancer +from lndmanage.lib.rebalance import Rebalancer, DEFAULT_MAX_FEE_RATE, DEFAULT_AMOUNT_SAT from lndmanage.lib.recommend_nodes import RecommendNodes from lndmanage.lib.report import Report from lndmanage import settings @@ -162,15 +162,14 @@ def __init__(self): self.parser_rebalance.add_argument('channel', type=int, help='channel_id') self.parser_rebalance.add_argument( - '--max-fee-sat', type=int, default=20, + '--max-fee-sat', type=int, default=None, help='Sets the maximal fees in satoshis to be paid.') self.parser_rebalance.add_argument( - '--chunksize', type=float, default=1.0, - help='Specifies if the individual rebalance attempts should be ' - 'split into smaller relative amounts. This increases success' - ' rates, but also increases costs!') + '--amount-sat', type=int, default=DEFAULT_AMOUNT_SAT, + help='Specifies the rebalance amount in satoshis. The direction is ' + 'determined automatically.') self.parser_rebalance.add_argument( - '--max-fee-rate', type=range_limited_float_type, default=5E-5, + '--max-fee-rate', type=range_limited_float_type, default=DEFAULT_MAX_FEE_RATE, help='Sets the maximal effective fee rate to be paid.' ' The effective fee rate is defined by ' '(base_fee + amt * fee_rate) / amt.') @@ -178,9 +177,8 @@ def __init__(self): '--reckless', help='Execute action in the network.', action='store_true') self.parser_rebalance.add_argument( - '--allow-unbalancing', - help=f'Allow channels to get an unbalancedness' - f' up to +-{settings.UNBALANCED_CHANNEL}.', + '--force', + help=f"Allow rebalances that are uneconomic.", action='store_true') self.parser_rebalance.add_argument( '--target', help=f'This feature is still experimental! ' @@ -188,35 +186,7 @@ def __init__(self): f'A target of -1 leads to a maximal local balance, a target of 0 ' f'to a 50:50 balanced channel and a target of 1 to a maximal ' f'remote balance. Default is a target of 0.', - type=unbalanced_float, default=0.0) - rebalancing_strategies = ['most-affordable-first', - 'lowest-feerate-first', 'match-unbalanced'] - self.parser_rebalance.add_argument( - '--strategy', - help=f'Rebalancing strategy.', - choices=rebalancing_strategies, type=str, default=None) - - # cmd: circle - self.parser_circle = subparsers.add_parser( - 'circle', help='circular self-payment', - formatter_class=argparse.ArgumentDefaultsHelpFormatter) - self.parser_circle.add_argument('channel_from', type=int, - help='channel_from') - self.parser_circle.add_argument('channel_to', type=int, - help='channel_from') - self.parser_circle.add_argument('amt_sat', type=int, - help='amount in satoshis') - self.parser_circle.add_argument( - '--max-fee-sat', type=int, default=20, - help='Sets the maximal fees in satoshis to be paid.') - self.parser_circle.add_argument( - '--max-fee-rate', type=range_limited_float_type, default=5E-5, - help='Sets the maximal effective fee rate to be paid. ' - 'The effective fee rate is defined by ' - '(base_fee + amt * fee_rate) / amt.') - self.parser_circle.add_argument( - '--reckless', help='Execute action in the network.', - action='store_true') + type=unbalanced_float, default=None) # cmd: recommend-nodes self.parser_recommend_nodes = subparsers.add_parser( @@ -542,15 +512,14 @@ def run_commands(self, node, args): if args.target: logger.warning("Warning: Target is set, this is still an " "experimental feature.") - rebalancer = Rebalancer(node, args.max_fee_rate, args.max_fee_sat) + rebalancer = Rebalancer(node, args.max_fee_rate, args.max_fee_sat, args.force) try: rebalancer.rebalance( args.channel, dry=not args.reckless, - chunksize=args.chunksize, target=args.target, - allow_unbalancing=args.allow_unbalancing, - strategy=args.strategy) + amount_sat=args.amount_sat + ) except TooExpensive as e: logger.error(f"Too expensive: {e}") except RebalanceFailure as e: @@ -558,29 +527,6 @@ def run_commands(self, node, args): except KeyboardInterrupt: pass - elif args.cmd == 'circle': - rebalancer = Rebalancer(node, args.max_fee_rate, args.max_fee_sat) - invoice = node.get_rebalance_invoice(memo='circular payment') - payment_hash, payment_address = invoice.r_hash, invoice.payment_addr - try: - rebalancer.rebalance_two_channels( - args.channel_from, args.channel_to, - args.amt_sat, payment_hash, payment_address, - args.max_fee_sat, - dry=not args.reckless) - except DryRun: - logger.info("This was just a dry run.") - except TooExpensive: - logger.error( - "Too expensive: consider to raise --max-fee-sat or " - "--max-fee-rate.") - except RebalancingTrialsExhausted: - logger.error( - f"Rebalancing trials exhausted (number of trials: " - f"{settings.REBALANCING_TRIALS}).") - except PaymentTimeOut: - logger.error("Payment failed because the payment timed out.") - elif args.cmd == 'recommend-nodes': if not args.subcmd: self.parser_recommend_nodes.print_help() diff --git a/lndmanage/settings.py b/lndmanage/settings.py index 5f7da08..9025fdc 100644 --- a/lndmanage/settings.py +++ b/lndmanage/settings.py @@ -28,8 +28,7 @@ UNBALANCED_CHANNEL = 0.2 # rebalancing will be done with CHUNK_SIZE of the minimal capacity # of the to be balanced channels -CHUNK_SIZE = 1.0 -REBALANCING_TRIALS = 30 +REBALANCING_TRIALS = 10 logger_config = None diff --git a/test/graph_definitions/routing_graph.py b/test/graph_definitions/routing_graph.py new file mode 100644 index 0000000..5f72b37 --- /dev/null +++ b/test/graph_definitions/routing_graph.py @@ -0,0 +1,199 @@ +""" +Implements a lightning network topology: + + 3 + A --- B + | 2/ | + 6 | E | 1 + | /5 \7 | + D --- C + 4 + +All fees are equal. + +valid routes from A -> E: +A -3-> B -2-> E +A -6-> D -5-> E +A -6-> D -4-> C -7-> E +A -3-> B -1-> C -7-> E +A -6-> D -4-> C -1-> B -2-> E +A -3-> B -1-> C -4-> D -5-> E +""" + +nodes = { + 'A': { + 'grpc_port': 11009, + 'rest_port': 8080, + 'port': 9735, + 'channels': { + 3: { + 'to': 'B', + 'capacity': 1_000_000, + 'ratio_local': 10, + 'ratio_remote': 0, + 'policies': { + 'A' > 'B': { + 'fee_base_msat': 1000, + 'fee_rate_milli_msat': 100, + 'time_lock_delta': 40, + 'disabled': False, + }, + 'B' > 'A': { + 'fee_base_msat': 1000, + 'fee_rate_milli_msat': 100, + 'time_lock_delta': 40, + 'disabled': False, + } + } + }, + 6: { + 'to': 'D', + 'capacity': 2_000_000, + 'ratio_local': 5, + 'ratio_remote': 5, + 'policies': { + 'A' > 'D': { + 'fee_base_msat': 1000, + 'fee_rate_milli_msat': 100, + 'time_lock_delta': 40, + 'disabled': False, + }, + 'D' > 'A': { + 'fee_base_msat': 1000, + 'fee_rate_milli_msat': 100, + 'time_lock_delta': 40, + 'disabled': False, + } + } + }, + } + }, + 'B': { + 'grpc_port': 11010, + 'rest_port': 8081, + 'port': 9736, + 'channels': { + 2: { + 'to': 'E', + 'capacity': 3000000, + 'ratio_local': 5, + 'ratio_remote': 5, + 'policies': { + 'B' > 'E': { + 'fee_base_msat': 1000, + 'fee_rate_milli_msat': 100, + 'time_lock_delta': 40, + 'disabled': False, + }, + 'E' > 'B': { + 'fee_base_msat': 1000, + 'fee_rate_milli_msat': 100, + 'time_lock_delta': 40, + 'disabled': False, + } + } + }, + 1: { + 'to': 'C', + 'capacity': 10000000, + 'ratio_local': 5, + 'ratio_remote': 5, + 'policies': { + 'B' > 'C': { + 'fee_base_msat': 1000, + 'fee_rate_milli_msat': 100, + 'time_lock_delta': 40, + 'disabled': False, + }, + 'C' > 'B': { + 'fee_base_msat': 1000, + 'fee_rate_milli_msat': 100, + 'time_lock_delta': 40, + 'disabled': False, + } + } + }, + } + }, + 'C': { + 'grpc_port': 11011, + 'rest_port': 8082, + 'port': 9737, + 'channels': { + 7: { + 'to': 'E', + 'capacity': 1000000, + 'ratio_local': 5, + 'ratio_remote': 5, + 'policies': { + 'C' > 'E': { + 'fee_base_msat': 1000, + 'fee_rate_milli_msat': 100, + 'time_lock_delta': 40, + 'disabled': False, + }, + 'E' > 'C': { + 'fee_base_msat': 1000, + 'fee_rate_milli_msat': 100, + 'time_lock_delta': 40, + 'disabled': False, + } + } + }, + 4: { + 'to': 'D', + 'capacity': 2000000, + 'ratio_local': 5, + 'ratio_remote': 5, + 'policies': { + 'C' > 'D': { + 'fee_base_msat': 1000, + 'fee_rate_milli_msat': 100, + 'time_lock_delta': 40, + 'disabled': False, + }, + 'D' > 'C': { + 'fee_base_msat': 1000, + 'fee_rate_milli_msat': 100, + 'time_lock_delta': 40, + 'disabled': False, + } + } + }, + } + }, + 'D': { + 'grpc_port': 11012, + 'rest_port': 8083, + 'port': 9738, + 'channels': { + 5: { + 'to': 'E', + 'capacity': 3_000_000, + 'ratio_local': 10, + 'ratio_remote': 0, + 'policies': { + 'D' > 'E': { + 'fee_base_msat': 1000, + 'fee_rate_milli_msat': 100, + 'time_lock_delta': 40, + 'disabled': False, + }, + 'E' > 'D': { + 'fee_base_msat': 1000, + 'fee_rate_milli_msat': 100, + 'time_lock_delta': 40, + 'disabled': False, + } + } + }, + } + }, + 'E': { + 'grpc_port': 11013, + 'rest_port': 8084, + 'port': 9739, + 'channels': { + } + }, +} diff --git a/test/test_circle.py b/test/test_circle.py index 991e1bd..61487ea 100644 --- a/test/test_circle.py +++ b/test/test_circle.py @@ -1,26 +1,27 @@ """ Tests for circular self-payments. """ +import math import time +from typing import List +import unittest from lndmanage.lib.listings import ListChannels from lndmanage.lib.rebalance import Rebalancer from lndmanage.lib.exceptions import ( - RebalanceFailure, TooExpensive, DryRun, RebalancingTrialsExhausted, NoRoute, - PolicyError, OurNodeFailure, ) from lndmanage import settings -from test.testnetwork import TestNetwork from test.testing_common import ( test_graphs_paths, lndmanage_home, - SLEEP_SEC_AFTER_REBALANCING + SLEEP_SEC_AFTER_REBALANCING, + TestNetwork, ) import logging.config @@ -32,23 +33,26 @@ class CircleTest(TestNetwork): - """ - Implements testing of circular self-payments. + """Implements testing of circular self-payments. """ network_definition = None - def circle_and_check(self, channel_number_send: int, - channel_number_receive: int, amount_sat: int, - expected_fees_msat: int, budget_sat=20, - max_effective_fee_rate=50, dry=False): - """ - Helper function for testing a circular payment. - - :param channel_number_send: channel whose local balance is decreased - :param channel_number_receive: channel whose local balance is increased + def circular_rebalance_and_check( + self, + channel_numbers_send: List[int], + channel_numbers_receive: List[int], + amount_sat: int, + expected_fees_msat: int, + budget_sat=20, + max_effective_fee_rate=50, + dry=False + ): + """Helper function for testing a circular payment. + + :param channel_numbers_send: channels whose local balance is decreased + :param channel_numbers_receive: channels whose local balance is increased :param amount_sat: amount in satoshi to rebalance - :param expected_fees_msat: expected fees in millisatoshi for - the rebalance + :param expected_fees_msat: expected fees in millisatoshi for the rebalance :param budget_sat: budget for rebalancing :param max_effective_fee_rate: the maximal effective fee rate accepted :param dry: if it should be a dry run @@ -58,56 +62,53 @@ def circle_and_check(self, channel_number_send: int, self.lndnode, max_effective_fee_rate=max_effective_fee_rate, budget_sat=budget_sat, + force=True, ) graph_before = self.testnet.assemble_graph() - channel_id_send = self.testnet.channel_mapping[ - channel_number_send]['channel_id'] - channel_id_receive = self.testnet.channel_mapping[ - channel_number_receive]['channel_id'] + send_channels = {} + self.rebalancer.channels = self.lndnode.get_unbalanced_channels() + + for c in channel_numbers_send: + channel_id = self.testnet.channel_mapping[c]['channel_id'] + send_channels[channel_id] = self.rebalancer.channels[channel_id] + receive_channels = {} + for c in channel_numbers_receive: + channel_id = self.testnet.channel_mapping[c]['channel_id'] + receive_channels[channel_id] = self.rebalancer.channels[channel_id] + invoice = self.lndnode.get_invoice(amount_sat, '') payment_hash, payment_address = invoice.r_hash, invoice.payment_addr - try: - fees_msat = self.rebalancer.rebalance_two_channels( - channel_id_send, - channel_id_receive, - amount_sat, - payment_hash, - payment_address, - budget_sat, - dry=dry - ) - time.sleep(SLEEP_SEC_AFTER_REBALANCING) - except Exception as e: - raise e + fees_msat = self.rebalancer._rebalance( + send_channels=send_channels, + receive_channels=receive_channels, + amt_sat=amount_sat, + payment_hash=payment_hash, + payment_address=payment_address, + budget_sat=budget_sat, + dry=dry + ) + + time.sleep(SLEEP_SEC_AFTER_REBALANCING) # needed to let lnd update the balances graph_after = self.testnet.assemble_graph() - channel_data_send_before = graph_before['A'][channel_number_send] - channel_data_receive_before = graph_before['A'][channel_number_receive] - channel_data_send_after = graph_after['A'][channel_number_send] - channel_data_receive_after = graph_after['A'][channel_number_receive] + + self.assertEqual(expected_fees_msat, fees_msat) + + # check that we send the amount we wanted and that it's conserved + # TODO: this depends on channel reserves, we assume we opened the channels + sent = 0 + received = 0 + for c in channel_numbers_send: + sent += (graph_before['A'][c]['local_balance'] - graph_after['A'][c]['local_balance']) + for c in channel_numbers_receive: + received += (graph_before['A'][c]['remote_balance'] - graph_after['A'][c]['remote_balance']) + assert sent - math.ceil(expected_fees_msat / 1000) == received + listchannels = ListChannels(self.lndnode) listchannels.print_all_channels('rev_alias') - # test that the fees are correct - self.assertEqual(fees_msat, expected_fees_msat) - # test if sending channel's remote balance has increased correctly - self.assertEqual( - amount_sat, - channel_data_send_after['remote_balance'] - - channel_data_send_before['remote_balance'] - - int(expected_fees_msat // 1000), - "Sending local balance is wrong" - ) - # test if receiving channel's local balance has increased correctly - self.assertEqual( - amount_sat, - channel_data_receive_after['local_balance'] - - channel_data_receive_before['local_balance'], - "Receiving local balance is wrong" - ) - def graph_test(self): """ graph_test should be implemented by each subclass test and check, @@ -116,6 +117,7 @@ def graph_test(self): raise NotImplementedError +@unittest.skip class TestCircleLiquid(CircleTest): network_definition = test_graphs_paths['star_ring_3_liquid'] @@ -127,14 +129,14 @@ def test_circle_success_1_2(self): """ Test successful rebalance from channel 1 to channel 2. """ - channel_number_from = 1 - channel_number_to = 2 + channel_numbers_from = [1] + channel_numbers_to = [2] amount_sat = 10000 expected_fees_msat = 43 - self.circle_and_check( - channel_number_from, - channel_number_to, + self.circular_rebalance_and_check( + channel_numbers_from, + channel_numbers_to, amount_sat, expected_fees_msat ) @@ -143,14 +145,14 @@ def test_circle_success_1_6(self): """ Test successful rebalance from channel 1 to channel 6. """ - channel_number_from = 1 - channel_number_to = 6 + channel_numbers_from = [1] + channel_numbers_to = [6] amount_sat = 10000 expected_fees_msat = 33 - self.circle_and_check( - channel_number_from, - channel_number_to, + self.circular_rebalance_and_check( + channel_numbers_from, + channel_numbers_to, amount_sat, expected_fees_msat ) @@ -160,16 +162,16 @@ def test_circle_6_1_fail_rebalance_failure_no_funds(self): Test expected failure for channel 6 to channel 1, where channel 6 doesn't have funds. """ - channel_number_from = 6 - channel_number_to = 1 + channel_numbers_from = [6] + channel_numbers_to = [1] amount_sat = 10000 expected_fees_msat = 33 self.assertRaises( OurNodeFailure, - self.circle_and_check, - channel_number_from, - channel_number_to, + self.circular_rebalance_and_check, + channel_numbers_from, + channel_numbers_to, amount_sat, expected_fees_msat, ) @@ -178,17 +180,17 @@ def test_circle_1_6_fail_budget_too_expensive(self): """ Test expected failure where rebalance uses more than the fee budget. """ - channel_number_from = 1 - channel_number_to = 6 + channel_numbers_from = [1] + channel_numbers_to = [6] amount_sat = 10000 expected_fees_msat = 33 budget_sat = 0 self.assertRaises( TooExpensive, - self.circle_and_check, - channel_number_from, - channel_number_to, + self.circular_rebalance_and_check, + channel_numbers_from, + channel_numbers_to, amount_sat, expected_fees_msat, budget_sat, @@ -199,8 +201,8 @@ def test_circle_1_6_fail_max_fee_rate_too_expensive(self): Test expected failure where rebalance is more expensive than the desired maximal fee rate. """ - channel_number_from = 1 - channel_number_to = 6 + channel_numbers_from = [1] + channel_numbers_to = [6] amount_sat = 10000 expected_fees_msat = 33 budget = 20 @@ -208,9 +210,9 @@ def test_circle_1_6_fail_max_fee_rate_too_expensive(self): self.assertRaises( TooExpensive, - self.circle_and_check, - channel_number_from, - channel_number_to, + self.circular_rebalance_and_check, + channel_numbers_from, + channel_numbers_to, amount_sat, expected_fees_msat, budget, @@ -222,23 +224,26 @@ def test_circle_1_6_success_channel_reserve(self): """ Test for a maximal amount circular payment. """ - channel_number_from = 1 - channel_number_to = 6 + channel_numbers_from = [1] + channel_numbers_to = [6] local_balance = 1000000 # take into account 1% channel reserve amount_sat = int(local_balance - 0.01 * local_balance) - # need to also subtract commitment fees - amount_sat -= 9050 + # need to subtract commitment fee (local + anchor output) + amount_sat -= 3140 + # need to subtract anchor values + amount_sat -= 330 * 2 # need to also subtract fees, then error message changes amount_sat -= 3 - # extra to make it succeed - amount_sat -= 2150 + # extra to make it work for in-between nodes + amount_sat -= 200 + # TODO: figure out exactly the localbalance - fees for initiator - expected_fees_msat = 2938 + expected_fees_msat = 2_959 - self.circle_and_check( - channel_number_from, - channel_number_to, + self.circular_rebalance_and_check( + channel_numbers_from, + channel_numbers_to, amount_sat, expected_fees_msat, ) @@ -247,21 +252,29 @@ def test_circle_1_6_fail_rebalance_dry(self): """ Test if dry run exception is raised. """ - channel_number_from = 1 - channel_number_to = 6 + channel_numbers_from = [1] + channel_numbers_to = [6] amount_sat = 10000 expected_fees_msat = 33 self.assertRaises( DryRun, - self.circle_and_check, - channel_number_from, - channel_number_to, + self.circular_rebalance_and_check, + channel_numbers_from, + channel_numbers_to, amount_sat, expected_fees_msat, dry=True ) + @unittest.skip + def test_multi_send(self): + pass + + @unittest.skip + def test_multi_receive(self): + pass + class TestCircleIlliquid(CircleTest): @@ -271,56 +284,54 @@ def graph_test(self): self.assertEqual(10, len(self.master_node_graph_view)) def test_circle_fail_2_3_no_route(self): - """ - Test if NoRoute is raised. - """ - channel_number_from = 2 - channel_number_to = 3 - amount_sat = 500000 + """Test if NoRoute is raised. We can't go beyond C.""" + channel_numbers_from = [2] # A -> C + channel_numbers_to = [3] # D -> A + amount_sat = 500_000 expected_fees_msat = None self.assertRaises( NoRoute, - self.circle_and_check, - channel_number_from, - channel_number_to, + self.circular_rebalance_and_check, + channel_numbers_from, + channel_numbers_to, amount_sat, expected_fees_msat ) def test_circle_1_2_fail_max_trials_exhausted(self): + """Test if RebalancingTrialsExhausted is raised. + + There will be a single rebalancing attempt, which fails, after which we don't retry. """ - Test if RebalancingTrialsExhausted is raised. - """ - channel_number_from = 1 - channel_number_to = 2 + channel_numbers_from = [1] + channel_numbers_to = [2] amount_sat = 190950 expected_fees_msat = None settings.REBALANCING_TRIALS = 1 self.assertRaises( RebalancingTrialsExhausted, - self.circle_and_check, - channel_number_from, - channel_number_to, + self.circular_rebalance_and_check, + channel_numbers_from, + channel_numbers_to, amount_sat, expected_fees_msat ) def test_circle_1_2_fail_no_route_multi_trials(self): + """Test if NoRoute is raised. """ - Test if RebalancingTrialsExhausted is raised. - """ - channel_number_from = 1 - channel_number_to = 2 + channel_numbers_from = [1] + channel_numbers_to = [2] amount_sat = 450000 expected_fees_msat = None self.assertRaises( RebalancingTrialsExhausted, - self.circle_and_check, - channel_number_from, - channel_number_to, + self.circular_rebalance_and_check, + channel_numbers_from, + channel_numbers_to, amount_sat, expected_fees_msat ) diff --git a/test/test_itest.py b/test/test_itest.py index 074c606..8f57842 100644 --- a/test/test_itest.py +++ b/test/test_itest.py @@ -1,8 +1,7 @@ """ Integration tests for lndmanage. """ -from test.testnetwork import TestNetwork -from test.testing_common import test_graphs_paths +from test.testing_common import test_graphs_paths, TestNetwork class NewNode(TestNetwork): diff --git a/test/test_openchannels.py b/test/test_openchannels.py index 8c4ddae..f5c7c8f 100644 --- a/test/test_openchannels.py +++ b/test/test_openchannels.py @@ -7,11 +7,10 @@ from lndmanage import settings from lndmanage.lib import openchannels -from test.testnetwork import TestNetwork - from test.testing_common import ( lndmanage_home, test_graphs_paths, + TestNetwork, ) import logging.config diff --git a/test/test_pathfinding.py b/test/test_pathfinding.py new file mode 100644 index 0000000..6f39a9d --- /dev/null +++ b/test/test_pathfinding.py @@ -0,0 +1,111 @@ +""" +Integration tests for batch opening of channels. +""" +from typing import Dict +from unittest import TestCase, mock + +from lndmanage.lib.network import Network +from lndmanage.lib.rating import ChannelRater +from lndmanage.lib.pathfinding import dijkstra + +from graph_definitions.routing_graph import nodes as test_graph + + +def new_test_graph(graph: Dict): + # we need to init the node interface with a public key + class MockNode: + pub_key = 'A' + + # we disable cached graph reading + with mock.patch.object(Network, 'cached_reading_graph_edges', return_value=None): + network = Network(MockNode()) + + # add nodes + for node, node_definition in graph.items(): + network.graph.add_node( + node, + alias=node, + last_update=None, + address=None, + color=None) + + # add channels + for node, node_definition in graph.items(): + for channel, channel_definition in node_definition['channels'].items(): + # create a dictionary for channel_id lookups + to_node = channel_definition['to'] + network.edges[channel] = { + 'node1_pub': node, + 'node2_pub': to_node, + 'capacity': channel_definition['capacity'], + 'last_update': None, + 'channel_id': channel, + 'chan_point': channel, + 'policies': { + node > to_node: channel_definition['policies'][node > to_node], + to_node > node: channel_definition['policies'][to_node > node], + } + } + + # add vertices to network graph for edge-based lookups + network.graph.add_edge( + node, + to_node, + channel_id=channel, + last_update=None, + capacity=channel_definition['capacity'], + fees={ + node > to_node: channel_definition['policies'][node > to_node], + to_node > node: channel_definition['policies'][to_node > node], + }) + + return network + + +class TestGraph(TestCase): + def test_network(self): + n = new_test_graph(test_graph) + self.assertEqual(5, n.graph.number_of_nodes()) + self.assertEqual(7, n.graph.number_of_edges()) + + def test_shortest_path(self): + network = new_test_graph(test_graph) + cr = ChannelRater(network) + amt_msat = 1_000_000 + weight_function = lambda v, u, e: cr.node_to_node_weight(v, u, e, amt_msat) + print(dijkstra(network.graph, 'A', 'E', weight=weight_function)) + # TODO: use too high capacity + # TODO: use parallel channels with different policies + + def test_liquidity_hints(self): + """ + 3 + A --- B + | 2/ | + 6 | E | 1 + | /5 \7 | + D --- C + 4 + """ + amt_msat = 100_000 * 1_000 + network = new_test_graph(test_graph) + cr = ChannelRater(network=network) + weight_function = lambda v, u, e: cr.node_to_node_weight(v, u, e, amt_msat) + + path = dijkstra(network.graph, 'A', 'E', weight=weight_function) + self.assertEqual(['A', 'B', 'E'], path) + + # We report that B cannot send to E + network.liquidity_hints.update_cannot_send('B', 'E', 2, 1_000) + path = dijkstra(network.graph, 'A', 'E', weight=weight_function) + self.assertEqual(['A', 'D', 'E'], path) + + # We report that D cannot send to E + network.liquidity_hints.update_cannot_send('D', 'E', 5, 1_000) + path = dijkstra(network.graph, 'A', 'E', weight=weight_function) + self.assertEqual(['A', 'B', 'C', 'E'], path) + + # We report that D can send to C + network.liquidity_hints.update_can_send('D', 'C', 4, amt_msat + 1000) + path = dijkstra(network.graph, 'A', 'E', weight=weight_function) + self.assertEqual(['A', 'D', 'C', 'E'], path) diff --git a/test/test_rebalance.py b/test/test_rebalance.py index 84b6be9..4dc34f1 100644 --- a/test/test_rebalance.py +++ b/test/test_rebalance.py @@ -2,18 +2,19 @@ Integration tests for rebalancing of channels. """ import time +from typing import Optional from lndmanage import settings -from lndmanage.lib.listings import ListChannels from lndmanage.lib.rebalance import Rebalancer from lndmanage.lib.ln_utilities import channel_unbalancedness_and_commit_fee -from lndmanage.lib.exceptions import RebalanceCandidatesExhausted -from test.testnetwork import TestNetwork +from lndmanage.lib.exceptions import NoRebalanceCandidates from test.testing_common import ( lndmanage_home, test_graphs_paths, - SLEEP_SEC_AFTER_REBALANCING) + SLEEP_SEC_AFTER_REBALANCING, + TestNetwork +) import logging.config settings.set_lndmanage_home_dir(lndmanage_home) @@ -27,60 +28,68 @@ class RebalanceTest(TestNetwork): """ Implements an abstract testing class for channel rebalancing. """ - def rebalance_and_check(self, test_channel_number, target, - allow_unbalancing, places=5): + def rebalance_and_check( + self, + test_channel_number: int, + target: Optional[float], + amount_sat: Optional[int], + allow_uneconomic: bool, + places: int = 5, + ): """ Test function for rebalancing to a specific target unbalancedness and asserts afterwards that the target was reached. :param test_channel_number: channel id - :type test_channel_number: int :param target: unbalancedness target - :type target: float - :param allow_unbalancing: if unbalancing should be allowed - :type allow_unbalancing: bool - :param places: accuracy of the comparison between expected and tested - values + :param amount_sat: rebalancing amount + :param allow_uneconomic: if uneconomic rebalancing should be allowed + :param places: accuracy of the comparison between expected and tested values :type places: int """ + graph_before = self.testnet.assemble_graph() + rebalancer = Rebalancer( self.lndnode, - max_effective_fee_rate=50, - budget_sat=20 + max_effective_fee_rate=5E-6, + budget_sat=20, + force=allow_uneconomic, ) channel_id = self.testnet.channel_mapping[ test_channel_number]['channel_id'] - try: - fees_msat = rebalancer.rebalance( - channel_id, - dry=False, - chunksize=1.0, - target=target, - allow_unbalancing=allow_unbalancing - ) - except Exception as e: - raise e + fees_msat = rebalancer.rebalance( + channel_id, + dry=False, + target=target, + amount_sat=amount_sat, + ) # sleep a bit to let LNDs update their balances time.sleep(SLEEP_SEC_AFTER_REBALANCING) # check if graph has the desired channel balances - graph = self.testnet.assemble_graph() - channel_data = graph['A'][test_channel_number] - listchannels = ListChannels(self.lndnode) - listchannels.print_all_channels('rev_alias') + graph_after = self.testnet.assemble_graph() + + channel_data_before = graph_before['A'][test_channel_number] + channel_data_after = graph_after['A'][test_channel_number] + amount_sent = channel_data_before['local_balance'] - channel_data_after['local_balance'] channel_unbalancedness, _ = channel_unbalancedness_and_commit_fee( - channel_data['local_balance'], - channel_data['capacity'], - channel_data['commit_fee'], - channel_data['initiator'] + channel_data_after['local_balance'], + channel_data_after['capacity'], + channel_data_after['commit_fee'], + channel_data_after['initiator'] ) - self.assertAlmostEqual( - target, channel_unbalancedness, places=places) + if target is not None: + self.assertAlmostEqual( + target, channel_unbalancedness, places=places) + + elif amount_sat is not None: + self.assertAlmostEqual( + amount_sat, amount_sent, places=places) return fees_msat @@ -106,52 +115,54 @@ class TestLiquidRebalance(RebalanceTest): def graph_test(self): self.assertEqual(6, len(self.master_node_graph_view)) - def test_rebalance_channel_6(self): + def test_non_init_balanced(self): test_channel_number = 6 - self.rebalance_and_check(test_channel_number, 0.0, False) + self.rebalance_and_check(test_channel_number, target=0.0, amount_sat=None, allow_uneconomic=True) - def test_small_positive_target_channel_6(self): + def test_non_init_small_positive_target(self): test_channel_number = 6 - self.rebalance_and_check(test_channel_number, 0.2, False) + self.rebalance_and_check(test_channel_number, target=0.2, amount_sat=None, allow_uneconomic=True) - def test_large_positive_channel_6(self): + def test_non_init_max_target(self): test_channel_number = 6 - self.rebalance_and_check(test_channel_number, 0.8, False) + self.rebalance_and_check(test_channel_number, target=1.0, amount_sat=None, allow_uneconomic=True) - def test_small_negative_target_channel_6_fail(self): + def test_non_init_negative_target(self): # this test should fail when unbalancing is not allowed, as it would # unbalance another channel if the full target would be accounted for test_channel_number = 6 - self.assertRaises( - RebalanceCandidatesExhausted, - self.rebalance_and_check, test_channel_number, -0.2, False) + self.rebalance_and_check(test_channel_number, target=-0.2, amount_sat=None, allow_uneconomic=True) - def test_small_negative_target_channel_6_succeed(self): + def test_non_init_fail_due_to_economic(self): # this test should fail when unbalancing is not allowed, as it would # unbalance another channel if the full target would be accounted for test_channel_number = 6 - self.rebalance_and_check(test_channel_number, -0.2, True) + self.assertRaises( + NoRebalanceCandidates, + self.rebalance_and_check, test_channel_number, target=-0.2, amount_sat=None, allow_uneconomic=False) - def test_rebalance_channel_1(self): + def test_init_balanced(self): test_channel_number = 1 - self.rebalance_and_check(test_channel_number, 0.0, False, places=2) + self.rebalance_and_check(test_channel_number, target=0.0, amount_sat=None, allow_uneconomic=True, places=1) - def test_rebalance_channel_2(self): + def test_init_already_balanced(self): test_channel_number = 2 - self.rebalance_and_check(test_channel_number, 0.0, False, places=1) + self.rebalance_and_check(test_channel_number, target=0.0, amount_sat=None, allow_uneconomic=True, places=2) + + def test_init_amount(self): + test_channel_number = 1 + self.rebalance_and_check(test_channel_number, target=None, amount_sat=500_000, allow_uneconomic=True, places=-1) def test_shuffle_arround(self): - """ - Shuffles sat around in channel 6. - """ + """Shuffles sats around in channel 6.""" first_target_amount = -0.1 second_target_amount = 0.1 test_channel_number = 6 self.rebalance_and_check( - test_channel_number, first_target_amount, True) + test_channel_number, target=first_target_amount, amount_sat=None, allow_uneconomic=True) self.rebalance_and_check( - test_channel_number, second_target_amount, True) + test_channel_number, target=second_target_amount, amount_sat=None, allow_uneconomic=True) class TestUnbalancedRebalance(RebalanceTest): @@ -166,12 +177,11 @@ class TestUnbalancedRebalance(RebalanceTest): def graph_test(self): self.assertEqual(10, len(self.master_node_graph_view)) - def test_rebalance_channel_1(self): + def test_channel_1(self): """tests multiple rebalance of one channel""" test_channel_number = 1 - # TODO: find out why not exact rebalancing target is reached print(self.rebalance_and_check( - test_channel_number, -0.05, False, places=1)) + test_channel_number, target=-0.05, amount_sat=None, allow_uneconomic=True, places=1)) class TestIlliquidRebalance(RebalanceTest): @@ -186,23 +196,10 @@ class TestIlliquidRebalance(RebalanceTest): def graph_test(self): self.assertEqual(10, len(self.master_node_graph_view)) - def test_rebalance_channel_1(self): - """ - Tests multiple payment attempt rebalancing. - """ + def test_channel_1_splitting(self): + """Tests multiple payment attempts with splitting.""" test_channel_number = 1 - # TODO: find out why not exact rebalancing target is reached fees_msat = self.rebalance_and_check( - test_channel_number, -0.05, False, places=1) - self.assertEqual(2623, fees_msat) + test_channel_number, target=-0.05, amount_sat=None, allow_uneconomic=True, places=1) + self.assertAlmostEqual(2000, fees_msat, places=-3) - def test_rebalance_channel_1_fail(self): - """ - Tests if there are no rebalance candidates, because the target - requested doesn't match with the other channels. - """ - test_channel_number = 1 - self.assertRaises( - RebalanceCandidatesExhausted, self.rebalance_and_check, - test_channel_number, 0.3, False, places=1 - ) diff --git a/test/testing_common.py b/test/testing_common.py index 659a882..4719e5f 100644 --- a/test/testing_common.py +++ b/test/testing_common.py @@ -1,4 +1,9 @@ import os +from unittest import TestCase + +from lnregtest.lib.network import Network + +from lib.node import LndNode from lndmanage import settings settings.CACHING_RETENTION_MINUTES = 0 @@ -28,3 +33,61 @@ graph_definitions_dir, 'empty_graph.py'), } + +class TestNetwork(TestCase): + """ + Class for spinning up simulated Lightning Networks to do integration + testing. + + The implementation inheriting from this class needs to implement the + graph_test method, which tests properties specific to the chosen test + network graph. The attribute network_definition is a string that points + to the file location of a network graph definition in terms of a dict. + """ + network_definition = None + + def setUp(self): + if self.network_definition is None: + self.skipTest("This class doesn't represent a real test case.") + raise NotImplementedError("A network definition path needs to be " + "given.") + + self.testnet = Network( + binary_folder=bin_dir, + network_definition_location=self.network_definition, + nodedata_folder=test_data_dir, + node_limit='H', + from_scratch=True + ) + self.testnet.run_nocleanup() + # to run the lightning network in the background and do some testing + # here, run: + # $ lnregtest --nodedata_folder /path/to/lndmanage/test/test_data/ + # self.testnet.run_from_background() + + # logger.info("Generated network information:") + # logger.info(format_dict(self.testnet.node_mapping)) + # logger.info(format_dict(self.testnet.channel_mapping)) + # logger.info(format_dict(self.testnet.assemble_graph())) + + master_node_data_dir = self.testnet.master_node.data_dir + master_node_port = self.testnet.master_node._grpc_port + self.master_node_graph_view = self.testnet.master_node_graph_view() + + self.lndnode = LndNode( + lnd_home=master_node_data_dir, + lnd_host='localhost:' + str(master_node_port), + regtest=True + ) + self.graph_test() + + def tearDown(self): + self.testnet.cleanup() + del self.testnet + + def graph_test(self): + """ + graph_test should be implemented by each subclass test and check, + whether the test graph has the correct shape. + """ + raise NotImplementedError \ No newline at end of file diff --git a/test/testnetwork.py b/test/testnetwork.py deleted file mode 100644 index beeb2c7..0000000 --- a/test/testnetwork.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -Defines a general live test network class for integration testing. -""" -from unittest import TestCase - -from lnregtest.lib.network import Network - -from lndmanage.lib.node import LndNode - -from test.testing_common import ( - bin_dir, - test_data_dir, -) - -import logging -logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) - - -class TestNetwork(TestCase): - """ - Class for spinning up simulated Lightning Networks to do integration - testing. - - The implementation inheriting from this class needs to implement the - graph_test method, which tests properties specific to the chosen test - network graph. The attribute network_definition is a string that points - to the file location of a network graph definition in terms of a dict. - """ - network_definition = None - - def setUp(self): - if self.network_definition is None: - self.skipTest("This class doesn't represent a real test case.") - raise NotImplementedError("A network definition path needs to be " - "given.") - - self.testnet = Network( - binary_folder=bin_dir, - network_definition_location=self.network_definition, - nodedata_folder=test_data_dir, - node_limit='H', - from_scratch=True - ) - self.testnet.run_nocleanup() - # to run the lightning network in the background and do some testing - # here, run: - # $ lnregtest --nodedata_folder /path/to/lndmanage/test/test_data/ - # self.testnet.run_from_background() - - # logger.info("Generated network information:") - # logger.info(format_dict(self.testnet.node_mapping)) - # logger.info(format_dict(self.testnet.channel_mapping)) - # logger.info(format_dict(self.testnet.assemble_graph())) - - master_node_data_dir = self.testnet.master_node.data_dir - master_node_port = self.testnet.master_node._grpc_port - self.master_node_graph_view = self.testnet.master_node_graph_view() - - self.lndnode = LndNode( - lnd_home=master_node_data_dir, - lnd_host='localhost:' + str(master_node_port), - regtest=True - ) - self.graph_test() - - def tearDown(self): - self.testnet.cleanup() - - def graph_test(self): - """ - graph_test should be implemented by each subclass test and check, - whether the test graph has the correct shape. - """ - raise NotImplementedError From cf608704b0b228ca8ce87f06a09d1f7f0e5c6f33 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Sat, 5 Feb 2022 10:56:18 +0100 Subject: [PATCH 02/15] liquidity hints: persist --- lndmanage/lib/network.py | 64 ++++++++++++++++++++++++++++---------- lndmanage/lib/node.py | 1 - lndmanage/lib/rebalance.py | 2 ++ lndmanage/lib/utilities.py | 17 ++++++++++ test/test_pathfinding.py | 2 +- 5 files changed, 68 insertions(+), 18 deletions(-) diff --git a/lndmanage/lib/network.py b/lndmanage/lib/network.py index a1126e5..bfd599c 100644 --- a/lndmanage/lib/network.py +++ b/lndmanage/lib/network.py @@ -1,48 +1,58 @@ import os import time import pickle +from typing import Dict, TYPE_CHECKING import networkx as nx +from lndmanage.lib.utilities import profiled from lndmanage.lib.ln_utilities import convert_channel_id_to_short_channel_id from lndmanage.lib.liquidityhints import LiquidityHintMgr from lndmanage.lib.rating import ChannelRater from lndmanage import settings +if TYPE_CHECKING: + from lndmanage.lib.node import LndNode + import logging logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) +def make_cache_filename(filename: str): + """Creates the cache directory and gives back the absolute path to it for filename.""" + cache_dir = os.path.join(settings.home_dir, 'cache') + if not os.path.exists(cache_dir): + os.mkdir(cache_dir) + return os.path.join(cache_dir, filename) + + class Network: """ Contains the network graph. The graph is received from the LND API or from a cached file, which contains the graph younger than `settings.CACHING_RETENTION_MINUTES`. - - :param node: :class:`lib.node.LndNode` object """ + node: 'LndNode' + edges: Dict + graph: nx.MultiGraph + liquidity_hints: LiquidityHintMgr - def __init__(self, node): - logger.info("Initializing network graph.") + def __init__(self, node: 'LndNode'): self.node = node - self.edges = {} - self.graph = nx.MultiGraph() - self.cached_reading_graph_edges() - self.liquidity_hints = LiquidityHintMgr(self.node.pub_key) + self.load_graph() + self.load_liquidity_hints() self.channel_rater = ChannelRater(self) - def cached_reading_graph_edges(self): + @profiled + def load_graph(self): """ Checks if networkx and edges dictionary pickles are present. If they are older than CACHING_RETENTION_MINUTES, make fresh pickles, else read them from the files. """ - cache_dir = os.path.join(settings.home_dir, 'cache') - if not os.path.exists(cache_dir): - os.mkdir(cache_dir) - cache_edges_filename = os.path.join(cache_dir, 'graph.gpickle') - cache_graph_filename = os.path.join(cache_dir, 'edges.gpickle') + cache_edges_filename = make_cache_filename('graph.gpickle') + cache_graph_filename = make_cache_filename('edges.gpickle') try: timestamp_graph = os.path.getmtime(cache_graph_filename) @@ -50,23 +60,45 @@ def cached_reading_graph_edges(self): timestamp_graph = 0 # set very old timestamp if timestamp_graph < time.time() - settings.CACHING_RETENTION_MINUTES * 60: # old graph in file - logger.info(f"Saved graph is too old. Fetching new one.") + logger.info(f"Cached graph is too old. Fetching new one.") self.set_graph_and_edges() nx.write_gpickle(self.graph, cache_graph_filename) with open(cache_edges_filename, 'wb') as file: pickle.dump(self.edges, file) else: # recent graph in file - logger.info("Reading graph from file.") self.graph = nx.read_gpickle(cache_graph_filename) with open(cache_edges_filename, 'rb') as file: self.edges = pickle.load(file) + logger.info(f"> Loaded graph from file: {len(self.graph)} nodes, {len(self.edges)} channels.") + @profiled + def load_liquidity_hints(self): + cache_hints_filename = make_cache_filename('liquidity_hints.gpickle') + try: + with open(cache_hints_filename, 'rb') as file: + self.liquidity_hints = pickle.load(file) + number_failures = len([f for f in self.liquidity_hints._failure_hints.values() if f]) + logger.info(f"> Loaded liquidty hints: {len(self.liquidity_hints._liquidity_hints)} hints, {number_failures} failures.") + except FileNotFoundError: + self.liquidity_hints = LiquidityHintMgr(self.node.pub_key) + except Exception as e: + logger.exception(e) + + @profiled + def save_liquidty_hints(self): + cache_hints_filename = make_cache_filename('liquidity_hints.gpickle') + with open(cache_hints_filename, 'wb') as file: + pickle.dump(self.liquidity_hints, file) + + @profiled def set_graph_and_edges(self): """ Reads in the networkx graph and edges dictionary. :return: nx graph and edges dict """ + self.edges = {} + self.graph = nx.MultiGraph() raw_graph = self.node.get_raw_network_graph() for n in raw_graph.nodes: diff --git a/lndmanage/lib/node.py b/lndmanage/lib/node.py index 8b0cc58..530a942 100644 --- a/lndmanage/lib/node.py +++ b/lndmanage/lib/node.py @@ -47,7 +47,6 @@ class Node(object): """Bare node object with attributes.""" def __init__(self): - logger.info("Initializing node interface.") self.alias = '' self.pub_key = '' self.total_capacity = 0 diff --git a/lndmanage/lib/rebalance.py b/lndmanage/lib/rebalance.py index 90fb421..16aa0c1 100644 --- a/lndmanage/lib/rebalance.py +++ b/lndmanage/lib/rebalance.py @@ -158,6 +158,7 @@ def report_success_up_to_failed_hop(failed_hop_index: Optional[int]): logger.debug(f"Preimage: {result.preimage.hex()}") logger.info("Success!\n") report_success_up_to_failed_hop(failed_hop_index=None) + self.node.network.save_liquidty_hints() return route.total_fee_msat if failed_hop: @@ -186,6 +187,7 @@ def report_success_up_to_failed_hop(failed_hop_index: Optional[int]): # report all the previous hops that they could route the amount report_success_up_to_failed_hop(failed_hop) + self.node.network.save_liquidty_hints() else: raise DryRun diff --git a/lndmanage/lib/utilities.py b/lndmanage/lib/utilities.py index 10cc161..b09adff 100644 --- a/lndmanage/lib/utilities.py +++ b/lndmanage/lib/utilities.py @@ -1,4 +1,9 @@ import collections +import time + +import logging +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) def convert_dictionary_number_strings_to_ints(data): @@ -20,3 +25,15 @@ def convert_dictionary_number_strings_to_ints(data): return type(data)(map(convert_dictionary_number_strings_to_ints, data)) else: return data + + +def profiled(func, *args, **kwargs): + """Function wrapper for measuring execution time.""" + def wrapper(*args, **kwargs): + name = func.__qualname__ + start = time.time() + output = func(*args, **kwargs) + delta = time.time() - start + logger.debug(f"{name} {delta:,.4f} s") + return output + return wrapper \ No newline at end of file diff --git a/test/test_pathfinding.py b/test/test_pathfinding.py index 6f39a9d..8b150e2 100644 --- a/test/test_pathfinding.py +++ b/test/test_pathfinding.py @@ -17,7 +17,7 @@ class MockNode: pub_key = 'A' # we disable cached graph reading - with mock.patch.object(Network, 'cached_reading_graph_edges', return_value=None): + with mock.patch.object(Network, 'load_graph', return_value=None): network = Network(MockNode()) # add nodes From de63838db47424c146c9dddd7412a087eb2d70d8 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Sun, 6 Feb 2022 18:46:07 +0100 Subject: [PATCH 03/15] rebalance: improve logging --- lndmanage/lib/rebalance.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lndmanage/lib/rebalance.py b/lndmanage/lib/rebalance.py index 16aa0c1..efe0c0a 100644 --- a/lndmanage/lib/rebalance.py +++ b/lndmanage/lib/rebalance.py @@ -29,7 +29,6 @@ RESERVED_REBALANCE_FEE_RATE = 50 - class Rebalancer(object): """Implements methods for rebalancing.""" @@ -94,6 +93,7 @@ def _rebalance( count += 1 if count > settings.REBALANCING_TRIALS: raise RebalancingTrialsExhausted + logger.info(f">>> Trying to rebalance with {amt_sat} sat (attempt number {count}).") route = self.router.get_route(send_channels, receive_channels, amt_msat) if not route: @@ -101,7 +101,7 @@ def _rebalance( effective_fee_rate = route.total_fee_msat / route.total_amt_msat logger.info( - f">>> Route summary: amount: {(route.total_amt_msat - route.total_fee_msat) / 1000:3.3f} " + f" > Route summary: amount: {(route.total_amt_msat - route.total_fee_msat) / 1000:3.3f} " f"sat, total fee: {route.total_fee_msat / 1000:3.3f} sat, " f"fee rate: {effective_fee_rate:1.6f}, " f"number of hops: {len(route.channel_hops)}") @@ -117,7 +117,7 @@ def _rebalance( # check economics fee_rate_margin = (illiquid_channel['local_fee_rate'] - liquid_channel['local_fee_rate']) / 1_000_000 - logger.info(f" expected gain: {(fee_rate_margin - effective_fee_rate) * amt_sat:3.3f} sat") + logger.info(f" > Expected gain: {(fee_rate_margin - effective_fee_rate) * amt_sat:3.3f} sat") if (effective_fee_rate > fee_rate_margin) and not self.force: raise NotEconomic("This rebalance attempt doesn't lead to enough expected earnings.") @@ -176,10 +176,10 @@ def report_success_up_to_failed_hop(failed_hop_index: Optional[int]): f"Failing channel: {failed_channel_id}") # determine the nodes involved in the channel - logger.info(f" Failed: hop: {failed_hop + 1}, channel: {failed_channel_id}") - logger.info(f" Could not reach {failed_target} ({self.node.network.node_alias(failed_target)})\n") - logger.debug(f" Node hops {route.node_hops}") - logger.debug(f" Channel hops {route.channel_hops}") + logger.info(f" > Failed: hop: {failed_hop + 1}, channel: {failed_channel_id}") + logger.info(f" > Could not reach {failed_target} ({self.node.network.node_alias(failed_target)})\n") + logger.debug(f" > Node hops {route.node_hops}") + logger.debug(f" > Channel hops {route.channel_hops}") # report that channel could not route the amount to liquidity hints self.node.network.liquidity_hints.update_cannot_send( From 4036e99650810a4c4a80bd4ef975c5c61ed3f337 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Wed, 9 Feb 2022 20:51:15 +0100 Subject: [PATCH 04/15] rebalance: constrain can send amounts --- lndmanage/lib/forwardings.py | 4 +- lndmanage/lib/ln_utilities.py | 19 +++-- lndmanage/lib/node.py | 4 +- lndmanage/lib/rebalance.py | 126 +++++++++++++++------------------- lndmanage/lndmanage.py | 4 +- test/test_ln_utilities.py | 34 +++++++-- test/test_rebalance.py | 8 +-- 7 files changed, 103 insertions(+), 96 deletions(-) diff --git a/lndmanage/lib/forwardings.py b/lndmanage/lib/forwardings.py index ea48263..c4dd6b1 100644 --- a/lndmanage/lib/forwardings.py +++ b/lndmanage/lib/forwardings.py @@ -7,7 +7,7 @@ from lndmanage.lib.data_types import NodeProperties from lndmanage.lib.node import LndNode -from lndmanage.lib.ln_utilities import channel_unbalancedness_and_commit_fee +from lndmanage.lib.ln_utilities import local_balance_to_unbalancedness from lndmanage import settings logger = logging.getLogger(__name__) @@ -641,7 +641,7 @@ def get_node_properites( "max_public_capacity": max(properties.public_capacities) if properties.public_capacities else 0, - "unbalancedness": channel_unbalancedness_and_commit_fee( + "unbalancedness": local_balance_to_unbalancedness( local_balance, capacity, 0, False )[0], } diff --git a/lndmanage/lib/ln_utilities.py b/lndmanage/lib/ln_utilities.py index 78877fe..dadb5d4 100644 --- a/lndmanage/lib/ln_utilities.py +++ b/lndmanage/lib/ln_utilities.py @@ -1,4 +1,5 @@ """Contains Lightning network specific conversion utilities.""" +from typing import Tuple import re import time @@ -38,23 +39,21 @@ def extract_short_channel_id_from_string(string): return groups -def channel_unbalancedness_and_commit_fee(local_balance, capacity, commit_fee, - initiator): - """ - Calculates the unbalancedness. +def local_balance_to_unbalancedness(local_balance: int, capacity: int, commit_fee: int, + initiator: bool) -> Tuple[float, int]: + """Calculates the unbalancedness. - :param local_balance: int - :param capacity: int - :param commit_fee: int - :param initiator: bool :return: float: in [-1.0, 1.0] """ # inverse of the formula: - # c.local_balance = c.capacity * 0.5 * (-unbalancedness + 1) - commit_fee commit_fee = 0 if not initiator else commit_fee - return -(2 * float(local_balance + commit_fee) / capacity - 1), commit_fee + return -(2 * (local_balance + commit_fee) / capacity - 1), commit_fee + +def unbalancedness_to_local_balance(unbalancedness: float, capacity: int, commit_fee: int, initiator: bool) -> Tuple[int, int]: + commit_fee = 0 if not initiator else commit_fee + return -int(capacity * (unbalancedness - 1) / 2) - commit_fee, commit_fee def height_to_timestamp(node, close_height): now = time.time() diff --git a/lndmanage/lib/node.py b/lndmanage/lib/node.py index 530a942..5bbc003 100644 --- a/lndmanage/lib/node.py +++ b/lndmanage/lib/node.py @@ -25,7 +25,7 @@ extract_short_channel_id_from_string, convert_short_channel_id_to_channel_id, convert_channel_id_to_short_channel_id, - channel_unbalancedness_and_commit_fee + local_balance_to_unbalancedness ) from lndmanage.lib.psbt import extract_psbt_inputs_outputs from lndmanage.lib.data_types import UTXO, AddressType @@ -395,7 +395,7 @@ def convert_to_days_ago(timestamp): # define unbalancedness |ub| large means very unbalanced channel_unbalancedness, our_commit_fee = \ - channel_unbalancedness_and_commit_fee( + local_balance_to_unbalancedness( c.local_balance, c.capacity, c.commit_fee, c.initiator) try: uptime_lifetime_ratio = c.uptime / c.lifetime diff --git a/lndmanage/lib/rebalance.py b/lndmanage/lib/rebalance.py index efe0c0a..797f356 100644 --- a/lndmanage/lib/rebalance.py +++ b/lndmanage/lib/rebalance.py @@ -15,6 +15,7 @@ PaymentTimeOut, TooExpensive, ) +from lndmanage.lib.ln_utilities import unbalancedness_to_local_balance from lndmanage import settings if TYPE_CHECKING: @@ -24,7 +25,7 @@ logger.addHandler(logging.NullHandler()) DEFAULT_MAX_FEE_RATE = 0.001000 -DEFAULT_AMOUNT_SAT = 500000 +DEFAULT_AMOUNT_SAT = 100000 # MIN_FEE_RATE_AFFORDABLE is the minimal effective fee rate a rebalance attempt can cost RESERVED_REBALANCE_FEE_RATE = 50 @@ -225,9 +226,17 @@ def _get_rebalance_candidates( k: c for k, c in self.channels.items() if k not in removed_channels} # filter channels, which can't receive/send the amount - candidate_balance = 'remote_balance' if local_balance_change < 0 else 'local_balance' - # TODO: include channel reserve - rebalance_candidates = {k: c for k, c in rebalance_candidates.items() if c[candidate_balance] > abs(local_balance_change)} + candidates_send = True if local_balance_change > 0 else False + rebalance_candidates_with_funds = {} + for k, c in rebalance_candidates.items(): + if candidates_send: + maximal_can_send = self._maximal_local_balance_change(False, c) + if maximal_can_send > abs(local_balance_change): + rebalance_candidates_with_funds[k] = c + else: + maximal_can_receive = self._maximal_local_balance_change(True, c) + if maximal_can_receive > abs(local_balance_change): + rebalance_candidates_with_funds[k] = c # We only include rebalance candidates for which it makes economically sense to # rebalance. This is determined by the difference in fee rates: @@ -237,7 +246,7 @@ def _get_rebalance_candidates( # fee_rate[rebalance_channel] - fee_rate[candidate_channel], # it always needs to be positive at least rebalance_candidates_filtered = {} - for k, c in rebalance_candidates.items(): + for k, c in rebalance_candidates_with_funds.items(): if not self.force: # we allow only economic candidates if local_balance_change < 0: # we take liquidity out of the channel fee_rate_margin = c['local_fee_rate'] - channel_fee_rate_milli_msat @@ -305,7 +314,7 @@ def _debug_rebalance_candidates(rebalance_candidates: Dict[int, dict]): @staticmethod def _maximal_local_balance_change( - unbalancedness_target: float, + increase_local_balance: bool, unbalanced_channel_info: dict ) -> int: """Tries to find out the amount to maximally send/receive given the @@ -326,61 +335,26 @@ def _maximal_local_balance_change( """ # both parties need to maintain a channel reserve of 1% # according to BOLT 2 - channel_reserve = int(0.01 * unbalanced_channel_info['capacity']) - - if unbalancedness_target: - # a commit fee needs to be only respected by the channel initiator - commit_fee = 0 if not unbalanced_channel_info['initiator'] \ - else unbalanced_channel_info['commit_fee'] - - # first naively calculate the local balance change to - # fulfill the requested target - local_balance_target = int( - unbalanced_channel_info['capacity'] * 0.5 * - (-unbalancedness_target + 1.0) - commit_fee) - local_balance_change = local_balance_target - \ - unbalanced_channel_info['local_balance'] - - # TODO: clarify exact definitions of dust and htlc_cost - # related: https://github.com/lightningnetwork/lnd/issues/1076 - # https://github.com/lightningnetwork/lightning-rfc/blob/master/03-transactions.md#fees - - dust = 700 - htlc_weight = 172 - number_htlcs = 2 - htlc_cost = int( - number_htlcs * htlc_weight * - unbalanced_channel_info['fee_per_kw'] / 1000) - logger.debug(f">>> Assuming a dust limit of {dust} sat and an " - f"HTLC cost of {htlc_cost} sat.") - - # we can only send the local balance less the channel reserve - # (if above the dust limit), less the cost to enforce the HTLC - can_send = max(0, unbalanced_channel_info['local_balance'] - - max(dust, channel_reserve) - htlc_cost - 1) - - # we can only receive the remote balance less the channel reserve - # (if above the dust limit) - can_receive = max(0, unbalanced_channel_info['remote_balance'] - - max(dust, channel_reserve) - 1) - - logger.debug(f">>> Channel can send {can_send} sat and receive " - f"{can_receive} sat.") - - # check that we respect and enforce the channel reserve - if (local_balance_change > 0 and - abs(local_balance_change)) > can_receive: - local_balance_change = can_receive - if (local_balance_change < 0 and - abs(local_balance_change)) > can_send: - local_balance_change = -can_send - - amt_target_original = int(local_balance_change) - else: - # use the already calculated optimal amount for 50:50 balancedness - amt_target_original = unbalanced_channel_info['amt_to_balanced'] - return amt_target_original + local_balance = unbalanced_channel_info['local_balance'] + remote_balance = unbalanced_channel_info['remote_balance'] + remote_channel_reserve = unbalanced_channel_info['remote_chan_reserve_sat'] + local_channel_reserve = unbalanced_channel_info['local_chan_reserve_sat'] + + if increase_local_balance: # we want to add funds to the channel + local_balance_change = remote_balance + local_balance_change -= remote_channel_reserve + else: # we want to decrease funds + local_balance_change = local_balance + local_balance_change -= local_channel_reserve + + # the local balance already reflects commitment transaction fees + # in principle we should account for the HTLC output here + local_balance_change -= 172 * unbalanced_channel_info['fee_per_kw'] / 1000 + + # TODO: buffer for all the rest of costs which is why + # local_balance_change can be negative + return max(0, int(local_balance_change)) def _node_is_multiple_connected(self, pub_key: str) -> bool: """Checks if the node is connected to us via several channels. @@ -446,19 +420,33 @@ def rebalance( # 0. determine the amount we want to send/receive on the channel if target is not None: - # if a target is given and it is set close to -1 or 1, - # then we need to think about the channel reserve - # TODO: check maximal local balance change - initial_local_balance_change = self._maximal_local_balance_change( - target, unbalanced_channel_info) + if target < unbalanced_channel_info['unbalancedness']: + increase_local_balance = True + else: + increase_local_balance = False + + maximal_abs_local_balance_change = self._maximal_local_balance_change( + increase_local_balance, unbalanced_channel_info) + target_local_balance, _ = unbalancedness_to_local_balance( + target, + unbalanced_channel_info['capacity'], + unbalanced_channel_info['commit_fee'], + unbalanced_channel_info['initiator'] + ) + abs_local_balance_change = abs(target_local_balance - unbalanced_channel_info['local_balance']) + initial_local_balance_change = min(abs_local_balance_change, maximal_abs_local_balance_change) + # encode the sign to send (< 0) or receive (> 0) + initial_local_balance_change *= 1 if increase_local_balance else -1 + if abs(initial_local_balance_change) <= 10_000: logger.info(f"Channel already balanced.") return 0 - # if no target is given, we enforce some default amount - else: - if not amount_sat: - amount_sat = DEFAULT_AMOUNT_SAT + # if no target is given, we enforce some default amount in the opposite direction of unbalancedness + elif not amount_sat: + amount_sat = DEFAULT_AMOUNT_SAT initial_local_balance_change = int(math.copysign(1, unbalanced_channel_info['unbalancedness']) * amount_sat) + else: + initial_local_balance_change = amount_sat # determine budget and fee rate from local balance change: # budget fee_rate result diff --git a/lndmanage/lndmanage.py b/lndmanage/lndmanage.py index 6a87994..55aa290 100755 --- a/lndmanage/lndmanage.py +++ b/lndmanage/lndmanage.py @@ -166,8 +166,8 @@ def __init__(self): help='Sets the maximal fees in satoshis to be paid.') self.parser_rebalance.add_argument( '--amount-sat', type=int, default=DEFAULT_AMOUNT_SAT, - help='Specifies the rebalance amount in satoshis. The direction is ' - 'determined automatically.') + help='Specifies the increase in local balance in sat. The amount can be' + 'negative to decrease the local balance.') self.parser_rebalance.add_argument( '--max-fee-rate', type=range_limited_float_type, default=DEFAULT_MAX_FEE_RATE, help='Sets the maximal effective fee rate to be paid.' diff --git a/test/test_ln_utilities.py b/test/test_ln_utilities.py index 8f662ad..13413fc 100644 --- a/test/test_ln_utilities.py +++ b/test/test_ln_utilities.py @@ -1,15 +1,35 @@ from unittest import TestCase -from lndmanage.lib.ln_utilities import channel_unbalancedness_and_commit_fee +from lndmanage.lib.ln_utilities import ( + local_balance_to_unbalancedness, + unbalancedness_to_local_balance, +) class LnUtilityTest(TestCase): def test_unbalancedness_formula(self): self.assertAlmostEqual( - channel_unbalancedness_and_commit_fee( - 500000, 1000000, 1000, False)[0], 0.0) + local_balance_to_unbalancedness(500000, 1000000, 1000, False)[0], 0.0 + ) self.assertAlmostEqual( - channel_unbalancedness_and_commit_fee( - 500000, 1000000, 1000, True)[0], -0.002) + local_balance_to_unbalancedness(500000, 1000000, 1000, True)[0], -0.002 + ) self.assertAlmostEqual( - channel_unbalancedness_and_commit_fee( - 600000, 1000000, 0, False)[0], -0.2) + local_balance_to_unbalancedness(600000, 1000000, 0, False)[0], -0.2 + ) + + # test inverse: + ub = -0.2 + cap = 1000000 + cf = 100 + self.assertAlmostEqual( + ub, + local_balance_to_unbalancedness( + unbalancedness_to_local_balance(ub, cap, 0, False)[0], cap, 0, False + )[0], + ) + self.assertAlmostEqual( + ub, + local_balance_to_unbalancedness( + unbalancedness_to_local_balance(ub, cap, cf, True)[0], cap, cf, True + )[0], + ) diff --git a/test/test_rebalance.py b/test/test_rebalance.py index 4dc34f1..91e7199 100644 --- a/test/test_rebalance.py +++ b/test/test_rebalance.py @@ -6,7 +6,7 @@ from lndmanage import settings from lndmanage.lib.rebalance import Rebalancer -from lndmanage.lib.ln_utilities import channel_unbalancedness_and_commit_fee +from lndmanage.lib.ln_utilities import local_balance_to_unbalancedness from lndmanage.lib.exceptions import NoRebalanceCandidates from test.testing_common import ( @@ -76,7 +76,7 @@ def rebalance_and_check( channel_data_after = graph_after['A'][test_channel_number] amount_sent = channel_data_before['local_balance'] - channel_data_after['local_balance'] - channel_unbalancedness, _ = channel_unbalancedness_and_commit_fee( + channel_unbalancedness, _ = local_balance_to_unbalancedness( channel_data_after['local_balance'], channel_data_after['capacity'], channel_data_after['commit_fee'], @@ -149,9 +149,9 @@ def test_init_already_balanced(self): test_channel_number = 2 self.rebalance_and_check(test_channel_number, target=0.0, amount_sat=None, allow_uneconomic=True, places=2) - def test_init_amount(self): + def test_init_default_amount(self): test_channel_number = 1 - self.rebalance_and_check(test_channel_number, target=None, amount_sat=500_000, allow_uneconomic=True, places=-1) + self.rebalance_and_check(test_channel_number, target=None, amount_sat=None, allow_uneconomic=True, places=-1) def test_shuffle_arround(self): """Shuffles sats around in channel 6.""" From b26b2e810309e4beea461714503e9e94d90f630e Mon Sep 17 00:00:00 2001 From: bitromortac Date: Fri, 11 Feb 2022 11:19:45 +0100 Subject: [PATCH 05/15] hints: introduce symmetric badness penalty --- lndmanage/lib/liquidityhints.py | 24 +++++++++------- lndmanage/lib/network.py | 4 +-- lndmanage/lib/pathfinding.py | 2 +- lndmanage/lib/rating.py | 51 ++++++++++++++++++++++----------- lndmanage/lib/rebalance.py | 7 +++++ lndmanage/lib/routing.py | 19 ++++++------ test/test_pathfinding.py | 8 ++++-- test/test_rating.py | 11 +++++++ 8 files changed, 85 insertions(+), 41 deletions(-) create mode 100644 test/test_rating.py diff --git a/lndmanage/lib/liquidityhints.py b/lndmanage/lib/liquidityhints.py index 47f96bd..6944c57 100644 --- a/lndmanage/lib/liquidityhints.py +++ b/lndmanage/lib/liquidityhints.py @@ -11,7 +11,8 @@ DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH = 100 # how much relative fee we apply for unknown sending capability of a channel BLACKLIST_DURATION = 3600 # how long (in seconds) a channel remains blacklisted HINT_DURATION = 3600 # how long (in seconds) a liquidity hint remains valid -ATTEMPTS_TO_FAIL = 4 # if a node fails this often to forward a payment, we won't use it anymore +ATTEMPTS_TO_FAIL = 10 # if a node fails this often to forward a payment, we won't use it anymore +FAILURE_FEE_MSAT = 10_000 class ShortChannelID(int): @@ -166,7 +167,7 @@ class LiquidityHintMgr: def __init__(self, source_node: str): self.source_node = source_node self._liquidity_hints: Dict[ShortChannelID, LiquidityHint] = {} - self._failure_hints: Dict[NodeID, int] = defaultdict(int) + self._badness_hints: Dict[NodeID, int] = defaultdict(int) def get_hint(self, channel_id: ShortChannelID) -> LiquidityHint: hint = self._liquidity_hints.get(channel_id) @@ -182,10 +183,13 @@ def update_can_send(self, node_from: NodeID, node_to: NodeID, channel_id: ShortC def update_cannot_send(self, node_from: NodeID, node_to: NodeID, channel_id: ShortChannelID, amount: int): logger.debug(f" report: cannot send {amount // 1000} sat over channel {channel_id}") - self._failure_hints[node_from] += 1 hint = self.get_hint(channel_id) hint.update_cannot_send(node_from < node_to, amount) + def update_badness_hint(self, node: NodeID, badness): + self._badness_hints[node] += badness + logger.debug(f" report: update badness {badness} +=> {self._badness_hints[node]} (node: {node})") + def add_htlc(self, node_from: NodeID, node_to: NodeID, channel_id: ShortChannelID): hint = self.get_hint(channel_id) hint.add_htlc(node_from < node_to) @@ -216,7 +220,7 @@ def penalty(self, node_from: NodeID, node_to: NodeID, edge: Dict, amount_msat: i base and relative fees. """ # we assume that our node can always route: - if self.source_node in [node_from, node_to]: + if self.source_node in [node_from, ]: return 0 # we only evaluate hints here, so use dict get (to not create many hints with self.get_hint) hint = self._liquidity_hints.get(edge['channel_id']) @@ -238,13 +242,13 @@ def penalty(self, node_from: NodeID, node_to: NodeID, edge: Dict, amount_msat: i log_penalty = - log((cannot_send - (amount_msat - can_send)) / cannot_send) # we give a base penalty if we haven't tried the channel yet penalty = fee_rate_milli_msat * amount_msat // 1_000_000 - # all the so-far successful channels would be tried over and over until all - # of the last successful node's channels are explored - # as a tie-breaker, we add another penalty that increases with the number of - # failures - failure_fee = self._failure_hints[node_from] * penalty // ATTEMPTS_TO_FAIL - return 5 * (log_penalty * penalty + failure_fee) + return log_penalty * penalty + + def badness_penalty(self, node_from: NodeID, amount: int) -> float: + """We blacklist a node if the attempts to fail are exhausted. Otherwise we just + scale up the effective fee proportional to the failed attempts.""" + return amount * self._badness_hints[node_from] def add_to_blacklist(self, channel_id: ShortChannelID): hint = self.get_hint(channel_id) diff --git a/lndmanage/lib/network.py b/lndmanage/lib/network.py index bfd599c..74948bc 100644 --- a/lndmanage/lib/network.py +++ b/lndmanage/lib/network.py @@ -77,8 +77,8 @@ def load_liquidity_hints(self): try: with open(cache_hints_filename, 'rb') as file: self.liquidity_hints = pickle.load(file) - number_failures = len([f for f in self.liquidity_hints._failure_hints.values() if f]) - logger.info(f"> Loaded liquidty hints: {len(self.liquidity_hints._liquidity_hints)} hints, {number_failures} failures.") + num_badness_hints = len([f for f in self.liquidity_hints._badness_hints.values() if f]) + logger.info(f"> Loaded liquidty hints: {len(self.liquidity_hints._liquidity_hints)} hints, {num_badness_hints} badness hints.") except FileNotFoundError: self.liquidity_hints = LiquidityHintMgr(self.node.pub_key) except Exception as e: diff --git a/lndmanage/lib/pathfinding.py b/lndmanage/lib/pathfinding.py index 125ba56..b3b5942 100644 --- a/lndmanage/lib/pathfinding.py +++ b/lndmanage/lib/pathfinding.py @@ -15,7 +15,7 @@ def dijkstra(graph: nx.Graph, source: str, target: str, weight: Callable) -> Lis :param graph: networkx graph :param source: find a path from this key :param target: to this key - :param weight: weight function, takes u (pubkey from), v (pubkey to), e (edge information) as arguments + :param weight: weight function, takes node_from (pubkey from), node_to (pubkey to), channel_info (edge information) as arguments :return: hops in terms of the node keys """ diff --git a/lndmanage/lib/rating.py b/lndmanage/lib/rating.py index 90a5886..f96b904 100644 --- a/lndmanage/lib/rating.py +++ b/lndmanage/lib/rating.py @@ -1,5 +1,5 @@ import math -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict from lndmanage import settings @@ -11,6 +11,12 @@ if TYPE_CHECKING: from lndmanage.lib.network import Network +BADNESS_RATE = 0.000_100 + + +def node_badness(node_number: int, hop: int): + return BADNESS_RATE * math.exp(-abs(node_number - (hop + 0.5))) + class ChannelRater: """The purpose of this class is to hold information about the balancedness of @@ -23,7 +29,7 @@ def __init__(self, network: "Network", source: str = None): self.blacklisted_nodes = [] self.source = source self.network = network - self.reference_fee_rate_milli_msat = 10 + self.reference_fee_rate_milli_msat = 200 def node_to_node_weight(self, u, v, e, amt_msat): """Is used to assign a weight for a channel. It is calculated from the fee @@ -46,36 +52,47 @@ def node_to_node_weight(self, u, v, e, amt_msat): ] return min(costs) - def channel_weight(self, u, v, e, amt_msat): + def channel_weight(self, node_from: str, node_to: str, channel_info: Dict, amt_msat): # check if channel is blacklisted - if self.blacklisted_channels.get(e["channel_id"]) == {"source": u, "target": v}: + if self.blacklisted_channels.get(channel_info["channel_id"]) == {"source": node_from, "target": node_to}: return math.inf + # we don't send if the channel cannot carry the payment - if amt_msat // 1000 > e["capacity"]: + if amt_msat // 1000 > channel_info["capacity"]: return math.inf + # we don't send over channel if it is disabled - policy = e.get("fees")[u > v] + policy = channel_info.get("fees")[node_from > node_to] if policy["disabled"]: return math.inf + # we don't pay fees if we own the channel and are sending over it - if self.source and u == self.source: + if self.source and node_from == self.source: return 0 + + # we apply a badness score proportional to the amount we send + badness_penalty = self.network.liquidity_hints.badness_penalty(node_from, amt_msat) + # compute liquidity penalty liquidity_penalty = self.network.liquidity_hints.penalty( - u, v, e, amt_msat, self.reference_fee_rate_milli_msat + node_from, node_to, channel_info, amt_msat, self.reference_fee_rate_milli_msat ) - # compute fees and add penalty + + # routing fees + # TODO: play with offsets fees = ( - policy["fee_base_msat"] - + amt_msat - * ( - abs(policy["fee_rate_milli_msat"] - self.reference_fee_rate_milli_msat) - + self.reference_fee_rate_milli_msat - ) - // 1_000_000 + abs(policy["fee_base_msat"]) + + amt_msat * abs(policy["fee_rate_milli_msat"]) // 1_000_000 ) - return liquidity_penalty + fees + # we give a base penalty of 2 sat per hop, as we want to avoid too long routes + # for small amounts + route_length_fee_msat = 2_000 + + # linear combination of components + weight = fees + liquidity_penalty + badness_penalty + route_length_fee_msat + + return weight def blacklist_add_channel(self, channel: int, source: str, target: str): """Adds a channel to the blacklist dict. diff --git a/lndmanage/lib/rebalance.py b/lndmanage/lib/rebalance.py index 797f356..d637acb 100644 --- a/lndmanage/lib/rebalance.py +++ b/lndmanage/lib/rebalance.py @@ -3,6 +3,7 @@ import math from typing import TYPE_CHECKING, Optional, Tuple, Dict +from lndmanage.lib.rating import node_badness from lndmanage.lib.routing import Router from lndmanage.lib import exceptions from lndmanage.lib.exceptions import ( @@ -139,6 +140,12 @@ def report_success_up_to_failed_hop(failed_hop_index: Optional[int]): target_node = route.node_hops[hop + 1] self.node.network.liquidity_hints.update_can_send(source_node, target_node, channel['chan_id'], amt_msat) + # symmetrically penalize failed hop (decreasing away from the failed hop): + if failed_hop_index: + for node_number, node in enumerate(route.node_hops): + badness = node_badness(node_number, failed_hop_index) + self.node.network.liquidity_hints.update_badness_hint(node, badness) + if not dry: try: result = self.node.send_to_route(route, payment_hash, payment_address) diff --git a/lndmanage/lib/routing.py b/lndmanage/lib/routing.py index 198c111..1a6db8a 100644 --- a/lndmanage/lib/routing.py +++ b/lndmanage/lib/routing.py @@ -39,8 +39,8 @@ def __init__(self, node: 'LndNode', channel_hops: List[int], node_dest: str, amt final_cltv = 144 fees_msat_container = [0] cltv_delta = [0] - node_right = node_dest - node_left = None + node_to = node_dest + node_from = None policy = None logger.debug("Route construction starting.") @@ -54,20 +54,20 @@ def __init__(self, node: 'LndNode', channel_hops: List[int], node_dest: str, amt raise RouteWithTooSmallCapacity(f"Amount too large for channel.") policies = channel_data['policies'] - if node_right == channel_data['node2_pub']: + if node_to == channel_data['node2_pub']: try: policy = policies[channel_data['node1_pub'] > channel_data['node2_pub']] - node_left = channel_data['node1_pub'] + node_from = channel_data['node1_pub'] except KeyError: logger.exception(f"No channel {channel_data}") else: policy = policies[channel_data['node2_pub'] > channel_data['node1_pub']] - node_left = channel_data['node2_pub'] + node_from = channel_data['node2_pub'] - self._node_hops.append(node_left) + self._node_hops.append(node_from) hop = len(channel_hops) - ichannel logger.info(f" Hop {hop}: {channel_id} (cap: {channel_data['capacity']} sat): " - f"{self.node.network.node_alias(node_right)} <- {self.node.network.node_alias(node_left)} ") + f"{self.node.network.node_alias(node_to)} <- {self.node.network.node_alias(node_from)} ") logger.debug(f" Policy of forwarding node: {policy}") fees_msat = policy['fee_base_msat'] + policy['fee_rate_milli_msat'] * forward_msat // 1000000 @@ -77,7 +77,8 @@ def __init__(self, node: 'LndNode', channel_hops: List[int], node_dest: str, amt logger.info(f" Fees: {fees_msat / 1000 if not hop == 1 else 0:3.3f} sat") logger.debug(f" Fees container {fees_msat_container}") logger.debug(f" Forward: {forward_msat / 1000:3.3f} sat") - logger.info(f" Liquidity penalty: {self.node.network.liquidity_hints.penalty(node_left, node_right, channel_data, amt_msat, self.node.network.channel_rater.reference_fee_rate_milli_msat) / 1000: 3.3f} sat") + logger.info(f" Liquidity penalty: {self.node.network.liquidity_hints.penalty(node_from, node_to, channel_data, amt_msat, self.node.network.channel_rater.reference_fee_rate_milli_msat) / 1000: 3.3f} sat") + logger.info(f" Badness penalty: {self.node.network.liquidity_hints.badness_penalty(node_from, amt_msat) / 1000: 3.3f} sat") self._hops.append({ 'chan_id': channel_data['channel_id'], @@ -90,7 +91,7 @@ def __init__(self, node: 'LndNode', channel_hops: List[int], node_dest: str, amt }) cltv_delta.append(policy['time_lock_delta']) - node_right = node_left + node_to = node_from self.hops = list(reversed(self._hops)) self.node_hops = list(reversed(self._node_hops)) diff --git a/test/test_pathfinding.py b/test/test_pathfinding.py index 8b150e2..338472c 100644 --- a/test/test_pathfinding.py +++ b/test/test_pathfinding.py @@ -4,6 +4,8 @@ from typing import Dict from unittest import TestCase, mock +import networkx as nx + from lndmanage.lib.network import Network from lndmanage.lib.rating import ChannelRater from lndmanage.lib.pathfinding import dijkstra @@ -19,6 +21,8 @@ class MockNode: # we disable cached graph reading with mock.patch.object(Network, 'load_graph', return_value=None): network = Network(MockNode()) + network.graph = nx.MultiGraph() + network.edges = {} # add nodes for node, node_definition in graph.items(): @@ -93,7 +97,7 @@ def test_liquidity_hints(self): weight_function = lambda v, u, e: cr.node_to_node_weight(v, u, e, amt_msat) path = dijkstra(network.graph, 'A', 'E', weight=weight_function) - self.assertEqual(['A', 'B', 'E'], path) + self.assertEqual(['A', 'D', 'E'], path) # We report that B cannot send to E network.liquidity_hints.update_cannot_send('B', 'E', 2, 1_000) @@ -103,7 +107,7 @@ def test_liquidity_hints(self): # We report that D cannot send to E network.liquidity_hints.update_cannot_send('D', 'E', 5, 1_000) path = dijkstra(network.graph, 'A', 'E', weight=weight_function) - self.assertEqual(['A', 'B', 'C', 'E'], path) + self.assertEqual(['A', 'D', 'C', 'E'], path) # We report that D can send to C network.liquidity_hints.update_can_send('D', 'C', 4, amt_msat + 1000) diff --git a/test/test_rating.py b/test/test_rating.py new file mode 100644 index 0000000..90123c8 --- /dev/null +++ b/test/test_rating.py @@ -0,0 +1,11 @@ +import unittest + +from lndmanage.lib.rating import node_badness + + +class TestBadness(unittest.TestCase): + def test_badness(self): + node_hops = ['A', 'B', 'C', 'D', 'E', 'F', 'G'] + failed_hop = 3 + for ni, _ in enumerate(node_hops): + print(node_badness(ni, failed_hop)) From f28323d683ea76243678a56b08b6242b03b3ffc3 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Fri, 11 Feb 2022 12:07:42 +0100 Subject: [PATCH 06/15] route participations and bug fixes --- lndmanage/lib/exceptions.py | 6 ++++++ lndmanage/lib/liquidityhints.py | 6 ++++++ lndmanage/lib/node.py | 14 ++++++++------ lndmanage/lib/rebalance.py | 4 ++++ 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/lndmanage/lib/exceptions.py b/lndmanage/lib/exceptions.py index 094117d..f13a290 100644 --- a/lndmanage/lib/exceptions.py +++ b/lndmanage/lib/exceptions.py @@ -86,6 +86,12 @@ def __init__(self, payment): super().__init__() +class IncorrectCLTVExpiry(PaymentFailure): + def __init__(self, payment): + self.payment = payment + super().__init__() + + class ChannelDisabled(PaymentFailure): def __init__(self, payment): self.payment = payment diff --git a/lndmanage/lib/liquidityhints.py b/lndmanage/lib/liquidityhints.py index 6944c57..34611c6 100644 --- a/lndmanage/lib/liquidityhints.py +++ b/lndmanage/lib/liquidityhints.py @@ -168,6 +168,7 @@ def __init__(self, source_node: str): self.source_node = source_node self._liquidity_hints: Dict[ShortChannelID, LiquidityHint] = {} self._badness_hints: Dict[NodeID, int] = defaultdict(int) + self._route_participations: Dict[NodeID, int] = defaultdict(int) def get_hint(self, channel_id: ShortChannelID) -> LiquidityHint: hint = self._liquidity_hints.get(channel_id) @@ -189,6 +190,11 @@ def update_cannot_send(self, node_from: NodeID, node_to: NodeID, channel_id: Sho def update_badness_hint(self, node: NodeID, badness): self._badness_hints[node] += badness logger.debug(f" report: update badness {badness} +=> {self._badness_hints[node]} (node: {node})") + self.update_route_participation(node) + + def update_route_participation(self, node: NodeID): + self._route_participations[node] += 1 + logger.debug(f" report: update route participation to {self._route_participations[node]} (node: {node})") def add_htlc(self, node_from: NodeID, node_to: NodeID, channel_id: ShortChannelID): hint = self.get_hint(channel_id) diff --git a/lndmanage/lib/node.py b/lndmanage/lib/node.py index 5bbc003..2fc399c 100644 --- a/lndmanage/lib/node.py +++ b/lndmanage/lib/node.py @@ -249,16 +249,18 @@ def send_to_route(self, route: 'Route', payment_hash: bytes, logger.debug(f"Routing failure: {failure}") if failure.failure_source_index == 0: raise OurNodeFailure("Not enough funds?") - if failure.code == 15: - raise exceptions.TemporaryChannelFailure(payment) - elif failure.code == 19: - raise exceptions.TemporaryNodeFailure(payment) + if failure.code == 12: + raise exceptions.FeeInsufficient(payment) + elif failure.code == 13: + raise exceptions.IncorrectCLTVExpiry(payment) elif failure.code == 14: raise exceptions.ChannelDisabled(payment) + elif failure.code == 15: + raise exceptions.TemporaryChannelFailure(payment) elif failure.code == 18: raise exceptions.UnknownNextPeer(payment) - elif failure.code == 12: - raise exceptions.FeeInsufficient(payment) + elif failure.code == 19: + raise exceptions.TemporaryNodeFailure(payment) else: logger.info(failure) raise Exception(f"Unknown error: code {failure.code}") diff --git a/lndmanage/lib/rebalance.py b/lndmanage/lib/rebalance.py index d637acb..698bb71 100644 --- a/lndmanage/lib/rebalance.py +++ b/lndmanage/lib/rebalance.py @@ -162,6 +162,10 @@ def report_success_up_to_failed_hop(failed_hop_index: Optional[int]): failed_hop = int(e.payment.failure.failure_source_index) except exceptions.FeeInsufficient as e: failed_hop = int(e.payment.failure.failure_source_index) + except exceptions.IncorrectCLTVExpiry as e: + failed_hop = int(e.payment.failure.failure_source_index) + except exceptions.In as e: + failed_hop = int(e.payment.failure.failure_source_index) else: logger.debug(f"Preimage: {result.preimage.hex()}") logger.info("Success!\n") From 905c7d7282ca2f19ac393910818256637ad534d0 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Sat, 12 Feb 2022 10:45:46 +0100 Subject: [PATCH 07/15] rebalance: check amount and set default amount --- lndmanage/lib/rebalance.py | 76 +++++++++++++++++++++++--------------- lndmanage/lndmanage.py | 9 +++-- 2 files changed, 52 insertions(+), 33 deletions(-) diff --git a/lndmanage/lib/rebalance.py b/lndmanage/lib/rebalance.py index 698bb71..e41edd6 100644 --- a/lndmanage/lib/rebalance.py +++ b/lndmanage/lib/rebalance.py @@ -1,11 +1,13 @@ """Module for channel rebalancing.""" import logging import math -from typing import TYPE_CHECKING, Optional, Tuple, Dict +from typing import TYPE_CHECKING, Optional, Dict +import time from lndmanage.lib.rating import node_badness from lndmanage.lib.routing import Router from lndmanage.lib import exceptions +from lndmanage.lib.forwardings import get_channel_properties from lndmanage.lib.exceptions import ( RebalanceFailure, NoRoute, @@ -27,8 +29,9 @@ DEFAULT_MAX_FEE_RATE = 0.001000 DEFAULT_AMOUNT_SAT = 100000 -# MIN_FEE_RATE_AFFORDABLE is the minimal effective fee rate a rebalance attempt can cost -RESERVED_REBALANCE_FEE_RATE = 50 +RESERVED_REBALANCE_FEE_RATE_MILLI_MSAT = 50 # a buffer for the fee rate a rebalance route can cost +FORWARDING_STATS_DAYS = 30 # how many days will be taken into account when determining the rebalance direction +MIN_REBALANCE_AMOUNT_SAT = 20_000 class Rebalancer(object): @@ -265,7 +268,7 @@ def _get_rebalance_candidates( fee_rate_margin = channel_fee_rate_milli_msat - c['local_fee_rate'] # We enforce a mimimum amount of an acceptable fee rate, # because we need to also pay for rebalancing. - if fee_rate_margin > RESERVED_REBALANCE_FEE_RATE: + if fee_rate_margin > RESERVED_REBALANCE_FEE_RATE_MILLI_MSAT: c['fee_rate_margin'] = fee_rate_margin / 1_000_000 rebalance_candidates_filtered[k] = c else: # otherwise, we can afford very high fee rates @@ -328,25 +331,7 @@ def _maximal_local_balance_change( increase_local_balance: bool, unbalanced_channel_info: dict ) -> int: - """Tries to find out the amount to maximally send/receive given the - relative target and channel reserve constraints for channel balance - candidates. - - The target is expressed as a relative quantity between -1 and 1: - -1: channel has only local balance - 0: 50:50 balanced - 1: channel has only remote balance - - :param unbalancedness_target: - interpreted in terms of unbalancedness [-1...1] - :param unbalanced_channel_info: fees, capacity, initiator info - - :return: positive or negative amount in sat (encodes - the decrease/increase in the local balance) - """ - # both parties need to maintain a channel reserve of 1% - # according to BOLT 2 - + """Finds the amount to maximally send/receive via the channel.""" local_balance = unbalanced_channel_info['local_balance'] remote_balance = unbalanced_channel_info['remote_balance'] remote_channel_reserve = unbalanced_channel_info['remote_chan_reserve_sat'] @@ -419,6 +404,7 @@ def rebalance( candidates :raises TooExpensive: the rebalance became too expensive """ + # TODO: allow and convert to pubkey-based rebalancing if target and not (-1.0 <= target <= 1.0): raise ValueError("Target must be between -1.0 and 1.0.") @@ -452,11 +438,43 @@ def rebalance( if abs(initial_local_balance_change) <= 10_000: logger.info(f"Channel already balanced.") return 0 - # if no target is given, we enforce some default amount in the opposite direction of unbalancedness - elif not amount_sat: - amount_sat = DEFAULT_AMOUNT_SAT - initial_local_balance_change = int(math.copysign(1, unbalanced_channel_info['unbalancedness']) * amount_sat) - else: + elif not amount_sat: # if no target is given, we enforce some default amount + now_sec = time.time() + then_sec = now_sec - FORWARDING_STATS_DAYS * 24 * 3600 + forwarding_properties = get_channel_properties(self.node, then_sec, now_sec) + channel_properties = forwarding_properties.get(channel_id) + fees_in = channel_properties['fees_in'] + fees_out = channel_properties['fees_out'] + ub = unbalanced_channel_info['unbalancedness'] + flow = channel_properties['flow_direction'] + + if abs(ub) > 0.95: # there's no other option + initial_local_balance_change = int(math.copysign(1, ub) * DEFAULT_AMOUNT_SAT) + logger.debug("Default amount due to strong unbalancedness.") + elif fees_in or fees_out: # based on type of earnings + if fees_out > fees_in: # then we want to increase balance in channel + initial_local_balance_change = DEFAULT_AMOUNT_SAT + else: + initial_local_balance_change = -DEFAULT_AMOUNT_SAT + logger.debug("Default amount due to fees.") + elif not math.isnan(flow): # counter the flow, probably not executed + initial_local_balance_change = int(math.copysign(1, flow) * DEFAULT_AMOUNT_SAT) + logger.debug("Default amount due to flow.") + else: # fall back to unbalancedness + initial_local_balance_change = int(math.copysign(1, ub) * DEFAULT_AMOUNT_SAT) + logger.debug("Default amount due to unbalancedness.") + else: # based on manual amount, checking bounds + increase_local_balance = True if amount_sat > 0 else False + maximal_change = self._maximal_local_balance_change( + increase_local_balance=increase_local_balance, + unbalanced_channel_info=unbalanced_channel_info + ) + if abs(amount_sat) > maximal_change: + raise ValueError( + f"Channel cannot {'receive' if increase_local_balance else 'send'} " + f"(maximal value: {int(math.copysign(1, amount_sat) * maximal_change)} sat)." + f" lb: {unbalanced_channel_info['local_balance']} sat" + f" rb: {unbalanced_channel_info['remote_balance']} sat") initial_local_balance_change = amount_sat # determine budget and fee rate from local balance change: @@ -616,7 +634,7 @@ def rebalance( # with the current amount. To improve the success rate, we split the # amount. amount_sat //= 2 - if abs(amount_sat) < 30_000: + if abs(amount_sat) < MIN_REBALANCE_AMOUNT_SAT: raise RebalanceFailure( "It is unlikely we can rebalance the channel. Attempts with " "small amounts already failed.\n") diff --git a/lndmanage/lndmanage.py b/lndmanage/lndmanage.py index 55aa290..dd4b50c 100755 --- a/lndmanage/lndmanage.py +++ b/lndmanage/lndmanage.py @@ -165,9 +165,9 @@ def __init__(self): '--max-fee-sat', type=int, default=None, help='Sets the maximal fees in satoshis to be paid.') self.parser_rebalance.add_argument( - '--amount-sat', type=int, default=DEFAULT_AMOUNT_SAT, + '--amount-sat', type=int, default=None, help='Specifies the increase in local balance in sat. The amount can be' - 'negative to decrease the local balance.') + f'negative to decrease the local balance. Default: {DEFAULT_AMOUNT_SAT} sat.') self.parser_rebalance.add_argument( '--max-fee-rate', type=range_limited_float_type, default=DEFAULT_MAX_FEE_RATE, help='Sets the maximal effective fee rate to be paid.' @@ -181,8 +181,7 @@ def __init__(self): help=f"Allow rebalances that are uneconomic.", action='store_true') self.parser_rebalance.add_argument( - '--target', help=f'This feature is still experimental! ' - f'The unbalancedness target is between [-1, 1]. ' + '--target', help=f'The unbalancedness target is between [-1, 1]. ' f'A target of -1 leads to a maximal local balance, a target of 0 ' f'to a 50:50 balanced channel and a target of 1 to a maximal ' f'remote balance. Default is a target of 0.', @@ -520,6 +519,8 @@ def run_commands(self, node, args): target=args.target, amount_sat=args.amount_sat ) + except ValueError as e: + logger.error(e) except TooExpensive as e: logger.error(f"Too expensive: {e}") except RebalanceFailure as e: From b1057e897e7dc601b7c022750f6af051ee8ddba4 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Sat, 12 Feb 2022 11:56:51 +0100 Subject: [PATCH 08/15] hints: add time elapsed for payment --- lndmanage/lib/liquidityhints.py | 9 ++++++++- lndmanage/lib/rebalance.py | 11 +++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/lndmanage/lib/liquidityhints.py b/lndmanage/lib/liquidityhints.py index 34611c6..120f7ab 100644 --- a/lndmanage/lib/liquidityhints.py +++ b/lndmanage/lib/liquidityhints.py @@ -169,6 +169,7 @@ def __init__(self, source_node: str): self._liquidity_hints: Dict[ShortChannelID, LiquidityHint] = {} self._badness_hints: Dict[NodeID, int] = defaultdict(int) self._route_participations: Dict[NodeID, int] = defaultdict(int) + self._elapsed_time: Dict[NodeID, int] = defaultdict(int) def get_hint(self, channel_id: ShortChannelID) -> LiquidityHint: hint = self._liquidity_hints.get(channel_id) @@ -187,7 +188,7 @@ def update_cannot_send(self, node_from: NodeID, node_to: NodeID, channel_id: Sho hint = self.get_hint(channel_id) hint.update_cannot_send(node_from < node_to, amount) - def update_badness_hint(self, node: NodeID, badness): + def update_badness_hint(self, node: NodeID, badness: float): self._badness_hints[node] += badness logger.debug(f" report: update badness {badness} +=> {self._badness_hints[node]} (node: {node})") self.update_route_participation(node) @@ -196,6 +197,12 @@ def update_route_participation(self, node: NodeID): self._route_participations[node] += 1 logger.debug(f" report: update route participation to {self._route_participations[node]} (node: {node})") + def update_elapsed_time(self, node: NodeID, elapsed_time: float): + self._elapsed_time[node] += elapsed_time + part = self._route_participations[node] + avg_time = self._elapsed_time[node] / part if part else 0 + logger.debug(f" report: update elapsed time {elapsed_time} +=> {self._elapsed_time[node]} (avg: {avg_time}) (node: {node})") + def add_htlc(self, node_from: NodeID, node_to: NodeID, channel_id: ShortChannelID): hint = self.get_hint(channel_id) hint.add_htlc(node_from < node_to) diff --git a/lndmanage/lib/rebalance.py b/lndmanage/lib/rebalance.py index e41edd6..0ccfbc2 100644 --- a/lndmanage/lib/rebalance.py +++ b/lndmanage/lib/rebalance.py @@ -95,6 +95,7 @@ def _rebalance( count = 0 while True: + start_time = time.time() count += 1 if count > settings.REBALANCING_TRIALS: raise RebalancingTrialsExhausted @@ -136,11 +137,13 @@ def _rebalance( def report_success_up_to_failed_hop(failed_hop_index: Optional[int]): """Reports routing success to liquidity hints up to failed index, exclusively.""" + length_until_failed = failed_hop_index if failed_hop_index else len(route.hops) for hop, channel in enumerate(route.hops): - if failed_hop_index and hop == failed_hop_index: - break source_node = route.node_hops[hop] target_node = route.node_hops[hop + 1] + self.node.network.liquidity_hints.update_elapsed_time(source_node, elapsed_time / length_until_failed) + if failed_hop_index and hop == failed_hop_index: + break self.node.network.liquidity_hints.update_can_send(source_node, target_node, channel['chan_id'], amt_msat) # symmetrically penalize failed hop (decreasing away from the failed hop): @@ -176,6 +179,10 @@ def report_success_up_to_failed_hop(failed_hop_index: Optional[int]): self.node.network.save_liquidty_hints() return route.total_fee_msat + end_time = time.time() + elapsed_time = end_time - start_time + logger.debug(f" > time elapsed: {elapsed_time:3.1f} s") + if failed_hop: failed_channel_id = route.hops[failed_hop]['chan_id'] failed_source = route.node_hops[failed_hop] From bd6a7a574db966a981db402db70a357a9bf5ebd1 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Sun, 13 Feb 2022 11:08:31 +0100 Subject: [PATCH 09/15] hints: time penalty --- lndmanage/lib/forwardings.py | 2 ++ lndmanage/lib/liquidityhints.py | 36 ++++++++++++++++++++++++++++----- lndmanage/lib/rating.py | 9 ++++++++- lndmanage/lib/rebalance.py | 19 ++++++++--------- lndmanage/lib/routing.py | 1 + 5 files changed, 52 insertions(+), 15 deletions(-) diff --git a/lndmanage/lib/forwardings.py b/lndmanage/lib/forwardings.py index c4dd6b1..3692921 100644 --- a/lndmanage/lib/forwardings.py +++ b/lndmanage/lib/forwardings.py @@ -688,6 +688,8 @@ def get_node_properites( except ZeroDivisionError: node_properties_forwardings[node_id]["fees_in_per_week"] = float("nan") + # TODO: unify with information from liquidity hints + return node_properties_forwardings diff --git a/lndmanage/lib/liquidityhints.py b/lndmanage/lib/liquidityhints.py index 120f7ab..04097ef 100644 --- a/lndmanage/lib/liquidityhints.py +++ b/lndmanage/lib/liquidityhints.py @@ -1,3 +1,4 @@ +import math from collections import defaultdict import time from typing import Set, Dict @@ -167,9 +168,18 @@ class LiquidityHintMgr: def __init__(self, source_node: str): self.source_node = source_node self._liquidity_hints: Dict[ShortChannelID, LiquidityHint] = {} - self._badness_hints: Dict[NodeID, int] = defaultdict(int) + # could_not_route tracks node's failures to route + self._could_not_route: Dict[NodeID, int] = defaultdict(int) + # could_route tracks node's successes to route + self._could_route: Dict[NodeID, int] = defaultdict(int) + # elapsed_time is the cumulative time of payemtens up to the failing hop + self._elapsed_time: Dict[NodeID, float] = defaultdict(float) + # route_participations is the cumulative number of times a node was part of a + # payment route self._route_participations: Dict[NodeID, int] = defaultdict(int) - self._elapsed_time: Dict[NodeID, int] = defaultdict(int) + # badness_hints track the cumulative penalty (in units of a fee rate), which is + # large for nodes that are close to failure sources along a path + self._badness_hints: Dict[NodeID, float] = defaultdict(float) def get_hint(self, channel_id: ShortChannelID) -> LiquidityHint: hint = self._liquidity_hints.get(channel_id) @@ -182,15 +192,20 @@ def update_can_send(self, node_from: NodeID, node_to: NodeID, channel_id: ShortC logger.debug(f" report: can send {amount_msat // 1000} sat over channel {channel_id}") hint = self.get_hint(channel_id) hint.update_can_send(node_from < node_to, amount_msat) + self._could_route[node_from] += 1 def update_cannot_send(self, node_from: NodeID, node_to: NodeID, channel_id: ShortChannelID, amount: int): logger.debug(f" report: cannot send {amount // 1000} sat over channel {channel_id}") hint = self.get_hint(channel_id) hint.update_cannot_send(node_from < node_to, amount) + self._could_not_route[node_from] += 1 def update_badness_hint(self, node: NodeID, badness: float): self._badness_hints[node] += badness - logger.debug(f" report: update badness {badness} +=> {self._badness_hints[node]} (node: {node})") + part = self._route_participations[node] + badness = self._badness_hints[node] + avg = badness / part if part else 0 + logger.debug(f" report: update badness {badness} +=> badness (avg: {avg}) (node: {node})") self.update_route_participation(node) def update_route_participation(self, node: NodeID): @@ -199,8 +214,8 @@ def update_route_participation(self, node: NodeID): def update_elapsed_time(self, node: NodeID, elapsed_time: float): self._elapsed_time[node] += elapsed_time - part = self._route_participations[node] - avg_time = self._elapsed_time[node] / part if part else 0 + nfwd = self._could_route[node] + avg_time = self._elapsed_time[node] / nfwd if nfwd else 0 logger.debug(f" report: update elapsed time {elapsed_time} +=> {self._elapsed_time[node]} (avg: {avg_time}) (node: {node})") def add_htlc(self, node_from: NodeID, node_to: NodeID, channel_id: ShortChannelID): @@ -258,6 +273,17 @@ def penalty(self, node_from: NodeID, node_to: NodeID, edge: Dict, amount_msat: i return log_penalty * penalty + def time_penalty(self, node, amount) -> float: + nfwd = self._could_route[node] + elapsed_time = self._elapsed_time[node] + avg_time = elapsed_time / nfwd if nfwd else 0 + estimated_error = avg_time / elapsed_time if elapsed_time else float('inf') + # only give a time penalty if we have some certainty about it + if avg_time and estimated_error < 0.2: + return 0.000010 * math.exp(avg_time / 10 - 1) * amount + else: + return 0.000010 * amount + def badness_penalty(self, node_from: NodeID, amount: int) -> float: """We blacklist a node if the attempts to fail are exhausted. Otherwise we just scale up the effective fee proportional to the failed attempts.""" diff --git a/lndmanage/lib/rating.py b/lndmanage/lib/rating.py index f96b904..058dd51 100644 --- a/lndmanage/lib/rating.py +++ b/lndmanage/lib/rating.py @@ -89,8 +89,15 @@ def channel_weight(self, node_from: str, node_to: str, channel_info: Dict, amt_m # for small amounts route_length_fee_msat = 2_000 + # we discount on badness if we know that the channel can route + if liquidity_penalty == 0: + badness_penalty /= 2 + + # time penalty + time_penalty = self.network.liquidity_hints.time_penalty(node_from, amt_msat) + # linear combination of components - weight = fees + liquidity_penalty + badness_penalty + route_length_fee_msat + weight = fees + liquidity_penalty + badness_penalty + route_length_fee_msat + time_penalty return weight diff --git a/lndmanage/lib/rebalance.py b/lndmanage/lib/rebalance.py index 0ccfbc2..1ac55a4 100644 --- a/lndmanage/lib/rebalance.py +++ b/lndmanage/lib/rebalance.py @@ -125,7 +125,8 @@ def _rebalance( fee_rate_margin = (illiquid_channel['local_fee_rate'] - liquid_channel['local_fee_rate']) / 1_000_000 logger.info(f" > Expected gain: {(fee_rate_margin - effective_fee_rate) * amt_sat:3.3f} sat") if (effective_fee_rate > fee_rate_margin) and not self.force: - raise NotEconomic("This rebalance attempt doesn't lead to enough expected earnings.") + # raise NotEconomic("This rebalance attempt doesn't lead to enough expected earnings.") + pass if effective_fee_rate > self.max_effective_fee_rate: raise TooExpensive(f"Route is too expensive (rate too high). Rate: {effective_fee_rate:.6f}, " @@ -136,17 +137,21 @@ def _rebalance( f"{route.total_fee_msat / 1000:.3f} sat, budget: {budget_sat:.3f} sat") def report_success_up_to_failed_hop(failed_hop_index: Optional[int]): - """Reports routing success to liquidity hints up to failed index, exclusively.""" - length_until_failed = failed_hop_index if failed_hop_index else len(route.hops) + """Rates the route.""" + end_time = time.time() + elapsed_time = end_time - start_time + logger.debug(f" > time elapsed: {elapsed_time:3.1f} s") + + success_path_length = failed_hop_index + 1 if failed_hop_index else len(route.hops) for hop, channel in enumerate(route.hops): source_node = route.node_hops[hop] target_node = route.node_hops[hop + 1] - self.node.network.liquidity_hints.update_elapsed_time(source_node, elapsed_time / length_until_failed) + self.node.network.liquidity_hints.update_elapsed_time(source_node, elapsed_time / success_path_length) if failed_hop_index and hop == failed_hop_index: break self.node.network.liquidity_hints.update_can_send(source_node, target_node, channel['chan_id'], amt_msat) - # symmetrically penalize failed hop (decreasing away from the failed hop): + # symmetrically penalize a route about the error source if it failed if failed_hop_index: for node_number, node in enumerate(route.node_hops): badness = node_badness(node_number, failed_hop_index) @@ -179,10 +184,6 @@ def report_success_up_to_failed_hop(failed_hop_index: Optional[int]): self.node.network.save_liquidty_hints() return route.total_fee_msat - end_time = time.time() - elapsed_time = end_time - start_time - logger.debug(f" > time elapsed: {elapsed_time:3.1f} s") - if failed_hop: failed_channel_id = route.hops[failed_hop]['chan_id'] failed_source = route.node_hops[failed_hop] diff --git a/lndmanage/lib/routing.py b/lndmanage/lib/routing.py index 1a6db8a..4094de6 100644 --- a/lndmanage/lib/routing.py +++ b/lndmanage/lib/routing.py @@ -79,6 +79,7 @@ def __init__(self, node: 'LndNode', channel_hops: List[int], node_dest: str, amt logger.debug(f" Forward: {forward_msat / 1000:3.3f} sat") logger.info(f" Liquidity penalty: {self.node.network.liquidity_hints.penalty(node_from, node_to, channel_data, amt_msat, self.node.network.channel_rater.reference_fee_rate_milli_msat) / 1000: 3.3f} sat") logger.info(f" Badness penalty: {self.node.network.liquidity_hints.badness_penalty(node_from, amt_msat) / 1000: 3.3f} sat") + logger.info(f" Time penalty: {self.node.network.liquidity_hints.time_penalty(node_from, amt_msat) / 1000: 3.3f} sat") self._hops.append({ 'chan_id': channel_data['channel_id'], From 93ffcd5a8085c87c2c35e853031ba29e8740bf1f Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 15 Feb 2022 19:58:00 +0100 Subject: [PATCH 10/15] rebalance: pubkey option for rebalancing --- lndmanage/lib/forwardings.py | 4 +-- lndmanage/lib/info.py | 49 +++++++---------------------------- lndmanage/lib/ln_utilities.py | 43 +++++++++++++++++++++++++++++- lndmanage/lib/node.py | 8 +++++- lndmanage/lib/rating.py | 1 - lndmanage/lib/rebalance.py | 20 +++++++++----- lndmanage/lndmanage.py | 10 +++---- 7 files changed, 78 insertions(+), 57 deletions(-) diff --git a/lndmanage/lib/forwardings.py b/lndmanage/lib/forwardings.py index 3692921..63fcf41 100644 --- a/lndmanage/lib/forwardings.py +++ b/lndmanage/lib/forwardings.py @@ -44,7 +44,7 @@ def initialize_forwarding_stats(self, time_start: float, time_end: float): :param time_start: time interval start, unix timestamp :param time_end: time interval end, unix timestamp """ - channel_id_to_node_id = self.node.get_channel_id_to_node_id() + channel_id_to_node_id = self.node.channel_id_to_node_id() self.channel_forwarding_stats = defaultdict(ForwardingStatistics) self.node_forwarding_stats = defaultdict(ForwardingStatistics) @@ -565,7 +565,7 @@ def get_node_properites( f"Time interval (between first and last forwarding) is " f"{forwarding_analyzer.max_time_interval_days:6.2f} days." ) - channel_id_to_node_id = node.get_channel_id_to_node_id(open_only=True) + channel_id_to_node_id = node.channel_id_to_node_id(open_only=True) node_ids_with_open_channels = {nid for nid in channel_id_to_node_id.values()} open_channels = node.get_open_channels() diff --git a/lndmanage/lib/info.py b/lndmanage/lib/info.py index 4331f44..98fbd81 100644 --- a/lndmanage/lib/info.py +++ b/lndmanage/lib/info.py @@ -1,9 +1,12 @@ -import re import datetime -from typing import Tuple, Dict +from typing import Dict from lndmanage.lib.network_info import NetworkAnalysis -from lndmanage.lib import ln_utilities +from lndmanage.lib.ln_utilities import ( + parse_nodeid_channelid, + convert_channel_id_to_short_channel_id, + height_to_timestamp +) from lndmanage import settings import logging @@ -64,7 +67,7 @@ def parse_and_print(self, info): # analyzer = NetworkAnalysis(self.node) try: - channel_id, node_pub_key = self.parse(info) + channel_id, node_pub_key = parse_nodeid_channelid(info) except ValueError: logger.info("Info didn't represent neither a channel nor a node.") return @@ -84,9 +87,9 @@ def parse_and_print(self, info): general_info['node2_alias'] = \ self.node.network.node_alias(general_info['node2_pub']) general_info['blockheight'] = \ - ln_utilities.convert_channel_id_to_short_channel_id( + convert_channel_id_to_short_channel_id( channel_id)[0] - general_info['open_timestamp'] = ln_utilities.height_to_timestamp( + general_info['open_timestamp'] = height_to_timestamp( self.node, general_info['blockheight']) # TODO: if it's our channel, add extra info @@ -106,40 +109,6 @@ def parse_and_print(self, info): self.print_node_info(general_info) - def parse(self, info: str) -> Tuple[int, str]: - """Parse whether info contains a channel id or node public key and hand - it back. If no info could be extracted, raise a ValueError. - - :return: channel_id, node_pub_key - """ - exp_channel_id = re.compile("^[0-9]{13,20}$") - exp_short_channel_id = re.compile("^[0-9]{6}x[0-9]{3}x[0-9]$") - exp_chan_point = re.compile("^[a-z0-9]{64}:[0-9]$") - exp_node_id = re.compile("^[a-z0-9]{66}$") - - channel_id = None - node_pub_key = None - - # prepare input string info - if exp_channel_id.match(info) is not None: - logger.debug("Info represents channel id.") - channel_id = int(info) - elif exp_short_channel_id.match(info) is not None: - logger.debug("Info represents short channel id.") - # TODO: convert short channel id to channel id - channel_id = 0 - elif exp_chan_point.match(info) is not None: - # TODO: convert chan point to channel id - logger.debug("Info represents short channel id.") - channel_id = 0 - elif exp_node_id.match(info) is not None: - logger.debug("Info represents node public key.") - node_pub_key = info - else: - raise ValueError("Info string doesn't match any pattern.") - - return channel_id, node_pub_key - def print_channel_info(self, general_info: Dict): """ Prints the channel info with peer information. diff --git a/lndmanage/lib/ln_utilities.py b/lndmanage/lib/ln_utilities.py index dadb5d4..6827551 100644 --- a/lndmanage/lib/ln_utilities.py +++ b/lndmanage/lib/ln_utilities.py @@ -4,6 +4,10 @@ import re import time +import logging +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + def convert_short_channel_id_to_channel_id(blockheight, transaction, output): """ @@ -55,9 +59,46 @@ def unbalancedness_to_local_balance(unbalancedness: float, capacity: int, commit commit_fee = 0 if not initiator else commit_fee return -int(capacity * (unbalancedness - 1) / 2) - commit_fee, commit_fee + def height_to_timestamp(node, close_height): now = time.time() blocks_ago = node.blockheight - close_height time_ago = blocks_ago * 10 * 60 timestamp_sec = int(now - time_ago) - return timestamp_sec \ No newline at end of file + return timestamp_sec + + +def parse_nodeid_channelid(info: str) -> Tuple[int, str]: + """Parse whether info contains a channel id or node public key and hand + it back. If no info could be extracted, raise a ValueError. + + :return: channel_id, node_pub_key + """ + exp_channel_id = re.compile("^[0-9]{13,20}$") + exp_short_channel_id = re.compile("^[0-9]{6}x[0-9]{3}x[0-9]$") + exp_chan_point = re.compile("^[a-z0-9]{64}:[0-9]$") + exp_node_id = re.compile("^[a-z0-9]{66}$") + + channel_id = None + node_pub_key = None + + # prepare input string info + info = str(info) + if exp_channel_id.match(info) is not None: + logger.debug("Info represents channel id.") + channel_id = int(info) + elif exp_short_channel_id.match(info) is not None: + logger.debug("Info represents short channel id.") + # TODO: convert short channel id to channel id + channel_id = 0 + elif exp_chan_point.match(info) is not None: + # TODO: convert chan point to channel id + logger.debug("Info represents short channel id.") + channel_id = 0 + elif exp_node_id.match(info) is not None: + logger.debug("Info represents node public key.") + node_pub_key = info + else: + raise ValueError("Info string doesn't match any pattern.") + + return channel_id, node_pub_key \ No newline at end of file diff --git a/lndmanage/lib/node.py b/lndmanage/lib/node.py index 2fc399c..f71fb6a 100644 --- a/lndmanage/lib/node.py +++ b/lndmanage/lib/node.py @@ -442,7 +442,7 @@ def convert_to_days_ago(timestamp): sorted(channels.items(), key=lambda x: x[1]['alias'])) return sorted_dict - def get_channel_id_to_node_id(self, open_only=False) -> Dict[int, str]: + def channel_id_to_node_id(self, open_only=False) -> Dict[int, str]: channel_id_to_node_id = {} closed_channels = self.get_closed_channels() open_channels = self.get_open_channels() @@ -453,6 +453,12 @@ def get_channel_id_to_node_id(self, open_only=False) -> Dict[int, str]: channel_id_to_node_id[cid] = c['remote_pubkey'] return channel_id_to_node_id + def node_id_to_channel_ids(self, open_only=False) -> Dict[str, List[int]]: + node_channels_mapping = defaultdict(list) + for cid, nid in self.channel_id_to_node_id(open_only=open_only).items(): + node_channels_mapping[nid].append(cid) + return node_channels_mapping + def get_inactive_channels(self): """ Returns all inactive channels. diff --git a/lndmanage/lib/rating.py b/lndmanage/lib/rating.py index 058dd51..48dcd10 100644 --- a/lndmanage/lib/rating.py +++ b/lndmanage/lib/rating.py @@ -79,7 +79,6 @@ def channel_weight(self, node_from: str, node_to: str, channel_info: Dict, amt_m ) # routing fees - # TODO: play with offsets fees = ( abs(policy["fee_base_msat"]) + amt_msat * abs(policy["fee_rate_milli_msat"]) // 1_000_000 diff --git a/lndmanage/lib/rebalance.py b/lndmanage/lib/rebalance.py index 1ac55a4..4327bba 100644 --- a/lndmanage/lib/rebalance.py +++ b/lndmanage/lib/rebalance.py @@ -12,13 +12,12 @@ RebalanceFailure, NoRoute, NoRebalanceCandidates, - NotEconomic, RebalancingTrialsExhausted, DryRun, PaymentTimeOut, TooExpensive, ) -from lndmanage.lib.ln_utilities import unbalancedness_to_local_balance +from lndmanage.lib.ln_utilities import unbalancedness_to_local_balance, parse_nodeid_channelid from lndmanage import settings if TYPE_CHECKING: @@ -241,7 +240,7 @@ def _get_rebalance_candidates( # need to make sure we don't rebalance with the same channel or other channels # of the same node - map_channel_id_node_id = self.node.get_channel_id_to_node_id() + map_channel_id_node_id = self.node.channel_id_to_node_id() rebalance_node_id = map_channel_id_node_id[channel_id] removed_channels = [cid for cid, nid in map_channel_id_node_id.items() if nid == rebalance_node_id] rebalance_candidates = { @@ -378,7 +377,7 @@ def _node_is_multiple_connected(self, pub_key: str) -> bool: def rebalance( self, - channel_id: int, + node_id_channel_id: str, dry=False, target: float = None, amount_sat: int = None, @@ -397,7 +396,7 @@ def rebalance( 2. try to rebalance with the cheapest route (taking into account different metrics including fees) 3. if it doesn't work for several attempts, go to 1. with a reduced amount - :param channel_id: the id of the channel to be rebalanced + :param node_id_channel_id: the id of the peer or channel to be rebalanced :param dry: if set, it's a dry run :param target: specifies unbalancedness after rebalancing in [-1, 1] :param amount_sat: rebalance amount (target takes precedence) @@ -412,12 +411,19 @@ def rebalance( candidates :raises TooExpensive: the rebalance became too expensive """ - # TODO: allow and convert to pubkey-based rebalancing if target and not (-1.0 <= target <= 1.0): raise ValueError("Target must be between -1.0 and 1.0.") - # get a fresh channel list + # convert the node id to a channel id if possible self.channels = self.node.get_unbalanced_channels() + channel_id, node_id = parse_nodeid_channelid(node_id_channel_id) + if node_id: + node_id_to_channel_ids_map = self.node.node_id_to_channel_ids() + for nid, cs in node_id_to_channel_ids_map.items(): + if nid == node_id: + if len(cs) > 1: + raise ValueError("Several channels correspond to node id, please specify channel id.") + channel_id = cs[0] try: unbalanced_channel_info = self.channels[channel_id] except KeyError: diff --git a/lndmanage/lndmanage.py b/lndmanage/lndmanage.py index dd4b50c..d57a98a 100755 --- a/lndmanage/lndmanage.py +++ b/lndmanage/lndmanage.py @@ -159,13 +159,13 @@ def __init__(self): self.parser_rebalance = subparsers.add_parser( 'rebalance', help='rebalance a channel', formatter_class=argparse.ArgumentDefaultsHelpFormatter) - self.parser_rebalance.add_argument('channel', type=int, - help='channel_id') + self.parser_rebalance.add_argument('node_channel', type=str, + help='node id or channel id') self.parser_rebalance.add_argument( '--max-fee-sat', type=int, default=None, help='Sets the maximal fees in satoshis to be paid.') self.parser_rebalance.add_argument( - '--amount-sat', type=int, default=None, + '--amount', type=int, default=None, help='Specifies the increase in local balance in sat. The amount can be' f'negative to decrease the local balance. Default: {DEFAULT_AMOUNT_SAT} sat.') self.parser_rebalance.add_argument( @@ -514,10 +514,10 @@ def run_commands(self, node, args): rebalancer = Rebalancer(node, args.max_fee_rate, args.max_fee_sat, args.force) try: rebalancer.rebalance( - args.channel, + args.node_channel, dry=not args.reckless, target=args.target, - amount_sat=args.amount_sat + amount_sat=args.amount ) except ValueError as e: logger.error(e) From 4500f14376d440990fa0d3518be653080fd5c514 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Wed, 16 Feb 2022 10:28:26 +0100 Subject: [PATCH 11/15] readme: update rebalancing hints --- README.md | 50 +++++++++++++++----------------------------------- 1 file changed, 15 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 8d2ae2c..a7f4303 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,6 @@ lndmanage is a command line tool for advanced channel management of an * Rebalancing command [```rebalance```](#channel-rebalancing) * different rebalancing strategies can be chosen * a target 'balancedness' can be specified (e.g. to empty the channel) -* Circular self-payments ```circle``` * Recommendation of good nodes [```recommend-nodes```](#channel-opening-strategies) * Batched channel opening [```openchannels```](#batched-channel-opening) * Support of [```lncli```](#lncli-support) @@ -45,7 +44,6 @@ positional arguments: listchannels lists channels with extended information [see also subcommands with -h] listpeers lists peers with extended information rebalance rebalance a channel - circle circular self-payment recommend-nodes recommends nodes [see also subcommands with -h] report displays reports of activity on the node info displays info on channels and nodes @@ -141,45 +139,27 @@ Forwardings: ``` ## Channel Rebalancing -The workflow for rebalancing a channel goes as follows: - -* take a look at all your unbalanced channels with: - - ```$ lndmanage listchannels rebalance``` - - The output will look like: -``` - -------- Description -------- - cid channel id - ub unbalancedness [-1 ... 1] (0 is 50:50 balanced) - cap channel capacity [sat] - lb local balance [sat] - rb remote balance [sat] - pbf peer base fee [msat] - pfr peer fee rate - annotation channel annotation - alias alias - -------- Channels -------- - cid ub cap lb rb pbf pfr alias - xxxxxxxxxxxxxxxxxx -0.78 1000000 888861 99480 10 0.000200 abc - xxxxxxxxxxxxxxxxxx -0.63 1000000 814537 173768 300 0.000010 def - xxxxxxxxxxxxxxxxxx 0.55 2000000 450792 1540038 35 0.000002 ghi - xxxxxxxxxxxxxxxxxx 0.59 400000 81971 306335 400 0.000101 jkl - ... -``` +Channels with depleted liquidity can be rebalanced with channels of saturated liquidity by circular self-payments, that is, a payment leaves from the saturated channel and arrives in the depleted channel. This operation has the cost of paying routing fees. Another often overlooked cost is *state accumulation*. Rebalancing is a time consuming process where many attemps are needed, because one is often trying to counter the natural flow within the network. This can lead to enlarged databases and therefore overall slower operations and strain on the network, which is why rebalancing should be used sparingly! + +Which channels could be rebalanced? Typically, we want to rebalance channels that give us high fee earning expecations [to see those channels, see `$ lndmanage listchannels forwardings`, fields `fo/w` (fees earned per week) and `fi/w` (fees earned by other channels due to incoming payments per week)]. We want to increase local balance in channels with high `fo/w` and decrease local balance in channels with high `fi/w`. Another use case is to increase/decrease local balance for new channels to see whether there's demand for forwarding. Otherwise it is not helpful to perfectly rebalance a channel 50:50 in most cases. + +The workflow to rebalance a channel is as follows: +* ideally your channels have meaningful fee rates set (see `$ lndmanage update-fees`) +* the counterparty rebalance channels are chosen according to the expected earnings calculated from the fee rate that is set for the to-be-rebalanced channel, i.e., a channel with a low fee rate will send its liquidity to channels with high fee rates, such that this capital can earn fees in the future and vice-versa +* take a look at all your channels with forwarding activity and select a channel with high `fo/w` or `fi/w`, or high absolute values of `ub` + * the ```ub``` field tells you how unbalanced your channel is - and in which direction -* take a channel_id from the list you wish - to rebalance (target is a 50:50 balance) + and in which direction (negative means that the channel has mostly local balance - you may want to decrease the local balance) +* take a `channel_id` or `node_id` you wish to rebalance +* decide on whether you want to increase (positive `--amount`) or decrease your local balance (negative `--amount`), chose small amounts (< 1000000 sat) for better success +* if `--amount` is not set, the sign of liquidity change is determined automatically * do a dry run to see what's waiting for you - ```$ lndmanage rebalance --max-fee-sat 20 --max-fee-rate 0.00001 channel_id``` + ```$ lndmanage rebalance channel_id/node_id``` * read the output and if everything looks well, then run with the ```--reckless``` flag -* in order to increase the success probability of your rebalancing you - can try to do it in smaller chunks, which can be set by the flag - `--chunksize 0.5` (in this example only half the amounts are used) +* if you want to disable the fee rate selection of counterparty channels to increase success probability, run with the `--force` flag ## Forwarding Information A more sophisticated way to see if funds have to be reallocated is to From 79d4ec2d39deb95fe46d730323c11e5dd6a6d8b8 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 17 Feb 2022 09:26:37 +0100 Subject: [PATCH 12/15] badness hints: let them decay --- lndmanage/lib/liquidityhints.py | 33 ++++++++++++++++++++++++--------- lndmanage/lib/pathfinding.py | 2 ++ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/lndmanage/lib/liquidityhints.py b/lndmanage/lib/liquidityhints.py index 04097ef..85fd975 100644 --- a/lndmanage/lib/liquidityhints.py +++ b/lndmanage/lib/liquidityhints.py @@ -8,12 +8,10 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -DEFAULT_PENALTY_BASE_MSAT = 1000 # how much base fee we apply for unknown sending capability of a channel -DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH = 100 # how much relative fee we apply for unknown sending capability of a channel BLACKLIST_DURATION = 3600 # how long (in seconds) a channel remains blacklisted HINT_DURATION = 3600 # how long (in seconds) a liquidity hint remains valid -ATTEMPTS_TO_FAIL = 10 # if a node fails this often to forward a payment, we won't use it anymore -FAILURE_FEE_MSAT = 10_000 +BADNESS_DECAY_ADJUSTMENT_SEC = 10 * 60 # adjustment interval for badness hints +BADNESS_DECAY_SEC = 24 * 3600 # exponential decay time for badness class ShortChannelID(int): @@ -180,6 +178,13 @@ def __init__(self, source_node: str): # badness_hints track the cumulative penalty (in units of a fee rate), which is # large for nodes that are close to failure sources along a path self._badness_hints: Dict[NodeID, float] = defaultdict(float) + # badness hints have an exponential decay time of BADNESS_DECAY_SEC updated + # every BADNESS_DECAY_ADJUSTMENT_SEC + self._badness_timestamps: Dict[NodeID, float] = defaultdict(float) + + @property + def now(self): + return time.time() def get_hint(self, channel_id: ShortChannelID) -> LiquidityHint: hint = self._liquidity_hints.get(channel_id) @@ -202,10 +207,11 @@ def update_cannot_send(self, node_from: NodeID, node_to: NodeID, channel_id: Sho def update_badness_hint(self, node: NodeID, badness: float): self._badness_hints[node] += badness - part = self._route_participations[node] + participations = self._route_participations[node] badness = self._badness_hints[node] - avg = badness / part if part else 0 - logger.debug(f" report: update badness {badness} +=> badness (avg: {avg}) (node: {node})") + average = badness / participations if participations else 0 + logger.debug(f" report: update badness {badness} +=> badness (avg: {average}) (node: {node})") + self._badness_timestamps[node] = time.time() self.update_route_participation(node) def update_route_participation(self, node: NodeID): @@ -285,8 +291,17 @@ def time_penalty(self, node, amount) -> float: return 0.000010 * amount def badness_penalty(self, node_from: NodeID, amount: int) -> float: - """We blacklist a node if the attempts to fail are exhausted. Otherwise we just - scale up the effective fee proportional to the failed attempts.""" + """The badness penalty indicates how close a node was to the failing hop of + payment routes in units of a fee rate. This fee rate can accumulate and may + lead to complete ignoring of the node, which is why we let the badness penalty + decay in time to open up these payment paths again.""" + badness_timestamp = self._badness_timestamps[node_from] + if badness_timestamp: + time_delta = self.now - badness_timestamp + # only adjust after some time has passed, we don't want to evaluate this + # for every badness_penalty request + if time_delta > BADNESS_DECAY_ADJUSTMENT_SEC: + self._badness_hints[node_from] *= math.exp(-time_delta / BADNESS_DECAY_SEC) return amount * self._badness_hints[node_from] def add_to_blacklist(self, channel_id: ShortChannelID): diff --git a/lndmanage/lib/pathfinding.py b/lndmanage/lib/pathfinding.py index b3b5942..ad43012 100644 --- a/lndmanage/lib/pathfinding.py +++ b/lndmanage/lib/pathfinding.py @@ -2,6 +2,7 @@ import networkx as nx +from lndmanage.lib.utilities import profiled from lndmanage import settings import logging @@ -9,6 +10,7 @@ logger.addHandler(logging.NullHandler()) +@profiled def dijkstra(graph: nx.Graph, source: str, target: str, weight: Callable) -> List[str]: """Wrapper for calculating a shortest path given a weight function. From 8590bcc23b70cc695b4bce507ba3be2fbeadabbc Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 17 Feb 2022 09:40:56 +0100 Subject: [PATCH 13/15] hints: clarify and adjust time penalty --- lndmanage/lib/liquidityhints.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/lndmanage/lib/liquidityhints.py b/lndmanage/lib/liquidityhints.py index 85fd975..0ffe436 100644 --- a/lndmanage/lib/liquidityhints.py +++ b/lndmanage/lib/liquidityhints.py @@ -12,6 +12,9 @@ HINT_DURATION = 3600 # how long (in seconds) a liquidity hint remains valid BADNESS_DECAY_ADJUSTMENT_SEC = 10 * 60 # adjustment interval for badness hints BADNESS_DECAY_SEC = 24 * 3600 # exponential decay time for badness +TIME_EXPECTATION_ACCURACY = 0.2 # the relative error in estimating node reaction times +TIME_PENALTY_RATE = 0.000_010 # the default penalty for reaction time +TIME_NODE_IS_SLOW_SEC = 5 # the time a node is viewed as slow class ShortChannelID(int): @@ -280,15 +283,18 @@ def penalty(self, node_from: NodeID, node_to: NodeID, edge: Dict, amount_msat: i return log_penalty * penalty def time_penalty(self, node, amount) -> float: - nfwd = self._could_route[node] + """Gives a penalty for slow nodes in units of amount.""" + number_forwardings = self._could_route[node] elapsed_time = self._elapsed_time[node] - avg_time = elapsed_time / nfwd if nfwd else 0 + avg_time = elapsed_time / number_forwardings if number_forwardings else 0 estimated_error = avg_time / elapsed_time if elapsed_time else float('inf') - # only give a time penalty if we have some certainty about it - if avg_time and estimated_error < 0.2: - return 0.000010 * math.exp(avg_time / 10 - 1) * amount + if avg_time and estimated_error < TIME_EXPECTATION_ACCURACY: + # if we are able to estimate the node reaction time accurately, + # we penalize nodes that do have a reaction time larger than TIME_NODE_IS_SLOW + return TIME_PENALTY_RATE * math.exp(avg_time / TIME_NODE_IS_SLOW_SEC - 1) * amount else: - return 0.000010 * amount + # otherwise give a default penalty + return TIME_PENALTY_RATE * amount def badness_penalty(self, node_from: NodeID, amount: int) -> float: """The badness penalty indicates how close a node was to the failing hop of From eba01d82daad4631adf0a25176790babc0d451d9 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 22 Feb 2022 08:26:05 +0100 Subject: [PATCH 14/15] pathfinding: respect min/max htlc size --- lndmanage/lib/network.py | 50 ++++++++++++++++++---------------------- lndmanage/lib/rating.py | 8 +++++++ 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/lndmanage/lib/network.py b/lndmanage/lib/network.py index 74948bc..f81c5f4 100644 --- a/lndmanage/lib/network.py +++ b/lndmanage/lib/network.py @@ -118,6 +118,24 @@ def set_graph_and_edges(self): color=n.color) for e in raw_graph.edges: + policy1 = { + 'time_lock_delta': e.node1_policy.time_lock_delta, + 'fee_base_msat': e.node1_policy.fee_base_msat, + 'fee_rate_milli_msat': e.node1_policy.fee_rate_milli_msat, + 'last_update': e.node1_policy.last_update, + 'disabled': e.node1_policy.disabled, + 'min_htlc': e.node1_policy.min_htlc, + 'max_htlc_msat': e.node1_policy.max_htlc_msat + } + policy2 = { + 'time_lock_delta': e.node2_policy.time_lock_delta, + 'fee_base_msat': e.node2_policy.fee_base_msat, + 'fee_rate_milli_msat': e.node2_policy.fee_rate_milli_msat, + 'last_update': e.node2_policy.last_update, + 'disabled': e.node2_policy.disabled, + 'min_htlc': e.node2_policy.min_htlc, + 'max_htlc_msat': e.node2_policy.max_htlc_msat + } # create a dictionary for channel_id lookups self.edges[e.channel_id] = { 'node1_pub': e.node1_pub, @@ -127,20 +145,8 @@ def set_graph_and_edges(self): 'channel_id': e.channel_id, 'chan_point': e.chan_point, 'policies': { - e.node1_pub > e.node2_pub: { - 'time_lock_delta': e.node1_policy.time_lock_delta, - 'fee_base_msat': e.node1_policy.fee_base_msat, - 'fee_rate_milli_msat': e.node1_policy.fee_rate_milli_msat, - 'last_update': e.node1_policy.last_update, - 'disabled': e.node1_policy.disabled - }, - e.node2_pub > e.node1_pub: { - 'time_lock_delta': e.node2_policy.time_lock_delta, - 'fee_base_msat': e.node2_policy.fee_base_msat, - 'fee_rate_milli_msat': e.node2_policy.fee_rate_milli_msat, - 'last_update': e.node2_policy.last_update, - 'disabled': e.node2_policy.disabled - } + e.node1_pub > e.node2_pub: policy1, + e.node2_pub > e.node1_pub: policy2 } } @@ -152,20 +158,8 @@ def set_graph_and_edges(self): last_update=e.last_update, capacity=e.capacity, fees={ - e.node2_pub > e.node1_pub: - { - 'time_lock_delta': e.node2_policy.time_lock_delta, - 'fee_base_msat': e.node2_policy.fee_base_msat, - 'fee_rate_milli_msat': e.node2_policy.fee_rate_milli_msat, - 'disabled': e.node2_policy.disabled - }, - e.node1_pub > e.node2_pub: - { - 'time_lock_delta': e.node1_policy.time_lock_delta, - 'fee_base_msat': e.node1_policy.fee_base_msat, - 'fee_rate_milli_msat': e.node1_policy.fee_rate_milli_msat, - 'disabled': e.node1_policy.disabled - }, + e.node1_pub > e.node2_pub: policy1, + e.node2_pub > e.node1_pub: policy2, }) def number_channels(self, node_pub_key): diff --git a/lndmanage/lib/rating.py b/lndmanage/lib/rating.py index 48dcd10..a4f3a8b 100644 --- a/lndmanage/lib/rating.py +++ b/lndmanage/lib/rating.py @@ -61,6 +61,14 @@ def channel_weight(self, node_from: str, node_to: str, channel_info: Dict, amt_m if amt_msat // 1000 > channel_info["capacity"]: return math.inf + # we don't send if the minimal htlc amount is not respected + if amt_msat < channel_info.get("fees")[node_from > node_to]['min_htlc']: + return math.inf + + # we don't send if the max_htlc_msat is not respected + if amt_msat > channel_info.get("fees")[node_from > node_to]['max_htlc_msat']: + return math.inf + # we don't send over channel if it is disabled policy = channel_info.get("fees")[node_from > node_to] if policy["disabled"]: From cf9d88e79c692b1ac229ab8e904c043142fb599d Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 22 Feb 2022 08:53:21 +0100 Subject: [PATCH 15/15] rebalance: restrict counterparties by ub --- lndmanage/lib/rebalance.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/lndmanage/lib/rebalance.py b/lndmanage/lib/rebalance.py index 4327bba..7e4a426 100644 --- a/lndmanage/lib/rebalance.py +++ b/lndmanage/lib/rebalance.py @@ -9,12 +9,13 @@ from lndmanage.lib import exceptions from lndmanage.lib.forwardings import get_channel_properties from lndmanage.lib.exceptions import ( - RebalanceFailure, - NoRoute, - NoRebalanceCandidates, - RebalancingTrialsExhausted, DryRun, + NoRebalanceCandidates, + NoRoute, + NotEconomic, PaymentTimeOut, + RebalanceFailure, + RebalancingTrialsExhausted, TooExpensive, ) from lndmanage.lib.ln_utilities import unbalancedness_to_local_balance, parse_nodeid_channelid @@ -31,6 +32,7 @@ RESERVED_REBALANCE_FEE_RATE_MILLI_MSAT = 50 # a buffer for the fee rate a rebalance route can cost FORWARDING_STATS_DAYS = 30 # how many days will be taken into account when determining the rebalance direction MIN_REBALANCE_AMOUNT_SAT = 20_000 +MAX_UNBALANCEDNESS_FOR_CANDIDATES = 0.2 # sending rebalance candidates will not have an unbalancedness higher than this class Rebalancer(object): @@ -124,7 +126,8 @@ def _rebalance( fee_rate_margin = (illiquid_channel['local_fee_rate'] - liquid_channel['local_fee_rate']) / 1_000_000 logger.info(f" > Expected gain: {(fee_rate_margin - effective_fee_rate) * amt_sat:3.3f} sat") if (effective_fee_rate > fee_rate_margin) and not self.force: - # raise NotEconomic("This rebalance attempt doesn't lead to enough expected earnings.") + # TODO: We could look for the hop that charges the highest fee + # and blacklist it, to ignore it in the next path. pass if effective_fee_rate > self.max_effective_fee_rate: @@ -252,11 +255,13 @@ def _get_rebalance_candidates( for k, c in rebalance_candidates.items(): if candidates_send: maximal_can_send = self._maximal_local_balance_change(False, c) - if maximal_can_send > abs(local_balance_change): + if maximal_can_send > abs(local_balance_change) and \ + c['unbalancedness'] < MAX_UNBALANCEDNESS_FOR_CANDIDATES: rebalance_candidates_with_funds[k] = c else: maximal_can_receive = self._maximal_local_balance_change(True, c) - if maximal_can_receive > abs(local_balance_change): + if maximal_can_receive > abs(local_balance_change) and \ + c['unbalancedness'] > -MAX_UNBALANCEDNESS_FOR_CANDIDATES: rebalance_candidates_with_funds[k] = c # We only include rebalance candidates for which it makes economically sense to