mirror of
https://github.com/bitromortac/lndmanage.git
synced 2026-08-13 12:33:37 +02:00
Merge pull request #139 from bitromortac/2209-rpc-route
replace route construction with rpc route
This commit is contained in:
commit
1b2446d5ac
14 changed files with 592 additions and 401 deletions
|
|
@ -13,9 +13,9 @@ logger = logging.getLogger(__name__)
|
|||
logger.addHandler(logging.NullHandler())
|
||||
|
||||
BLACKLIST_DURATION = 3600 # how long (in seconds) a channel remains blacklisted
|
||||
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
|
||||
HINT_DURATION = 3600 * 24 * 7 # how long (in seconds) a liquidity hint remains valid
|
||||
BADNESS_DECAY_ADJUSTMENT_SEC = 1 * 60 # adjustment interval for badness hints
|
||||
BADNESS_DECAY_SEC = 3600 * 24 * 4 # 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
|
||||
|
|
@ -71,11 +71,15 @@ class LiquidityHint:
|
|||
|
||||
@can_send_forward.setter
|
||||
def can_send_forward(self, new_amount_history: AmountHistory):
|
||||
if new_amount_history < self._can_send_forward:
|
||||
# we don't want to record less significant info
|
||||
# (sendable amount is lower than known sendable amount):
|
||||
# We don't want to record less significant info (sendable amount is
|
||||
# lower than known sendable amount), unless we the hint is not valid
|
||||
# anymore.
|
||||
if new_amount_history < self._can_send_forward and \
|
||||
not self.is_hint_invalid(self._can_send_forward.timestamp):
|
||||
return
|
||||
|
||||
self._can_send_forward = new_amount_history
|
||||
|
||||
# we make a sanity check that sendable amount is lower than not sendable amount
|
||||
if self._can_send_forward > self._cannot_send_forward:
|
||||
self._cannot_send_forward = AmountHistory()
|
||||
|
|
@ -88,10 +92,14 @@ class LiquidityHint:
|
|||
|
||||
@can_send_backward.setter
|
||||
def can_send_backward(self, new_amount_history: AmountHistory):
|
||||
if new_amount_history < self._can_send_backward:
|
||||
# don't overwrite with insignificant info
|
||||
# Don't overwrite with insignificant info unless the hint is not valid
|
||||
# anymore.
|
||||
if new_amount_history < self._can_send_backward and \
|
||||
not self.is_hint_invalid(self._can_send_backward.timestamp):
|
||||
return
|
||||
|
||||
self._can_send_backward = new_amount_history
|
||||
|
||||
# sanity check
|
||||
if self._can_send_backward > self._cannot_send_backward:
|
||||
self._cannot_send_backward = AmountHistory()
|
||||
|
|
@ -104,10 +112,14 @@ class LiquidityHint:
|
|||
|
||||
@cannot_send_forward.setter
|
||||
def cannot_send_forward(self, new_amount_history: AmountHistory):
|
||||
if new_amount_history > self._cannot_send_forward:
|
||||
# don't overwrite with insignificant info
|
||||
# Don't overwrite with insignificant info unless the hint is not valid
|
||||
# anymore.
|
||||
if new_amount_history > self._cannot_send_forward and \
|
||||
not self.is_hint_invalid(self._cannot_send_forward.timestamp):
|
||||
return
|
||||
|
||||
self._cannot_send_forward = new_amount_history
|
||||
|
||||
# sanity check
|
||||
if self._can_send_forward > self._cannot_send_forward:
|
||||
self._can_send_forward = AmountHistory()
|
||||
|
|
@ -120,10 +132,14 @@ class LiquidityHint:
|
|||
|
||||
@cannot_send_backward.setter
|
||||
def cannot_send_backward(self, new_amount_history: AmountHistory):
|
||||
if new_amount_history > self._cannot_send_backward:
|
||||
# don't overwrite with insignificant info
|
||||
# Don't overwrite with insignificant info unless the hint is not valid
|
||||
# anymore.
|
||||
if new_amount_history > self._cannot_send_backward and \
|
||||
not self.is_hint_invalid(self._cannot_send_backward.timestamp):
|
||||
return
|
||||
|
||||
self._cannot_send_backward = new_amount_history
|
||||
|
||||
# sanity check
|
||||
if self._can_send_backward > self._cannot_send_backward:
|
||||
self._can_send_backward = AmountHistory()
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class Network:
|
|||
self.node = node
|
||||
self.load_graph()
|
||||
self.load_liquidity_hints()
|
||||
self.channel_rater = ChannelRater(self)
|
||||
self.channel_rater = ChannelRater(self, node.pub_key)
|
||||
|
||||
@profiled
|
||||
def load_graph(self):
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ logger.addHandler(logging.NullHandler())
|
|||
|
||||
NUM_MAX_FORWARDING_EVENTS = 100000
|
||||
OPEN_EXPIRY_TIME_MINUTES = 8
|
||||
GRPC_TIMEOUT_SEC = 5 * 60
|
||||
|
||||
|
||||
class LndNode:
|
||||
|
|
@ -242,33 +243,25 @@ class LndNode:
|
|||
invoice = self._rpc.AddInvoice(lnd.Invoice(value=0, memo=memo))
|
||||
return invoice
|
||||
|
||||
def send_to_route(self, route: 'Route', payment_hash: bytes,
|
||||
payment_address: bytes):
|
||||
"""
|
||||
Takes bare route (list) and tries to send along it,
|
||||
trying to fulfill the invoice labeled by the given hash.
|
||||
def send_to_route(self, route: lnd.Route, payment_hash: bytes):
|
||||
"""Takes a route and sends to it."""
|
||||
|
||||
:param route: (list) of :class:`lib.routes.Route`
|
||||
:param payment_hash: invoice identifier
|
||||
:return:
|
||||
"""
|
||||
lnd_route = self._to_lnd_route(route)
|
||||
# set payment address for last hop
|
||||
lnd_route.hops[-1].tlv_payload = True
|
||||
lnd_route.hops[-1].mpp_record.payment_addr = payment_address
|
||||
lnd_route.hops[-1].mpp_record.total_amt_msat = lnd_route.hops[-1].amt_to_forward_msat
|
||||
# set payment hash
|
||||
request = lndrouter.SendToRouteRequest(
|
||||
route=lnd_route,
|
||||
route=route,
|
||||
payment_hash=payment_hash,
|
||||
)
|
||||
|
||||
try:
|
||||
# timeout after 5 minutes
|
||||
payment = self._routerrpc.SendToRouteV2(request, timeout=5 * 60)
|
||||
except _Rendezvous:
|
||||
raise PaymentTimeOut
|
||||
except _InactiveRpcError:
|
||||
raise PaymentTimeOut
|
||||
payment = self._routerrpc.SendToRouteV2(
|
||||
request, timeout=GRPC_TIMEOUT_SEC,
|
||||
)
|
||||
|
||||
except grpc.RpcError as e:
|
||||
if e.code() == grpc.StatusCode.DEADLINE_EXCEEDED:
|
||||
raise PaymentTimeOut
|
||||
|
||||
raise
|
||||
|
||||
if payment.HasField('failure'):
|
||||
failure = payment.failure # type: lnd.Failure.FailureCode
|
||||
logger.debug(f"Routing failure: {failure}")
|
||||
|
|
@ -287,11 +280,30 @@ class LndNode:
|
|||
elif failure.code == 19:
|
||||
raise exceptions.TemporaryNodeFailure(payment)
|
||||
else:
|
||||
logger.info(failure)
|
||||
raise Exception(f"Unknown error: code {failure.code}")
|
||||
logger.info(f"Unknown error: code: {failure.code}")
|
||||
raise exceptions.TemporaryChannelFailure(payment)
|
||||
|
||||
return payment
|
||||
|
||||
def build_route(self, amt_msat: int, outgoing_chan_id: int,
|
||||
hop_pubkeys: List[str], payment_addr: bytes) -> lnd.Route:
|
||||
""" Queries the routerrpc endpoint to build a route."""
|
||||
|
||||
final_cltv_delta = 144
|
||||
|
||||
# Convert hop_pubkeys to List[bytes]
|
||||
hop_pubkeys = [bytes.fromhex(n) for n in hop_pubkeys]
|
||||
|
||||
request = lndrouter.BuildRouteRequest(
|
||||
amt_msat = amt_msat,
|
||||
final_cltv_delta = final_cltv_delta,
|
||||
outgoing_chan_id = outgoing_chan_id,
|
||||
hop_pubkeys = hop_pubkeys,
|
||||
payment_addr = payment_addr,
|
||||
)
|
||||
|
||||
return self._routerrpc.BuildRoute(request, timeout=5 * 60).route
|
||||
|
||||
def get_raw_network_graph(self):
|
||||
try:
|
||||
graph = self._rpc.DescribeGraph(lnd.ChannelGraphRequest())
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from typing import Callable, List
|
||||
from lndmanage.lib.exceptions import NoRoute
|
||||
|
||||
import networkx as nx
|
||||
|
||||
|
|
@ -22,4 +23,8 @@ def dijkstra(graph: nx.Graph, source: str, target: str, weight: Callable) -> Lis
|
|||
:return: hops in terms of the node keys
|
||||
"""
|
||||
path = nx.shortest_path(graph, source, target, weight=weight)
|
||||
|
||||
if not path:
|
||||
raise NoRoute
|
||||
|
||||
return path
|
||||
|
|
|
|||
|
|
@ -12,11 +12,17 @@ logger.addHandler(logging.NullHandler())
|
|||
if TYPE_CHECKING:
|
||||
from lndmanage.lib.network import Network
|
||||
|
||||
BADNESS_RATE = 0.000_100
|
||||
# BADNESS_RATE represents the factor how quickly a node becomes bad if it's not
|
||||
# able to route or is in the vicinity of a failed hop.
|
||||
BADNESS_RATE = 0.000_050
|
||||
|
||||
def node_badness(node_index: int, failed_hop_index: int):
|
||||
"""Computes a badness for a node.
|
||||
|
||||
def node_badness(node_number: int, hop: int):
|
||||
return BADNESS_RATE * math.exp(-abs(node_number - (hop + 0.5)))
|
||||
indexes are zero-based:
|
||||
A(0) -hop0- B(1) -hop1- C(2)
|
||||
"""
|
||||
return BADNESS_RATE * math.exp(-abs(failed_hop_index - (node_index + 0.5)))
|
||||
|
||||
|
||||
class ChannelRater:
|
||||
|
|
|
|||
|
|
@ -59,8 +59,6 @@ class Rebalancer(object):
|
|||
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:
|
||||
|
|
@ -73,8 +71,6 @@ class Rebalancer(object):
|
|||
: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 secret
|
||||
:param budget_sat: budget for the rebalance in sat
|
||||
:param dry: specifies, if it is a dry run
|
||||
|
||||
|
|
@ -90,6 +86,11 @@ class Rebalancer(object):
|
|||
:raises NotEconomic: we would effectively loose money due to not enough expected
|
||||
earnings in the future
|
||||
"""
|
||||
# We create an invoice and a payment hash for the requested rebalance
|
||||
# amount.
|
||||
invoice = self.node.get_invoice(amt_sat, memo=f"lndmanage: rebalance")
|
||||
payment_hash, payment_address = invoice.r_hash, invoice.payment_addr
|
||||
|
||||
# be up to date with the blockheight, otherwise could lead to cltv errors
|
||||
self.node.update_blockheight()
|
||||
amt_msat = amt_sat * 1000
|
||||
|
|
@ -99,32 +100,47 @@ class Rebalancer(object):
|
|||
start_time = time.time()
|
||||
count += 1
|
||||
if count > settings.REBALANCING_TRIALS:
|
||||
raise RebalancingTrialsExhausted
|
||||
exception = RebalancingTrialsExhausted
|
||||
exception.trials = count
|
||||
raise exception
|
||||
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:
|
||||
raise NoRoute
|
||||
route = self.router.route_from_constraints(
|
||||
send_channels, receive_channels, amt_msat, payment_address,
|
||||
)
|
||||
|
||||
effective_fee_rate = route.total_fee_msat / route.total_amt_msat
|
||||
# Display info about the route.
|
||||
effective_fee_rate = route.total_fees_msat / route.total_amt_msat
|
||||
amount_msat = route.total_amt_msat - route.total_fees_msat
|
||||
logger.info(
|
||||
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" > Route summary: amount: { amount_msat / 1000:3.3f} "
|
||||
f"sat, total fees: {route.total_fees_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}")
|
||||
f"number of hops: {len(route.hops)}")
|
||||
|
||||
# check if route makes sense
|
||||
illiquid_channel_id = route.channel_hops[-1]
|
||||
# Check that the route includes send and receive channels.
|
||||
illiquid_channel_id = route.hops[-1].chan_id
|
||||
illiquid_channel = self.channels[illiquid_channel_id]
|
||||
liquid_channel_id = route.channel_hops[0]
|
||||
liquid_channel_id = route.hops[0].chan_id
|
||||
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")
|
||||
# Check that the last hop is a channel with a receiving peer.
|
||||
# last_hop_node is the node from which we receive.
|
||||
last_hop_node = route.hops[-2].pub_key
|
||||
for info in receive_channels.values():
|
||||
if info['remote_pubkey'] == last_hop_node:
|
||||
break
|
||||
else:
|
||||
raise Exception("last hop is not in receive channels")
|
||||
|
||||
assert liquid_channel_id in list(send_channels.keys()), \
|
||||
"sending channel should be in send list"
|
||||
|
||||
# Check the economics of the route.
|
||||
fee_rate_margin = (illiquid_channel['local_fee_rate'] - \
|
||||
liquid_channel['local_fee_rate']) / 1_000_000
|
||||
gain = (fee_rate_margin - effective_fee_rate) * amt_sat
|
||||
logger.info(f" > Expected gain: {gain:3.3f} sat")
|
||||
if (effective_fee_rate > fee_rate_margin) and not self.force:
|
||||
# TODO: We could look for the hop that charges the highest fee
|
||||
# and blacklist it, to ignore it in the next path.
|
||||
|
|
@ -134,38 +150,85 @@ class Rebalancer(object):
|
|||
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:
|
||||
if route.total_fees_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")
|
||||
f"{route.total_fees_msat / 1000:.3f} sat, budget: {budget_sat:.3f} sat")
|
||||
|
||||
def report_success_up_to_failed_hop(failed_hop_index: Optional[int]):
|
||||
"""Rates the route."""
|
||||
# all_node_hops includes all node pubkeys involved in the route,
|
||||
# starts with own node, ends with own node.
|
||||
all_node_hops = [self.node.pub_key]
|
||||
all_node_hops.extend([h.pub_key for h in route.hops])
|
||||
|
||||
def report_elapsed_time(fail_hop_idx: int=None):
|
||||
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 / 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, amt_msat,
|
||||
# If the failed hop index is zero, this means that the first hop
|
||||
# failed and that there was no successful part of the route.
|
||||
# Thus, the failed hop index indicates the length of the
|
||||
# successful part of the route.
|
||||
success_path_length = fail_hop_idx if fail_hop_idx else len(route.hops)
|
||||
|
||||
for hop, _ in enumerate(route.hops):
|
||||
from_node = all_node_hops[hop]
|
||||
|
||||
# We could not reach the to_node, but we still update the
|
||||
# elapsed time for the from_node.
|
||||
self.node.network.liquidity_hints.update_elapsed_time(
|
||||
from_node, elapsed_time / success_path_length,
|
||||
)
|
||||
|
||||
# symmetrically penalize a route about the error source if it failed
|
||||
def report_success_up_to_failed_hop(failed_hop_index: Optional[int]):
|
||||
# We report how long it took to get the outcome of the payment.
|
||||
report_elapsed_time(failed_hop_index)
|
||||
|
||||
# Report that the the successful hops could route a payment.
|
||||
for hop, _ in enumerate(route.hops):
|
||||
from_node = all_node_hops[hop]
|
||||
to_node = all_node_hops[hop+1]
|
||||
|
||||
if failed_hop_index and hop == failed_hop_index:
|
||||
break
|
||||
|
||||
self.node.network.liquidity_hints.update_can_send(
|
||||
from_node, to_node, amt_msat,
|
||||
)
|
||||
|
||||
# Compute node badness and report all nodes.
|
||||
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)
|
||||
for hop, hop_details in enumerate(route.hops):
|
||||
badness = node_badness(hop, failed_hop_index)
|
||||
to_node = hop_details.pub_key
|
||||
|
||||
self.node.network.liquidity_hints.update_badness_hint(
|
||||
to_node, badness,
|
||||
)
|
||||
|
||||
if not dry:
|
||||
try:
|
||||
result = self.node.send_to_route(route, payment_hash, payment_address)
|
||||
result = self.node.send_to_route(route, payment_hash)
|
||||
except PaymentTimeOut:
|
||||
raise PaymentTimeOut
|
||||
# If we experience a payment timeout that means that an HTLC
|
||||
# is still in-flight. For rebalances we can ignore this
|
||||
# ocasionally unlike for payments where we only want a
|
||||
# payment to be paid only once.
|
||||
report_elapsed_time()
|
||||
|
||||
logger.info(" > Rebalance timed out.")
|
||||
|
||||
# We need to create a new invoice in order to not send again
|
||||
# to the same payment hash.
|
||||
invoice = self.node.get_invoice(
|
||||
amt_sat, memo=f"lndmanage: rebalance",
|
||||
)
|
||||
payment_hash = invoice.r_hash
|
||||
payment_address = invoice.payment_addr
|
||||
|
||||
self.node.network.save_liquidty_hints()
|
||||
|
||||
continue
|
||||
|
||||
# TODO: check whether the failure source is correctly attributed
|
||||
except exceptions.TemporaryChannelFailure as e:
|
||||
failed_hop = int(e.payment.failure.failure_source_index)
|
||||
|
|
@ -184,12 +247,13 @@ class Rebalancer(object):
|
|||
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
|
||||
|
||||
return route.total_fees_msat
|
||||
|
||||
if failed_hop:
|
||||
failed_channel_id = route.hops[failed_hop]['chan_id']
|
||||
failed_source = route.node_hops[failed_hop]
|
||||
failed_target = route.node_hops[failed_hop + 1]
|
||||
failed_channel_id = route.hops[failed_hop].chan_id
|
||||
failed_source = all_node_hops[failed_hop]
|
||||
failed_target = all_node_hops[failed_hop + 1]
|
||||
|
||||
if failed_channel_id in [send_channels, receive_channels]:
|
||||
raise RebalanceFailure(
|
||||
|
|
@ -201,10 +265,11 @@ class Rebalancer(object):
|
|||
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" > Failed: hop: {failed_hop}, 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.debug(f" > Node hops {all_node_hops}")
|
||||
channel_hops = [h.chan_id for h in route.hops]
|
||||
logger.debug(f" > Channel hops {channel_hops}")
|
||||
|
||||
# report that channel could not route the amount to liquidity hints
|
||||
self.node.network.liquidity_hints.update_cannot_send(
|
||||
|
|
@ -605,12 +670,6 @@ class Rebalancer(object):
|
|||
f"of {initial_local_balance_change} sat. "
|
||||
f"Fees paid up to now: {total_fees_paid_msat / 1000:.3f} sat.")
|
||||
|
||||
# for each rebalance amount, get a new invoice
|
||||
invoice = self.node.get_invoice(
|
||||
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}
|
||||
|
|
@ -623,8 +682,8 @@ class Rebalancer(object):
|
|||
# attempt the rebalance
|
||||
try:
|
||||
rebalance_fees_msat = self._rebalance(
|
||||
send_channels, receive_channels, abs(amount_sat), payment_hash,
|
||||
payment_address, budget_sat, dry=dry)
|
||||
send_channels, receive_channels, abs(amount_sat),
|
||||
budget_sat, dry=dry)
|
||||
|
||||
# account for running costs / target
|
||||
budget_sat -= rebalance_fees_msat // 1000
|
||||
|
|
|
|||
|
|
@ -1,283 +1,264 @@
|
|||
from typing import List, Dict, TYPE_CHECKING
|
||||
import random
|
||||
|
||||
import grpc
|
||||
|
||||
from lndmanage.lib.data_types import NodePair
|
||||
from lndmanage.lib.exceptions import RouteWithTooSmallCapacity, NoRoute
|
||||
from lndmanage.lib.exceptions import NoRoute
|
||||
from lndmanage.lib.pathfinding import dijkstra
|
||||
from lndmanage import settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lndmanage.lib.node import LndNode
|
||||
import lndmanage.grpc_compiled.lightning_pb2 as lnd
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.addHandler(logging.NullHandler())
|
||||
|
||||
|
||||
def calculate_fees_on_policy(amt_msat, policy):
|
||||
return policy['fee_base_msat'] + amt_msat * policy['fee_rate_milli_msat'] // 1000000
|
||||
|
||||
|
||||
class Route(object):
|
||||
"""Deals with the onion route construction from list of channels. Calculates fees
|
||||
and cltvs.
|
||||
"""
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
self.node = node
|
||||
self.blockheight = node.blockheight
|
||||
logger.debug(f"Blockheight: {self.blockheight}")
|
||||
self.channel_hops = channel_hops
|
||||
self._hops = []
|
||||
self._node_hops = [node_dest]
|
||||
forward_msat = amt_msat
|
||||
final_cltv = 144
|
||||
fees_msat_container = [0]
|
||||
cltv_delta = [0]
|
||||
node_to = node_dest
|
||||
node_from = None
|
||||
policy = None
|
||||
|
||||
logger.debug("Route construction starting.")
|
||||
# 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_to == channel_data['node2_pub']:
|
||||
try:
|
||||
policy = policies[channel_data['node1_pub'] > channel_data['node2_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_from = channel_data['node2_pub']
|
||||
|
||||
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_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
|
||||
forward_msat = amt_msat + sum(fees_msat_container[:ichannel])
|
||||
fees_msat_container.append(fees_msat)
|
||||
|
||||
node_pair = NodePair((channel_data['node1_pub'], channel_data['node2_pub']))
|
||||
capacity = self.node.network.max_pair_capacity[node_pair]
|
||||
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_from, node_to, capacity, 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'],
|
||||
'chan_capacity': channel_data['capacity'],
|
||||
'amt_to_forward': forward_msat // 1000,
|
||||
'fee': fees_msat_container[-2] // 1000,
|
||||
'expiry': self.blockheight + final_cltv + sum(cltv_delta[:ichannel]),
|
||||
'amt_to_forward_msat': forward_msat,
|
||||
'fee_msat': fees_msat_container[-2],
|
||||
})
|
||||
|
||||
cltv_delta.append(policy['time_lock_delta'])
|
||||
node_to = node_from
|
||||
|
||||
self.hops = list(reversed(self._hops))
|
||||
self.node_hops = list(reversed(self._node_hops))
|
||||
self.total_amt_msat = amt_msat + sum(fees_msat_container[:-1])
|
||||
self.total_fee_msat = sum(fees_msat_container[:-1])
|
||||
self.total_time_lock = sum(cltv_delta[:-1]) + self.blockheight + final_cltv
|
||||
|
||||
def _debug_route(self):
|
||||
"""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']}"
|
||||
f" c:{h['expiry'] - self.node.blockheight}")
|
||||
channel_info = self.node.network.edges[h['chan_id']]
|
||||
node1 = channel_info['node1_pub']
|
||||
node2 = channel_info['node2_pub']
|
||||
logger.debug(f"{node1[:5]}, {channel_info['node1_policy']}")
|
||||
logger.debug(f"{node2[:5]}, {channel_info['node2_policy']}")
|
||||
logger.debug(f"tl:{self.total_time_lock} ta:{self.total_amt_msat} tf:{self.total_fee_msat}")
|
||||
def fees_for_policy(amt_msat, policy):
|
||||
return policy['fee_base_msat'] + \
|
||||
amt_msat * policy['fee_rate_milli_msat'] // 1000000
|
||||
|
||||
|
||||
class Router(object):
|
||||
"""Contains utilities for constructing routes."""
|
||||
"""Contains utilities for route construction."""
|
||||
|
||||
def __init__(self, node: 'LndNode'):
|
||||
self.node = node
|
||||
|
||||
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
|
||||
:return: list of channel_ids
|
||||
"""
|
||||
channels = []
|
||||
for p in range(len(node_route) - 1):
|
||||
channels.append(
|
||||
self._determine_channel(
|
||||
node_route[p], node_route[p + 1], amt_msat)[1])
|
||||
return channels
|
||||
|
||||
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.
|
||||
def find_path(self, node_from: str, node_to: str,
|
||||
amt_msat: int) -> List[str]:
|
||||
"""Looks for a path from one node to another for a certain amount.
|
||||
|
||||
:param node_from: pubkey
|
||||
:param node_to: pubkey
|
||||
:param amt_msat: amount to send in msat
|
||||
:return: route
|
||||
|
||||
:return: list of pubkey hops
|
||||
"""
|
||||
self.node.network.channel_rater.blacklisted_nodes.append(self.node.pub_key) # excludes self-loops
|
||||
|
||||
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)
|
||||
# Exclude self-loops.
|
||||
self.node.network.channel_rater.blacklisted_nodes.append(
|
||||
self.node.pub_key,
|
||||
)
|
||||
|
||||
if not route:
|
||||
raise NoRoute
|
||||
def weight_function(v, u, e):
|
||||
return self.node.network.channel_rater.node_to_node_weight(
|
||||
v, u, e, amt_msat,
|
||||
)
|
||||
|
||||
# Perform a Dijkstra shortest path search.
|
||||
# TODO: known limitation: does not include fees of fees.
|
||||
route = dijkstra(
|
||||
self.node.network.graph, node_from, node_to, weight=weight_function,
|
||||
)
|
||||
|
||||
return route
|
||||
|
||||
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.
|
||||
def check_route(self, route):
|
||||
"""Checks a route for sanity and gives debug output."""
|
||||
|
||||
:param node_from: pubkey
|
||||
:param node_to: pubkey
|
||||
:param amt_msat: amount to send in msat
|
||||
:return: channel_id
|
||||
"""
|
||||
number_edges = self.node.network.graph.number_of_edges(node_from, node_to)
|
||||
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.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])
|
||||
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
|
||||
# We check that the route is not just sending and receiveing over the
|
||||
# same channel.
|
||||
if len(route.hops) == 2:
|
||||
raise NoRoute(f"only minimal route available: self -> other -> "+
|
||||
"self: {rpc_error.details()}")
|
||||
|
||||
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]
|
||||
# We check that the chosen channels have some finite chance of success
|
||||
# and that they are not blacklisted.
|
||||
node_from = self.node.pub_key
|
||||
for i, hop in enumerate(route.hops):
|
||||
node_to = hop.pub_key
|
||||
|
||||
# We don't want our node to be inside the path.
|
||||
if i > 0 and i < len(route.hops) - 1:
|
||||
assert node_to != self.node.pub_key, "our node is inside of the path"
|
||||
|
||||
# Fetch the channel policy.
|
||||
edge_data = None
|
||||
edges = self.node.network.graph[node_from][node_to]
|
||||
for edge in edges.values():
|
||||
if edge['channel_id'] == hop.chan_id:
|
||||
edge_data = edge
|
||||
break
|
||||
if not edge_data:
|
||||
raise NoRoute("channel not found in local graph")
|
||||
|
||||
# Display some debug output.
|
||||
logger.info(f" Hop {i}: {hop.chan_id} (cap: {edge_data['capacity']} sat): "
|
||||
f"{self.node.network.node_alias(node_from)} -> " +
|
||||
f"{self.node.network.node_alias(node_to)}")
|
||||
logger.debug(f" Fees next: {hop.fee_msat:9.3f} sat")
|
||||
|
||||
_ = self.node.network.channel_rater.channel_weight(
|
||||
node_from, node_to, edge_data, hop.amt_to_forward_msat,
|
||||
)
|
||||
|
||||
node_from = node_to
|
||||
|
||||
def find_node_route(self, source_pubkey: str, target_pubkey: str,
|
||||
amt_msat: int) -> List[int]:
|
||||
"""Finds a route from source to target for a certain amount. Returns a
|
||||
list of pubkeys."""
|
||||
|
||||
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_route = self.get_route_from_to_nodes(
|
||||
node_hops = self.find_path(
|
||||
source_pubkey, target_pubkey, amt_msat)
|
||||
|
||||
return self._node_route_to_channel_route(node_route, amt_msat)
|
||||
return node_hops
|
||||
|
||||
def get_route(
|
||||
def route_from_constraints(
|
||||
self,
|
||||
send_channels: Dict[int, dict],
|
||||
receive_channels: Dict[int, dict],
|
||||
amt_msat: int
|
||||
) -> Route:
|
||||
"""Calculates a route from send_channels to receive_channels.
|
||||
amt_msat: int,
|
||||
payment_addr: bytes,
|
||||
) -> 'lnd.Route':
|
||||
"""Calculates a route that leaves over send_channels and enters via
|
||||
receive_channels.
|
||||
|
||||
:param send_channels: channel ids to send from
|
||||
:param receive_channels: channel ids to receive to
|
||||
:param amt_msat: payment amount in msat
|
||||
|
||||
:return: a route for rebalancing
|
||||
:return: a route that can be sent do via the lnd api
|
||||
"""
|
||||
this_node = self.node.pub_key # TODO: make this a parameter for general route calculation
|
||||
|
||||
this_node = self.node.pub_key
|
||||
|
||||
# Reset old blacklists.
|
||||
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)
|
||||
# We will ask for a route from source to target. The specific source and
|
||||
# target depends on the input channels.
|
||||
|
||||
# Case1: single send channel and multiple receive channels:
|
||||
# * this_node -(send channel)->
|
||||
# [* source ->
|
||||
# ... ->
|
||||
# * receiver neighbors -(receive channels)->
|
||||
# * target (this_node)]
|
||||
# Look for a path in parantheses.
|
||||
|
||||
if len(send_channels) == 1:
|
||||
# There is only a single send channel.
|
||||
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 don't want to go backwards via the send_channel and other
|
||||
# parallel channels between source and target.
|
||||
blocked_channels = self.node.network.graph[source][target]
|
||||
for channel in blocked_channels.values():
|
||||
self.node.network.channel_rater.blacklist_add_channel(
|
||||
channel['channel_id'], source, target,
|
||||
)
|
||||
|
||||
# We exclude all other channels other than receive channels from
|
||||
# receiving.
|
||||
|
||||
# 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)
|
||||
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)
|
||||
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
|
||||
# Case 2: 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:
|
||||
# We have only a single receive channel.
|
||||
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 block the receiving channel and parallel ones from
|
||||
# sending.
|
||||
blocked_channels = self.node.network.graph[source][target]
|
||||
for channel in blocked_channels.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
|
||||
# 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)
|
||||
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)
|
||||
self.node.network.channel_rater.blacklist_add_channel(
|
||||
channel_id, source, sender_neighbor,
|
||||
)
|
||||
else:
|
||||
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
|
||||
route_channel_hops = \
|
||||
self.get_route_channel_hops_from_to_node_internal(
|
||||
source, target, amt_msat)
|
||||
# Up to this point, we have determined source and target channels.
|
||||
|
||||
final_channel_hops = []
|
||||
# Compute hops from source to target.
|
||||
hop_pubkeys = self.find_node_route(
|
||||
source, target, amt_msat,
|
||||
)
|
||||
|
||||
# Construct the final list of nodes.
|
||||
final_hop_pubkeys = []
|
||||
outgoing_channel = None
|
||||
|
||||
# Single-send channel, multiple receive channels.
|
||||
if len(send_channels) == 1:
|
||||
final_channel_hops.append(send_channel['chan_id'])
|
||||
final_channel_hops.extend(route_channel_hops)
|
||||
final_hop_pubkeys.extend(hop_pubkeys)
|
||||
outgoing_channel = send_channel['chan_id']
|
||||
|
||||
# Multiple send channels, single receive channel.
|
||||
else:
|
||||
final_channel_hops.extend(route_channel_hops)
|
||||
final_channel_hops.append(receive_channel['chan_id'])
|
||||
# We need to extend the path with the pubkey of this node.
|
||||
final_hop_pubkeys.extend(hop_pubkeys[1:])
|
||||
final_hop_pubkeys.append(this_node)
|
||||
|
||||
# TODO: add some consistency checks, route shouldn't contain self-loops
|
||||
logger.debug("Channel hops:")
|
||||
logger.debug(final_channel_hops)
|
||||
# For the outgoing channel, select a channel from the send channels
|
||||
# with the nearest neighbor (second pubkey).
|
||||
send_candidates = [c for c, v in send_channels.items() if
|
||||
v['remote_pubkey'] == final_hop_pubkeys[0]]
|
||||
|
||||
# initialize Route objects with appropriate fees and expiries
|
||||
route = Route(self.node, final_channel_hops, this_node, amt_msat)
|
||||
# TODO: select a better send channel if multiple are available.
|
||||
outgoing_channel = random.choice(send_candidates)
|
||||
|
||||
logger.debug("Node hops:")
|
||||
logger.debug(final_hop_pubkeys)
|
||||
|
||||
logger.info(f"Construct route for {len(final_hop_pubkeys)} hops.")
|
||||
|
||||
# Build a route via an RPC call to LND.
|
||||
try:
|
||||
route = self.node.build_route(
|
||||
amt_msat, outgoing_channel, final_hop_pubkeys, payment_addr,
|
||||
)
|
||||
except grpc.RpcError as rpc_error:
|
||||
if rpc_error.code() == grpc.StatusCode.UNKNOWN:
|
||||
if "for node 0" in rpc_error.details():
|
||||
raise NoRoute(f"our node can't send: {rpc_error.details()}")
|
||||
raise NoRoute(
|
||||
f"could not build a route {final_hop_pubkeys}"
|
||||
f"outgoing {outgoing_channel}: {rpc_error.details()}"
|
||||
)
|
||||
else:
|
||||
raise rpc_error
|
||||
|
||||
self.check_route(route)
|
||||
|
||||
return route
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
"""
|
||||
Implements a lightning network topology:
|
||||
"""Implements a lightning network topology:
|
||||
|
||||
|
||||
A-1-->--------------B 1: 1_000_000 10:0
|
||||
2 . 3 4 2: 1_000_000 5:5
|
||||
| < > | 3: 10_000_000 5:5
|
||||
^ . . ^ 4: 10_000_000 5:5
|
||||
v . . v 5: 1_000_000 5:5
|
||||
| < . | 6: 1_000_000 10:0
|
||||
| . 6 |
|
||||
C-5--<--------->----D
|
||||
|
||||
"""
|
||||
nodes = {
|
||||
'A': {
|
||||
|
|
@ -11,13 +21,13 @@ nodes = {
|
|||
'channels': {
|
||||
1: {
|
||||
'to': 'B',
|
||||
'capacity': 1000000,
|
||||
'capacity': 1_000_000 ,
|
||||
'ratio_local': 10,
|
||||
'ratio_remote': 0,
|
||||
},
|
||||
2: {
|
||||
'to': 'C',
|
||||
'capacity': 1000000,
|
||||
'capacity': 1_000_000,
|
||||
'ratio_local': 5,
|
||||
'ratio_remote': 5,
|
||||
},
|
||||
|
|
@ -32,13 +42,13 @@ nodes = {
|
|||
'channels': {
|
||||
3: {
|
||||
'to': 'C',
|
||||
'capacity': 10000000,
|
||||
'capacity': 10_000_000,
|
||||
'ratio_local': 5,
|
||||
'ratio_remote': 5,
|
||||
},
|
||||
4: {
|
||||
'to': 'D',
|
||||
'capacity': 10000000,
|
||||
'capacity': 10_000_000,
|
||||
'ratio_local': 5,
|
||||
'ratio_remote': 5,
|
||||
},
|
||||
|
|
@ -53,7 +63,7 @@ nodes = {
|
|||
'channels': {
|
||||
5: {
|
||||
'to': 'D',
|
||||
'capacity': 1000000,
|
||||
'capacity': 1_000_000,
|
||||
'ratio_local': 5,
|
||||
'ratio_remote': 5,
|
||||
},
|
||||
|
|
@ -68,7 +78,7 @@ nodes = {
|
|||
'channels': {
|
||||
6: {
|
||||
'to': 'A',
|
||||
'capacity': 1000000,
|
||||
'capacity': 1_000_000,
|
||||
'ratio_local': 10,
|
||||
'ratio_remote': 0,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
"""
|
||||
Implements a complete graph, where the master node A can be thought of
|
||||
"""Implements a complete graph, where the master node A can be thought of
|
||||
being surrounded by four nodes, which share an illiquid network of channels.
|
||||
The master node has unbalanced total inbound to outbound ratio.
|
||||
A 1: 1_000_000 10:0
|
||||
4. ^ .1 2: 1_000_000 3:7
|
||||
. 3 / r 2 . 3: 1_000_000 3:7
|
||||
. / r . 4: 1_000_000 4:6
|
||||
E-----------------------7----B 5: 500_000 1:9
|
||||
. x / r o6 .5 6: 500_000 5:5
|
||||
. x o . 7: 500_000 9:1
|
||||
. / x o r . 8: 500_000 9:1
|
||||
10. o x9 . 9: 500_000 1:9
|
||||
D---------8-C 10: 500_000 5:5
|
||||
"""
|
||||
nodes = {
|
||||
'A': {
|
||||
|
|
@ -13,25 +22,25 @@ nodes = {
|
|||
'channels': {
|
||||
1: {
|
||||
'to': 'B',
|
||||
'capacity': 1000000,
|
||||
'capacity': 1_000_000,
|
||||
'ratio_local': 10,
|
||||
'ratio_remote': 0,
|
||||
},
|
||||
2: {
|
||||
'to': 'C',
|
||||
'capacity': 1000000,
|
||||
'capacity': 1_000_000,
|
||||
'ratio_local': 3,
|
||||
'ratio_remote': 7,
|
||||
},
|
||||
3: {
|
||||
'to': 'D',
|
||||
'capacity': 1000000,
|
||||
'capacity': 1_000_000,
|
||||
'ratio_local': 3,
|
||||
'ratio_remote': 7,
|
||||
},
|
||||
4: {
|
||||
'to': 'E',
|
||||
'capacity': 1000000,
|
||||
'capacity': 1_000_000,
|
||||
'ratio_local': 4,
|
||||
'ratio_remote': 6,
|
||||
},
|
||||
|
|
@ -46,19 +55,19 @@ nodes = {
|
|||
'channels': {
|
||||
5: {
|
||||
'to': 'C',
|
||||
'capacity': 500000,
|
||||
'capacity': 500_000,
|
||||
'ratio_local': 1,
|
||||
'ratio_remote': 9,
|
||||
},
|
||||
6: {
|
||||
'to': 'D',
|
||||
'capacity': 500000,
|
||||
'capacity': 500_000,
|
||||
'ratio_local': 5,
|
||||
'ratio_remote': 5,
|
||||
},
|
||||
7: {
|
||||
'to': 'E',
|
||||
'capacity': 500000,
|
||||
'capacity': 500_000,
|
||||
'ratio_local': 9,
|
||||
'ratio_remote': 1,
|
||||
},
|
||||
|
|
@ -73,13 +82,13 @@ nodes = {
|
|||
'channels': {
|
||||
8: {
|
||||
'to': 'D',
|
||||
'capacity': 500000,
|
||||
'capacity': 500_000,
|
||||
'ratio_local': 9,
|
||||
'ratio_remote': 1,
|
||||
},
|
||||
9: {
|
||||
'to': 'E',
|
||||
'capacity': 500000,
|
||||
'capacity': 500_000,
|
||||
'ratio_local': 1,
|
||||
'ratio_remote': 9,
|
||||
},
|
||||
|
|
@ -94,7 +103,7 @@ nodes = {
|
|||
'channels': {
|
||||
10: {
|
||||
'to': 'E',
|
||||
'capacity': 500000,
|
||||
'capacity': 500_000,
|
||||
'ratio_local': 5,
|
||||
'ratio_remote': 5,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ from lndmanage.lib.exceptions import (
|
|||
DryRun,
|
||||
RebalancingTrialsExhausted,
|
||||
NoRoute,
|
||||
OurNodeFailure,
|
||||
)
|
||||
|
||||
from lndmanage import settings # needed for side effect configuration
|
||||
|
|
@ -39,7 +38,7 @@ class CircleTest(TestNetwork):
|
|||
max_effective_fee_rate=50,
|
||||
dry=False
|
||||
):
|
||||
"""Helper function for testing a circular payment.
|
||||
"""Helper function to test a circular payment.
|
||||
|
||||
:param channel_numbers_send: channels whose local balance is decreased
|
||||
:param channel_numbers_receive: channels whose local balance is increased
|
||||
|
|
@ -59,44 +58,47 @@ class CircleTest(TestNetwork):
|
|||
)
|
||||
|
||||
graph_before = self.testnet.assemble_graph()
|
||||
send_channels = {}
|
||||
|
||||
self.rebalancer.channels = self.lndnode.get_unbalanced_channels()
|
||||
|
||||
send_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
|
||||
|
||||
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
|
||||
# Let LND update its balances.
|
||||
time.sleep(SLEEP_SEC_AFTER_REBALANCING)
|
||||
|
||||
graph_after = self.testnet.assemble_graph()
|
||||
|
||||
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
|
||||
# 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'])
|
||||
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'])
|
||||
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)
|
||||
|
|
@ -117,13 +119,14 @@ class TestCircleLiquid(CircleTest):
|
|||
# assert some basic properties of the graph
|
||||
self.assertEqual(6, len(self.master_node_graph_view))
|
||||
|
||||
def test_circle_success_1_2(self):
|
||||
"""
|
||||
Test successful rebalance from channel 1 to channel 2.
|
||||
def test_success_1_2(self):
|
||||
"""Test successful rebalance from channel 1 to channel 2.
|
||||
|
||||
Successful route: A->B->C->A.
|
||||
"""
|
||||
channel_numbers_from = [1]
|
||||
channel_numbers_to = [2]
|
||||
amount_sat = 10000
|
||||
amount_sat = 10_000
|
||||
expected_fees_msat = 43
|
||||
|
||||
asyncio.run(self.circular_rebalance_and_check(
|
||||
|
|
@ -133,9 +136,29 @@ class TestCircleLiquid(CircleTest):
|
|||
expected_fees_msat
|
||||
))
|
||||
|
||||
def test_circle_success_1_6(self):
|
||||
def test_fail_1_2(self):
|
||||
"""Test failing rebalance from channel 1 to channel 2.
|
||||
|
||||
Route A->B->C->A fails because of missing liquidity from C->A.
|
||||
"""
|
||||
Test successful rebalance from channel 1 to channel 6.
|
||||
channel_numbers_from = [1]
|
||||
channel_numbers_to = [2]
|
||||
amount_sat = 600_000
|
||||
|
||||
self.assertRaises(
|
||||
NoRoute,
|
||||
lambda: asyncio.run(self.circular_rebalance_and_check(
|
||||
channel_numbers_from,
|
||||
channel_numbers_to,
|
||||
amount_sat,
|
||||
None,
|
||||
),
|
||||
))
|
||||
|
||||
def test_success_1_6(self):
|
||||
"""Test successful rebalance from channel 1 to channel 6.
|
||||
|
||||
Successful route: A->B->D->A.
|
||||
"""
|
||||
channel_numbers_from = [1]
|
||||
channel_numbers_to = [6]
|
||||
|
|
@ -149,9 +172,8 @@ class TestCircleLiquid(CircleTest):
|
|||
expected_fees_msat
|
||||
))
|
||||
|
||||
def test_circle_6_1_fail_rebalance_failure_no_funds(self):
|
||||
"""
|
||||
Test expected failure for channel 6 to channel 1, where channel 6
|
||||
def test_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_numbers_from = [6]
|
||||
|
|
@ -160,7 +182,7 @@ class TestCircleLiquid(CircleTest):
|
|||
expected_fees_msat = 33
|
||||
|
||||
self.assertRaises(
|
||||
OurNodeFailure,
|
||||
NoRoute,
|
||||
asyncio.run,
|
||||
self.circular_rebalance_and_check(
|
||||
channel_numbers_from,
|
||||
|
|
@ -170,9 +192,8 @@ class TestCircleLiquid(CircleTest):
|
|||
)
|
||||
)
|
||||
|
||||
def test_circle_1_6_fail_budget_too_expensive(self):
|
||||
"""
|
||||
Test expected failure where rebalance uses more than the fee budget.
|
||||
def test_1_6_fail_budget_too_expensive(self):
|
||||
"""Test expected failure where rebalance uses more than the fee budget.
|
||||
"""
|
||||
channel_numbers_from = [1]
|
||||
channel_numbers_to = [6]
|
||||
|
|
@ -192,9 +213,8 @@ class TestCircleLiquid(CircleTest):
|
|||
)
|
||||
)
|
||||
|
||||
def test_circle_1_6_fail_max_fee_rate_too_expensive(self):
|
||||
"""
|
||||
Test expected failure where rebalance is more expensive than
|
||||
def test_1_6_fail_max_fee_rate_too_expensive(self):
|
||||
"""Test expected failure where rebalance is more expensive than
|
||||
the desired maximal fee rate.
|
||||
"""
|
||||
channel_numbers_from = [1]
|
||||
|
|
@ -217,13 +237,12 @@ class TestCircleLiquid(CircleTest):
|
|||
)
|
||||
)
|
||||
|
||||
def test_circle_1_6_success_channel_reserve(self):
|
||||
"""
|
||||
Test for a maximal amount circular payment.
|
||||
def test_1_6_success_channel_reserve(self):
|
||||
"""Test for a maximal amount circular payment.
|
||||
"""
|
||||
channel_numbers_from = [1]
|
||||
channel_numbers_to = [6]
|
||||
local_balance = 1000000
|
||||
local_balance = 1_000_000
|
||||
# take into account 1% channel reserve
|
||||
amount_sat = int(local_balance - 0.01 * local_balance)
|
||||
# need to subtract commitment fee (local + anchor output)
|
||||
|
|
@ -247,9 +266,8 @@ class TestCircleLiquid(CircleTest):
|
|||
)
|
||||
)
|
||||
|
||||
def test_circle_1_6_fail_rebalance_dry(self):
|
||||
"""
|
||||
Test if dry run exception is raised.
|
||||
def test_1_6_fail_rebalance_dry(self):
|
||||
"""Test if dry run exception is raised.
|
||||
"""
|
||||
channel_numbers_from = [1]
|
||||
channel_numbers_to = [6]
|
||||
|
|
@ -268,24 +286,48 @@ class TestCircleLiquid(CircleTest):
|
|||
)
|
||||
)
|
||||
|
||||
@unittest.skip
|
||||
def test_multi_send(self):
|
||||
pass
|
||||
"""Tests sending over multiple rebalance candidates."""
|
||||
channel_numbers_from = [1, 2]
|
||||
channel_numbers_to = [6]
|
||||
amount_sat = 10000
|
||||
expected_fees_msat = 33
|
||||
|
||||
asyncio.run(
|
||||
self.circular_rebalance_and_check(
|
||||
channel_numbers_from,
|
||||
channel_numbers_to,
|
||||
amount_sat,
|
||||
expected_fees_msat,
|
||||
)
|
||||
)
|
||||
|
||||
@unittest.skip
|
||||
def test_multi_receive(self):
|
||||
pass
|
||||
"""Tests receiving via multiple rebalance candidates."""
|
||||
channel_numbers_from = [1]
|
||||
channel_numbers_to = [2, 6]
|
||||
amount_sat = 10000
|
||||
expected_fees_msat = 33
|
||||
|
||||
asyncio.run(
|
||||
self.circular_rebalance_and_check(
|
||||
channel_numbers_from,
|
||||
channel_numbers_to,
|
||||
amount_sat,
|
||||
expected_fees_msat,
|
||||
)
|
||||
)
|
||||
|
||||
class TestCircleIlliquid(CircleTest):
|
||||
|
||||
network_definition = test_graphs_paths['star_ring_4_illiquid']
|
||||
|
||||
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. We can't go beyond C."""
|
||||
def test_fail_2_3_no_route(self):
|
||||
"""Test that NoRoute is raised. We can't go beyond C, none of its
|
||||
channels have enough capacity.
|
||||
"""
|
||||
channel_numbers_from = [2] # A -> C
|
||||
channel_numbers_to = [3] # D -> A
|
||||
amount_sat = 500_000
|
||||
|
|
@ -302,42 +344,60 @@ class TestCircleIlliquid(CircleTest):
|
|||
)
|
||||
)
|
||||
|
||||
def test_circle_1_2_fail_max_trials_exhausted(self):
|
||||
def test_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.
|
||||
There will be a single rebalancing attempt A->B->C->A, which fails for
|
||||
B->C. After this trial we stop due to max rebalance trials.
|
||||
"""
|
||||
channel_numbers_from = [1]
|
||||
channel_numbers_to = [2]
|
||||
amount_sat = 190950
|
||||
expected_fees_msat = None
|
||||
settings.REBALANCING_TRIALS = 1
|
||||
|
||||
self.assertRaises(
|
||||
RebalancingTrialsExhausted,
|
||||
asyncio.run,
|
||||
self.circular_rebalance_and_check(
|
||||
channel_numbers_from,
|
||||
channel_numbers_to,
|
||||
amount_sat,
|
||||
expected_fees_msat,
|
||||
try:
|
||||
previous = settings.REBALANCING_TRIALS
|
||||
settings.REBALANCING_TRIALS = 1
|
||||
|
||||
self.assertRaises(
|
||||
RebalancingTrialsExhausted,
|
||||
asyncio.run,
|
||||
self.circular_rebalance_and_check(
|
||||
channel_numbers_from,
|
||||
channel_numbers_to,
|
||||
amount_sat,
|
||||
expected_fees_msat,
|
||||
)
|
||||
)
|
||||
)
|
||||
finally:
|
||||
settings.REBALANCING_TRIALS = previous
|
||||
|
||||
def test_circle_1_2_fail_no_route_multi_trials(self):
|
||||
"""Test if NoRoute is raised."""
|
||||
def test_1_2_fail_no_route_multi_trials(self):
|
||||
"""Test if RebalancingTrialsExhausted is raised.
|
||||
|
||||
None of those paths support the payment:
|
||||
A -> B -> C -> A
|
||||
A -> B -> E -> C -> A
|
||||
A -> B -> D -> C -> A
|
||||
"""
|
||||
channel_numbers_from = [1]
|
||||
channel_numbers_to = [2]
|
||||
amount_sat = 450000
|
||||
expected_fees_msat = None
|
||||
|
||||
self.assertRaises(
|
||||
RebalancingTrialsExhausted,
|
||||
asyncio.run,
|
||||
self.circular_rebalance_and_check(
|
||||
channel_numbers_from,
|
||||
channel_numbers_to,
|
||||
amount_sat,
|
||||
expected_fees_msat,
|
||||
try:
|
||||
previous = settings.REBALANCING_TRIALS
|
||||
settings.REBALANCING_TRIALS = 3
|
||||
|
||||
asyncio.run(
|
||||
self.circular_rebalance_and_check(
|
||||
channel_numbers_from,
|
||||
channel_numbers_to,
|
||||
amount_sat,
|
||||
expected_fees_msat,
|
||||
)
|
||||
)
|
||||
)
|
||||
except RebalancingTrialsExhausted as e:
|
||||
self.assertEqual(4, e.trials)
|
||||
finally:
|
||||
settings.REBALANCING_TRIALS = previous
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from dataclasses import dataclass
|
|||
from unittest import TestCase, mock
|
||||
|
||||
from lndmanage.lib.data_types import NodePair
|
||||
from lndmanage.lib.liquidityhints import LiquidityHintMgr, AmountHistory
|
||||
from lndmanage.lib.liquidityhints import LiquidityHintMgr, AmountHistory, LiquidityHint
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -77,3 +77,27 @@ class LiquidityTest(TestCase):
|
|||
node_pair = NodePair(("bb", "cc"))
|
||||
hint = mgr._liquidity_hints.get(node_pair)
|
||||
self.assertIsNone(hint)
|
||||
|
||||
class TestLiquidityHint(TestCase):
|
||||
def test_liquidity_hint(self):
|
||||
"""Test updating an expired hint."""
|
||||
|
||||
hint = LiquidityHint()
|
||||
amount = 100000000
|
||||
direction = False
|
||||
self.assertFalse(direction)
|
||||
|
||||
hint = LiquidityHint()
|
||||
hint._can_send_backward = AmountHistory(None, None)
|
||||
hint._can_send_forward = AmountHistory(100066910, 0)
|
||||
hint._cannot_send_backward = AmountHistory(25003625, 0)
|
||||
hint._cannot_send_forward = AmountHistory(100066911, 0)
|
||||
|
||||
self.assertIsNone(hint.can_send(direction).amount_msat)
|
||||
self.assertIsNone(hint.cannot_send(direction).amount_msat)
|
||||
|
||||
hint.update_cannot_send(direction, amount, None)
|
||||
|
||||
self.assertIsNone(hint.can_send(direction).amount_msat)
|
||||
self.assertEqual(amount, hint.cannot_send(direction).amount_msat)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,24 @@
|
|||
import unittest
|
||||
|
||||
from test import testing_common
|
||||
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))
|
||||
node_hops = ['B', 'C', 'D', 'E', 'F', 'G'] # A is the very first node.
|
||||
|
||||
def get_max_index(values):
|
||||
return max(range(len(values)), key=values.__getitem__)
|
||||
|
||||
# Failed hop is from A->B: punish B the most
|
||||
failed_hop = 0
|
||||
badnesses = [node_badness(ni, failed_hop) for
|
||||
ni in range(len(node_hops))]
|
||||
self.assertEqual(0, get_max_index(badnesses))
|
||||
|
||||
# Failed hop is from A->B: punish B and C the most
|
||||
failed_hop = 1
|
||||
badnesses = [node_badness(ni, failed_hop) for
|
||||
ni in range(len(node_hops))]
|
||||
self.assertEqual(0, get_max_index(badnesses))
|
||||
self.assertEqual(badnesses[0], badnesses[1])
|
||||
|
|
|
|||
|
|
@ -15,9 +15,7 @@ from lndmanage.lib.exceptions import NoRebalanceCandidates
|
|||
|
||||
|
||||
class RebalanceTest(TestNetwork):
|
||||
"""
|
||||
Implements an abstract testing class for channel rebalancing.
|
||||
"""
|
||||
"""Implements an abstract testing class for channel rebalancing."""
|
||||
async def rebalance_and_check(
|
||||
self,
|
||||
test_channel_number: int,
|
||||
|
|
@ -26,16 +24,15 @@ class RebalanceTest(TestNetwork):
|
|||
allow_uneconomic: bool,
|
||||
places: int = 5,
|
||||
):
|
||||
"""
|
||||
Test function for rebalancing to a specific target unbalancedness and
|
||||
"""Test function for rebalancing to a specific target unbalancedness and
|
||||
asserts afterwards that the target was reached.
|
||||
|
||||
:param test_channel_number: channel id
|
||||
:param target: unbalancedness target
|
||||
: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
|
||||
:param places: accuracy of the comparison between expected and tested
|
||||
values
|
||||
"""
|
||||
async with self.lndnode:
|
||||
graph_before = self.testnet.assemble_graph()
|
||||
|
|
@ -65,7 +62,8 @@ class RebalanceTest(TestNetwork):
|
|||
|
||||
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']
|
||||
amount_sent = (channel_data_before['local_balance'] -
|
||||
channel_data_after['local_balance'])
|
||||
|
||||
channel_unbalancedness, _ = local_balance_to_unbalancedness(
|
||||
channel_data_after['local_balance'],
|
||||
|
|
@ -85,8 +83,7 @@ class RebalanceTest(TestNetwork):
|
|||
return fees_msat
|
||||
|
||||
def graph_test(self):
|
||||
"""
|
||||
graph_test should be implemented by each subclass test and check,
|
||||
"""graph_test should be implemented by each subclass test and check,
|
||||
whether the test graph has the correct shape.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
|
@ -267,7 +264,7 @@ class TestIlliquidRebalance(RebalanceTest):
|
|||
|
||||
def test_channel_1_splitting(self):
|
||||
"""Tests multiple payment attempts with splitting."""
|
||||
test_channel_number = 1
|
||||
test_channel_number = 1 #
|
||||
fees_msat = asyncio.run(
|
||||
self.rebalance_and_check(
|
||||
test_channel_number,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ from lndmanage.lib.node import LndNode
|
|||
|
||||
import logging.config
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
from lndmanage import settings
|
||||
settings.CACHING_RETENTION_MINUTES = 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue