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."""