Merge pull request #139 from bitromortac/2209-rpc-route

replace route construction with rpc route
This commit is contained in:
bitromortac 2023-01-16 21:21:22 +01:00 committed by GitHub
commit 1b2446d5ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 592 additions and 401 deletions

View file

@ -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()

View file

@ -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):

View file

@ -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())

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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,
},

View file

@ -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,
},

View file

@ -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

View file

@ -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)

View file

@ -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])

View file

@ -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,

View file

@ -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