Skip to content

Commit

Permalink
rebalance: constrain can send amounts
Browse files Browse the repository at this point in the history
  • Loading branch information
bitromortac committed Feb 9, 2022
1 parent de63838 commit 4036e99
Show file tree
Hide file tree
Showing 7 changed files with 103 additions and 96 deletions.
4 changes: 2 additions & 2 deletions lndmanage/lib/forwardings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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],
}
Expand Down
19 changes: 9 additions & 10 deletions lndmanage/lib/ln_utilities.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Contains Lightning network specific conversion utilities."""
from typing import Tuple

import re
import time
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions lndmanage/lib/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
126 changes: 57 additions & 69 deletions lndmanage/lib/rebalance.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
PaymentTimeOut,
TooExpensive,
)
from lndmanage.lib.ln_utilities import unbalancedness_to_local_balance
from lndmanage import settings

if TYPE_CHECKING:
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lndmanage/lndmanage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand Down
34 changes: 27 additions & 7 deletions test/test_ln_utilities.py
Original file line number Diff line number Diff line change
@@ -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],
)
8 changes: 4 additions & 4 deletions test/test_rebalance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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."""
Expand Down

0 comments on commit 4036e99

Please sign in to comment.