Skip to content

Commit

Permalink
add "Economy Fee Mode"
Browse files Browse the repository at this point in the history
fixes #161
  • Loading branch information
C-Otto committed May 7, 2021
1 parent 3aa7faf commit 160f49e
Show file tree
Hide file tree
Showing 5 changed files with 140 additions and 14 deletions.
46 changes: 44 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ If this fails, make sure you're running Python 3.
usage: rebalance.py [-h] [--lnddir LNDDIR] [--grpc GRPC] [-r RATIO] [-l]
[-o | -i] [-f CHANNEL] [-t CHANNEL]
[-a AMOUNT | -p PERCENTAGE] [-e EXCLUDE]
[--max-fee-factor MAX_FEE_FACTOR]
[--max-fee-factor MAX_FEE_FACTOR | --econ-fee | --no-econ-fee]
optional arguments:
-h, --help show this help message and exit
Expand Down Expand Up @@ -81,6 +81,8 @@ rebalance:
(default: 10) Reject routes that cost more than x
times the lnd default (base: 1 sat, rate: 1 millionth
sat) per hop on average
--econ-fee, --no-econ-fee
(default: disabled) Economy Fee Mode, see README.md"
```

### List of channels
Expand Down Expand Up @@ -135,12 +137,52 @@ It is also possible to indicate the `--to/-t` channel by the number shown next t

If you do not specify the amount, the rebalance amount is determined automatically.
As an alternative to specify the amount you may also set a percentage of the rebalance amount using `-p`.
For example, the following command tries to sends 20% of the amount required to rebalance the channel:
For example, the following command tries to send 20% of the amount required to rebalance the channel:

`rebalance.py -t 23 -p 20`

The maximum amount you can send in one transaction currently is limited (by the protocol) to 4,294,967 satoshis.

### Fees

In order for the network to route your rebalance transaction, you have to pay fees.
To protect yourself from "greedy" peers, certain routes are rejected by default by using `--max-fee-factor` with a
default value of 10:
> Reject routes that cost more than 10 times the lnd default (base: 1 sat, rate: 1 millionth sat) per hop on average.
Note that it may be necessary to pay higher fees, i.e. you'd need to set a higher `--max-fee-factor`.

#### Economy Fee Mode
As an alternative to `--max-fee-factor`, you can also enable the Economy Fee Mode using `--econ-fee`.
If this mode is enabled, three different fees are taken into account:

1. The actual fee you'd have to pay for the rebalance transaction
2. The fee you'd earn if, instead of sending the funds due to your own rebalance transaction, your node is paid to forward the amount through the outbound (`--from`) channel
3. The fee you'd earn if, after the rebalance transaction is done, your node forwards the amount back again through the inbound (`--to`) channel

The rebalance transaction is not performed if the direct costs (1) plus the implicit costs (2) are higher than the
possible future income (3).

#### Example
You have lots of funds in channel A and nothing in channel B. You would like to send half of the funds through
channel A (outbound channel) through the lightning network, and finally back into channel B. This incurs a transaction
fee you'd have to pay for the transaction. This is the direct cost (1).

Furthermore, if in the future there is demand for your node to route funds
through channel A, you cannot do that as much (because you reduced outbound liquidity in the channel).
This is the implicit cost (2).

Finally, you rebalance channel B in the hope that lateron someone reqeusts your node to forward funds from your own node
through channel B towards the peer at the other end, so that you can earn fees for this.
These fees are the possible future income (3).

#### Warning
To determine the future income, the fee set as part of the channel policy is used in the computation.
As such, if you set a too high fee rate, with `--econ-mode` you'd allow more expensive rebalance
transactions. Please make sure to set realistic fee rates, which at best are already known to attract forwardings.
To protect you from making mistakes, `--econ-mode` only works if the inbound channel has a fee rate of less than
10,000 sat per 1M sat (which corresponds to a 1% fee).

## Contributing

Contributions are highly welcome!
Expand Down
17 changes: 16 additions & 1 deletion lnd.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ def get_channels(self):
self.channels = self.stub.ListChannels(request).channels
return self.channels

def get_route(self, pub_key, amount, ignored_pairs, ignored_nodes, first_hop_channel_id):
def get_route(self, pub_key, amount, ignored_pairs, ignored_nodes, first_hop_channel_id, fee_limit_sat):
if fee_limit_sat:
fee_limit = {"fixed": int(fee_limit_sat)}
else:
fee_limit = None
if pub_key:
last_hop_pubkey = base64.b16decode(pub_key, True)
else:
Expand All @@ -87,6 +91,7 @@ def get_route(self, pub_key, amount, ignored_pairs, ignored_nodes, first_hop_cha
last_hop_pubkey=last_hop_pubkey,
amt=amount,
ignored_pairs=ignored_pairs,
fee_limit=fee_limit,
ignored_nodes=ignored_nodes,
use_mission_control=True,
outgoing_chan_id=first_hop_channel_id,
Expand All @@ -97,6 +102,16 @@ def get_route(self, pub_key, amount, ignored_pairs, ignored_nodes, first_hop_cha
except:
return None

def get_policy_to(self, channel_id):
# node1_policy contains the fee base and rate for payments from node1 to node2
for edge in self.get_edges():
if edge.channel_id == channel_id:
if edge.node1_pub == self.get_own_pubkey():
result = edge.node1_policy
else:
result = edge.node2_policy
return result

def send_payment(self, payment_request, route):
last_hop = route.hops[-1]
last_hop.mpp_record.payment_addr = payment_request.payment_addr
Expand Down
76 changes: 70 additions & 6 deletions logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

DEFAULT_BASE_FEE_SAT_MSAT = 1000
DEFAULT_FEE_RATE_MSAT = 0.001
MAX_FEE_RATE = 10000


def debug(message):
Expand All @@ -15,7 +16,9 @@ def debugnobreak(message):


class Logic:
def __init__(self, lnd, first_hop_channel, last_hop_channel, amount, channel_ratio, excluded, max_fee_factor):
def __init__(self,
lnd, first_hop_channel, last_hop_channel, amount, channel_ratio, excluded, max_fee_factor, econ_fee
):
self.lnd = lnd
self.first_hop_channel = first_hop_channel
self.last_hop_channel = last_hop_channel
Expand All @@ -25,8 +28,10 @@ def __init__(self, lnd, first_hop_channel, last_hop_channel, amount, channel_rat
if excluded:
self.excluded = excluded
self.max_fee_factor = max_fee_factor
self.econ_fee = econ_fee

def rebalance(self):
fee_limit = self.get_fee_limit()
if self.last_hop_channel:
debug(("Sending {:,} satoshis to rebalance to channel with ID %d"
% self.last_hop_channel.chan_id).format(self.amount))
Expand All @@ -38,7 +43,7 @@ def rebalance(self):
debug("Forced first channel has ID %d" % self.first_hop_channel.chan_id)

payment_request = self.generate_invoice()
routes = Routes(self.lnd, payment_request, self.first_hop_channel, self.last_hop_channel)
routes = Routes(self.lnd, payment_request, self.first_hop_channel, self.last_hop_channel, fee_limit)

self.initialize_ignored_channels(routes)

Expand All @@ -52,6 +57,19 @@ def rebalance(self):
debug("Could not find any suitable route")
return False

def get_fee_limit(self):
fee_limit = None
if self.last_hop_channel and self.econ_fee:
policy = self.lnd.get_policy_to(self.last_hop_channel.chan_id)
if policy.fee_rate_milli_msat > MAX_FEE_RATE:
debug("Unable to use --econ-fee, policy for inbound channel looks odd (fee rate %s)"
% policy.fee_rate_milli_msat)
sys.exit(1)
fee_limit = self.fee_for_policy(self.amount, policy)
debug("Setting fee limit to %s (due to --econ-fee)" % int(fee_limit))

return fee_limit

def try_route(self, payment_request, route, routes, tried_routes):
if self.route_is_invalid(route, routes):
return False
Expand Down Expand Up @@ -110,16 +128,16 @@ def route_is_invalid(self, route, routes):
first_hop = route.hops[0]
last_hop = route.hops[-1]
if self.low_local_ratio_after_sending(first_hop, route.total_amt):
debugnobreak("First hop would have low local ratio after sending, ")
debugnobreak("Outbound channel would have low local ratio after sending, ")
routes.ignore_first_hop(self.get_channel_for_channel_id(first_hop.chan_id))
return True
if self.first_hop_and_last_hop_use_same_channel(first_hop, last_hop):
debugnobreak("First hop and last hop use same channel, ")
debugnobreak("Outbound and inbound channel are identical, ")
hop_before_last_hop = route.hops[-2]
routes.ignore_edge_from_to(last_hop.chan_id, hop_before_last_hop.pub_key, last_hop.pub_key)
return True
if self.high_local_ratio_after_receiving(last_hop):
debugnobreak("Last hop would have high local ratio after receiving, ")
debugnobreak("Inbound channel would have high local ratio after receiving, ")
hop_before_last_hop = route.hops[-2]
routes.ignore_edge_from_to(last_hop.chan_id, hop_before_last_hop.pub_key, last_hop.pub_key)
return True
Expand Down Expand Up @@ -163,10 +181,43 @@ def first_hop_and_last_hop_use_same_channel(first_hop, last_hop):
return first_hop.chan_id == last_hop.chan_id

def fees_too_high(self, route):
if self.econ_fee:
return self.fees_too_high_econ_fee(route)
hops_with_fees = len(route.hops) - 1
lnd_fees = hops_with_fees * (DEFAULT_BASE_FEE_SAT_MSAT + (self.amount * DEFAULT_FEE_RATE_MSAT))
limit = self.max_fee_factor * lnd_fees
return route.total_fees_msat > limit
high_fees = route.total_fees_msat > limit
if high_fees:
debugnobreak("High fees (%s sat over limit of %s), "
% (int((route.total_fees_msat - limit) / 1000), int(limit/1000)))
return high_fees

def fees_too_high_econ_fee(self, route):
policy_first_hop = self.lnd.get_policy_to(route.hops[0].chan_id)
amount = route.total_amt
missed_fee = self.fee_for_policy(amount, policy_first_hop)
policy_last_hop = self.lnd.get_policy_to(route.hops[-1].chan_id)
if policy_last_hop.fee_rate_milli_msat > MAX_FEE_RATE:
debug("Ignoring route, policy for inbound channel looks odd (fee rate %s)"
% policy_last_hop.fee_rate_milli_msat)
return True
expected_fee = self.fee_for_policy(amount, policy_last_hop)
rebalance_fee = route.total_fees
high_fees = rebalance_fee + missed_fee > expected_fee
if high_fees:
difference = rebalance_fee + missed_fee - expected_fee
debugnobreak("High fees ("
"%s expected future fee income for inbound channel, "
"have to pay %s now, "
"missing out on %s future fees for outbound channel, "
"difference %s), "
% (int(expected_fee), int(rebalance_fee), int(missed_fee), int(difference)))
return high_fees

@staticmethod
def fee_for_policy(amount, policy):
expected_fee_msat = amount / 1000000 * policy.fee_rate_milli_msat + policy.fee_base_msat / 1000
return expected_fee_msat

def generate_invoice(self):
if self.last_hop_channel:
Expand Down Expand Up @@ -196,9 +247,22 @@ def initialize_ignored_channels(self, routes):
from_pub_key = self.lnd.get_own_pubkey()
to_pub_key = self.last_hop_channel.remote_pubkey
routes.ignore_edge_from_to(chan_id, from_pub_key, to_pub_key, show_message=False)
if self.econ_fee:
self.ignore_first_hops_with_fee_rate_higher_than_last_hop(routes)
for channel in self.lnd.get_channels():
if self.low_local_ratio_after_sending(channel, self.amount):
routes.ignore_first_hop(channel, show_message=False)
if channel.chan_id in self.excluded:
debugnobreak("Channel is excluded, ")
routes.ignore_first_hop(channel)

def ignore_first_hops_with_fee_rate_higher_than_last_hop(self, routes):
policy_last_hop = self.lnd.get_policy_to(self.last_hop_channel.chan_id)
last_hop_fee_rate = policy_last_hop.fee_rate_milli_msat
from_pub_key = self.lnd.get_own_pubkey()
for channel in self.lnd.get_channels():
chan_id = channel.chan_id
policy = self.lnd.get_policy_to(chan_id)
if policy.fee_rate_milli_msat > last_hop_fee_rate:
to_pub_key = channel.remote_pubkey
routes.ignore_edge_from_to(chan_id, from_pub_key, to_pub_key, show_message=False)
9 changes: 7 additions & 2 deletions rebalance.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,10 @@ def main():
sys.exit(0)

max_fee_factor = arguments.max_fee_factor
econ_fee = arguments.econ_fee
excluded = arguments.exclude
return Logic(lnd, first_hop_channel, last_hop_channel, amount, channel_ratio, excluded,
max_fee_factor).rebalance()
max_fee_factor, econ_fee).rebalance()


def get_amount(arguments, first_hop_channel, last_hop_channel):
Expand Down Expand Up @@ -180,11 +181,15 @@ def get_argument_parser():
action="append",
help="Exclude the given channel ID as the outgoing channel (no funds will be taken "
"out of excluded channels)")
rebalance_group.add_argument("--max-fee-factor",
fee_group = rebalance_group.add_mutually_exclusive_group()
fee_group.add_argument("--max-fee-factor",
type=float,
default=10,
help="(default: 10) Reject routes that cost more than x times the lnd default "
"(base: 1 sat, rate: 1 millionth sat) per hop on average")
fee_group.add_argument("--econ-fee",
action=argparse.BooleanOptionalAction,
help="(default: disabled) Economy Fee Mode, see README.md")
return parser


Expand Down
6 changes: 3 additions & 3 deletions routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def debugnobreak(message):


class Routes:
def __init__(self, lnd, payment_request, first_hop_channel, last_hop_channel):
def __init__(self, lnd, payment_request, first_hop_channel, last_hop_channel, fee_limit):
self.lnd = lnd
self.payment_request = payment_request
self.first_hop_channel = first_hop_channel
Expand All @@ -23,6 +23,7 @@ def __init__(self, lnd, payment_request, first_hop_channel, last_hop_channel):
self.ignored_pairs = []
self.ignored_nodes = []
self.num_requested_routes = 0
self.fee_limit = fee_limit

def has_next(self):
self.update_routes()
Expand Down Expand Up @@ -55,7 +56,7 @@ def request_route(self):
else:
first_hop_channel_id = None
routes = self.lnd.get_route(last_hop_pubkey, amount, self.ignored_pairs,
self.ignored_nodes, first_hop_channel_id)
self.ignored_nodes, first_hop_channel_id, self.fee_limit)
if routes is None:
self.num_requested_routes = MAX_ROUTES_TO_REQUEST
else:
Expand Down Expand Up @@ -100,7 +101,6 @@ def ignore_node_with_highest_fee(self, route):
max_fee_hop = hop

pub_key = max_fee_hop.pub_key
debugnobreak("High fees (%s msat), " % max_fee_msat)
self.ignore_node(pub_key)

def ignore_edge_from_to(self, chan_id, from_pubkey, to_pubkey, show_message=True):
Expand Down

2 comments on commit 160f49e

@githorray
Copy link

@githorray githorray commented on 160f49e May 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just updated to master. Getting this error when trying to run.

$ ./rebalance.py 
Traceback (most recent call last):
  File "./rebalance.py", line 277, in <module>
    success = main()
  File "./rebalance.py", line 18, in main
    argument_parser = get_argument_parser()
  File "./rebalance.py", line 191, in get_argument_parser
    action=argparse.BooleanOptionalAction,
AttributeError: module 'argparse' has no attribute 'BooleanOptionalAction'

Update: Ahhh, it looks like this requires Python 3.9. Currently on 3.8.5. Will update and try again.

@C-Otto
Copy link
Owner Author

@C-Otto C-Otto commented on 160f49e May 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@githorray thanks, I just pushed a fix

Please sign in to comment.