Upon receiving UNKNOWN_NEXT_PEER, TEMPORARY_NODE_FAILURE or TEMPORARY_CHANNEL_FAILURE, remember the trampoline route that was used, and try other routes before raising the trampoline fee. Before this commit, we used to raise the trampoline fee upon receiving any error message, in order to ensure termination of the payment loop. Note that we will still retry failing routes after we have raised the trampoline fee. This choice is questionable, it is unclear if doing so significantly increases the probability of success. Tests: add a test for trampoline handling of UNKNOWN_NEXT_PEER
320 lines
13 KiB
Python
320 lines
13 KiB
Python
import os
|
|
import bitstring
|
|
import random
|
|
|
|
from typing import Mapping, DefaultDict, Tuple, Optional, Dict, List
|
|
|
|
from .lnutil import LnFeatures
|
|
from .lnonion import calc_hops_data_for_payment, new_onion_packet
|
|
from .lnrouter import RouteEdge, TrampolineEdge, LNPaymentRoute, is_route_sane_to_use
|
|
from .lnutil import NoPathFound, LNPeerAddr
|
|
from . import constants
|
|
|
|
# trampoline nodes are supposed to advertise their fee and cltv in node_update message
|
|
TRAMPOLINE_FEES = [
|
|
{
|
|
'fee_base_msat': 0,
|
|
'fee_proportional_millionths': 0,
|
|
'cltv_expiry_delta': 576,
|
|
},
|
|
{
|
|
'fee_base_msat': 1000,
|
|
'fee_proportional_millionths': 100,
|
|
'cltv_expiry_delta': 576,
|
|
},
|
|
{
|
|
'fee_base_msat': 3000,
|
|
'fee_proportional_millionths': 100,
|
|
'cltv_expiry_delta': 576,
|
|
},
|
|
{
|
|
'fee_base_msat': 5000,
|
|
'fee_proportional_millionths': 500,
|
|
'cltv_expiry_delta': 576,
|
|
},
|
|
{
|
|
'fee_base_msat': 7000,
|
|
'fee_proportional_millionths': 1000,
|
|
'cltv_expiry_delta': 576,
|
|
},
|
|
{
|
|
'fee_base_msat': 12000,
|
|
'fee_proportional_millionths': 3000,
|
|
'cltv_expiry_delta': 576,
|
|
},
|
|
{
|
|
'fee_base_msat': 100000,
|
|
'fee_proportional_millionths': 3000,
|
|
'cltv_expiry_delta': 576,
|
|
},
|
|
]
|
|
|
|
# hardcoded list
|
|
# TODO for some pubkeys, there are multiple network addresses we could try
|
|
TRAMPOLINE_NODES_MAINNET = {
|
|
'ACINQ': LNPeerAddr(host='node.acinq.co', port=9735, pubkey=bytes.fromhex('03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f')),
|
|
'Electrum trampoline': LNPeerAddr(host='lightning.electrum.org', port=9740, pubkey=bytes.fromhex('03ecef675be448b615e6176424070673ef8284e0fd19d8be062a6cb5b130a0a0d1')),
|
|
'trampoline hodlisterco': LNPeerAddr(host='trampoline.hodlister.co', port=9740, pubkey=bytes.fromhex('02ce014625788a61411398f83c945375663972716029ef9d8916719141dc109a1c')),
|
|
}
|
|
|
|
TRAMPOLINE_NODES_TESTNET = {
|
|
'endurance': LNPeerAddr(host='34.250.234.192', port=9735, pubkey=bytes.fromhex('03933884aaf1d6b108397e5efe5c86bcf2d8ca8d2f700eda99db9214fc2712b134')),
|
|
'Electrum trampoline': LNPeerAddr(host='lightning.electrum.org', port=9739, pubkey=bytes.fromhex('02bf82e22f99dcd7ac1de4aad5152ce48f0694c46ec582567f379e0adbf81e2d0f')),
|
|
}
|
|
|
|
TRAMPOLINE_NODES_SIGNET = {
|
|
'lnd wakiyamap.dev': LNPeerAddr(host='signet-electrumx.wakiyamap.dev', port=9735, pubkey=bytes.fromhex('02dadf6c28f3284d591cd2a4189d1530c1ff82c07059ebea150a33ab76e7364b4a')),
|
|
'eclair wakiyamap.dev': LNPeerAddr(host='signet-eclair.wakiyamap.dev', port=9735, pubkey=bytes.fromhex('0271cf3881e6eadad960f47125434342e57e65b98a78afa99f9b4191c02dd7ab3b')),
|
|
}
|
|
|
|
_TRAMPOLINE_NODES_UNITTESTS = {} # used in unit tests
|
|
|
|
def hardcoded_trampoline_nodes() -> Mapping[str, LNPeerAddr]:
|
|
if _TRAMPOLINE_NODES_UNITTESTS:
|
|
return _TRAMPOLINE_NODES_UNITTESTS
|
|
elif constants.net.NET_NAME == "mainnet":
|
|
return TRAMPOLINE_NODES_MAINNET
|
|
elif constants.net.NET_NAME == "testnet":
|
|
return TRAMPOLINE_NODES_TESTNET
|
|
elif constants.net.NET_NAME == "signet":
|
|
return TRAMPOLINE_NODES_SIGNET
|
|
else:
|
|
return {}
|
|
|
|
def trampolines_by_id():
|
|
return dict([(x.pubkey, x) for x in hardcoded_trampoline_nodes().values()])
|
|
|
|
def is_hardcoded_trampoline(node_id: bytes) -> bool:
|
|
return node_id in trampolines_by_id()
|
|
|
|
def encode_routing_info(r_tags):
|
|
result = bitstring.BitArray()
|
|
for route in r_tags:
|
|
result.append(bitstring.pack('uint:8', len(route)))
|
|
for step in route:
|
|
pubkey, channel, feebase, feerate, cltv = step
|
|
result.append(bitstring.BitArray(pubkey) + bitstring.BitArray(channel) + bitstring.pack('intbe:32', feebase) + bitstring.pack('intbe:32', feerate) + bitstring.pack('intbe:16', cltv))
|
|
return result.tobytes()
|
|
|
|
|
|
def is_legacy_relay(invoice_features, r_tags) -> Tuple[bool, List[bytes]]:
|
|
"""Returns if we deal with a legacy payment and the list of trampoline pubkeys in the invoice.
|
|
"""
|
|
invoice_features = LnFeatures(invoice_features)
|
|
# trampoline-supporting wallets:
|
|
# OPTION_TRAMPOLINE_ROUTING_OPT_COMPAT_ECLAIR: old Phoenix/Eclair wallets
|
|
# OPTION_TRAMPOLINE_ROUTING_OPT_COMPAT_ELECTRUM: old Electrum wallets
|
|
if (invoice_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT)
|
|
or invoice_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_COMPAT_ECLAIR)
|
|
or invoice_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_COMPAT_ELECTRUM)):
|
|
# If there are no r_tags (routing hints) included, the wallet doesn't have
|
|
# private channels and is probably directly connected to a trampoline node.
|
|
# Any trampoline node should be able to figure out a path to the receiver and
|
|
# we can use an e2e payment.
|
|
if not r_tags:
|
|
return False, []
|
|
else:
|
|
# - We choose one routing hint at random, and
|
|
# use end-to-end trampoline if that node is a trampoline-forwarder (TF).
|
|
# - In case of e2e, the route will have either one or two TFs (one neighbour of sender,
|
|
# and one neighbour of recipient; and these might coincide). Note that there are some
|
|
# channel layouts where two TFs are needed for a payment to succeed, e.g. both
|
|
# endpoints connected to T1 and T2, and sender only has send-capacity with T1, while
|
|
# recipient only has recv-capacity with T2.
|
|
singlehop_r_tags = [x for x in r_tags if len(x) == 1]
|
|
invoice_trampolines = [x[0][0] for x in singlehop_r_tags if is_hardcoded_trampoline(x[0][0])]
|
|
return False, invoice_trampolines
|
|
# if trampoline receiving is not supported or the forwarder is not known as a trampoline,
|
|
# we send a legacy payment
|
|
return True, []
|
|
|
|
|
|
def trampoline_policy(
|
|
trampoline_fee_level: int,
|
|
) -> Dict:
|
|
"""Return the fee policy for all trampoline nodes.
|
|
|
|
Raises NoPathFound if the fee level is exhausted."""
|
|
# TODO: ideally we want to use individual fee levels for each trampoline node,
|
|
# but because at the moment we can't attribute insufficient fee errors to
|
|
# downstream trampolines we need to use a global fee level here
|
|
if trampoline_fee_level < len(TRAMPOLINE_FEES):
|
|
return TRAMPOLINE_FEES[trampoline_fee_level]
|
|
else:
|
|
raise NoPathFound()
|
|
|
|
|
|
def extend_trampoline_route(
|
|
route: List,
|
|
start_node: bytes,
|
|
end_node: bytes,
|
|
trampoline_fee_level: int,
|
|
pay_fees=True
|
|
):
|
|
"""Extends the route and modifies it in place."""
|
|
trampoline_features = LnFeatures.VAR_ONION_OPT
|
|
policy = trampoline_policy(trampoline_fee_level)
|
|
route.append(
|
|
TrampolineEdge(
|
|
start_node=start_node,
|
|
end_node=end_node,
|
|
fee_base_msat=policy['fee_base_msat'] if pay_fees else 0,
|
|
fee_proportional_millionths=policy['fee_proportional_millionths'] if pay_fees else 0,
|
|
cltv_expiry_delta=policy['cltv_expiry_delta'] if pay_fees else 0,
|
|
node_features=trampoline_features))
|
|
|
|
|
|
def choose_second_trampoline(my_trampoline, trampolines, failed_routes):
|
|
if my_trampoline in trampolines:
|
|
trampolines.remove(my_trampoline)
|
|
for r in failed_routes:
|
|
if len(r) > 2:
|
|
t2 = bytes.fromhex(r[1])
|
|
if t2 in trampolines:
|
|
trampolines.remove(t2)
|
|
if not trampolines:
|
|
raise NoPathFound('all routes have failed')
|
|
return random.choice(trampolines)
|
|
|
|
def create_trampoline_route(
|
|
*,
|
|
amount_msat: int,
|
|
min_cltv_expiry: int,
|
|
invoice_pubkey: bytes,
|
|
invoice_features: int,
|
|
my_pubkey: bytes,
|
|
my_trampoline: bytes, # the first trampoline in the path; which we are directly connected to
|
|
r_tags,
|
|
trampoline_fee_level: int,
|
|
use_two_trampolines: bool,
|
|
failed_routes: list,
|
|
) -> LNPaymentRoute:
|
|
# we decide whether to convert to a legacy payment
|
|
is_legacy, invoice_trampolines = is_legacy_relay(invoice_features, r_tags)
|
|
|
|
# we build a route of trampoline hops and extend the route list in place
|
|
route = []
|
|
second_trampoline = None
|
|
|
|
# our first trampoline hop is decided by the channel we use
|
|
extend_trampoline_route(route, my_pubkey, my_trampoline, trampoline_fee_level)
|
|
|
|
if is_legacy:
|
|
# we add another different trampoline hop for privacy
|
|
if use_two_trampolines:
|
|
trampolines = trampolines_by_id()
|
|
second_trampoline = choose_second_trampoline(my_trampoline, list(trampolines.keys()), failed_routes)
|
|
extend_trampoline_route(route, my_trampoline, second_trampoline, trampoline_fee_level)
|
|
# the last trampoline onion must contain routing hints for the last trampoline
|
|
# node to find the recipient
|
|
invoice_routing_info = encode_routing_info(r_tags)
|
|
route[-1].invoice_routing_info = invoice_routing_info
|
|
route[-1].invoice_features = invoice_features
|
|
route[-1].outgoing_node_id = invoice_pubkey
|
|
else:
|
|
if invoice_trampolines:
|
|
if my_trampoline in invoice_trampolines:
|
|
short_route = [my_trampoline.hex(), invoice_pubkey.hex()]
|
|
if short_route in failed_routes:
|
|
add_trampoline = True
|
|
else:
|
|
add_trampoline = False
|
|
else:
|
|
add_trampoline = True
|
|
if add_trampoline:
|
|
second_trampoline = choose_second_trampoline(my_trampoline, invoice_trampolines, failed_routes)
|
|
extend_trampoline_route(route, my_trampoline, second_trampoline, trampoline_fee_level)
|
|
|
|
# final edge (not part of the route if payment is legacy, but eclair requires an encrypted blob)
|
|
extend_trampoline_route(route, route[-1].end_node, invoice_pubkey, trampoline_fee_level, pay_fees=False)
|
|
# check that we can pay amount and fees
|
|
for edge in route[::-1]:
|
|
amount_msat += edge.fee_for_edge(amount_msat)
|
|
if not is_route_sane_to_use(route, amount_msat, min_cltv_expiry):
|
|
raise NoPathFound("We cannot afford to pay the fees.")
|
|
return route
|
|
|
|
|
|
def create_trampoline_onion(*, route, amount_msat, final_cltv, total_msat, payment_hash, payment_secret):
|
|
# all edges are trampoline
|
|
hops_data, amount_msat, cltv = calc_hops_data_for_payment(
|
|
route,
|
|
amount_msat,
|
|
final_cltv,
|
|
total_msat=total_msat,
|
|
payment_secret=payment_secret)
|
|
# detect trampoline hops.
|
|
payment_path_pubkeys = [x.node_id for x in route]
|
|
num_hops = len(payment_path_pubkeys)
|
|
for i in range(num_hops):
|
|
route_edge = route[i]
|
|
assert route_edge.is_trampoline()
|
|
payload = hops_data[i].payload
|
|
if i < num_hops - 1:
|
|
payload.pop('short_channel_id')
|
|
next_edge = route[i+1]
|
|
assert next_edge.is_trampoline()
|
|
hops_data[i].payload["outgoing_node_id"] = {"outgoing_node_id":next_edge.node_id}
|
|
# only for final
|
|
if i == num_hops - 1:
|
|
payload["payment_data"] = {
|
|
"payment_secret": payment_secret,
|
|
"total_msat": total_msat
|
|
}
|
|
# legacy
|
|
if i == num_hops - 2 and route_edge.invoice_features:
|
|
payload["invoice_features"] = {"invoice_features":route_edge.invoice_features}
|
|
payload["invoice_routing_info"] = {"invoice_routing_info":route_edge.invoice_routing_info}
|
|
payload["payment_data"] = {
|
|
"payment_secret": payment_secret,
|
|
"total_msat": total_msat
|
|
}
|
|
trampoline_session_key = os.urandom(32)
|
|
trampoline_onion = new_onion_packet(payment_path_pubkeys, trampoline_session_key, hops_data, associated_data=payment_hash, trampoline=True)
|
|
return trampoline_onion, amount_msat, cltv
|
|
|
|
|
|
def create_trampoline_route_and_onion(
|
|
*,
|
|
amount_msat,
|
|
total_msat,
|
|
min_cltv_expiry,
|
|
invoice_pubkey,
|
|
invoice_features,
|
|
my_pubkey: bytes,
|
|
node_id,
|
|
r_tags,
|
|
payment_hash,
|
|
payment_secret,
|
|
local_height: int,
|
|
trampoline_fee_level: int,
|
|
use_two_trampolines: bool,
|
|
failed_routes: list):
|
|
# create route for the trampoline_onion
|
|
trampoline_route = create_trampoline_route(
|
|
amount_msat=amount_msat,
|
|
min_cltv_expiry=min_cltv_expiry,
|
|
my_pubkey=my_pubkey,
|
|
invoice_pubkey=invoice_pubkey,
|
|
invoice_features=invoice_features,
|
|
my_trampoline=node_id,
|
|
r_tags=r_tags,
|
|
trampoline_fee_level=trampoline_fee_level,
|
|
use_two_trampolines=use_two_trampolines,
|
|
failed_routes=failed_routes)
|
|
# compute onion and fees
|
|
final_cltv = local_height + min_cltv_expiry
|
|
trampoline_onion, amount_with_fees, bucket_cltv = create_trampoline_onion(
|
|
route=trampoline_route,
|
|
amount_msat=amount_msat,
|
|
final_cltv=final_cltv,
|
|
total_msat=total_msat,
|
|
payment_hash=payment_hash,
|
|
payment_secret=payment_secret)
|
|
bucket_cltv_delta = bucket_cltv - local_height
|
|
bucket_cltv_delta += trampoline_route[0].cltv_expiry_delta
|
|
# trampoline fee for this very trampoline
|
|
trampoline_fee = trampoline_route[0].fee_for_edge(amount_with_fees)
|
|
amount_with_fees += trampoline_fee
|
|
return trampoline_route, trampoline_onion, amount_with_fees, bucket_cltv_delta
|