You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

304 lines
13 KiB

import os
import bitstring
import random
from typing import Mapping, DefaultDict, Tuple, Optional, Dict, List
from .logging import get_logger, Logger
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
_logger = get_logger(__name__)
# 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]:
nodes = {}
if constants.net.NET_NAME == "mainnet":
nodes.update(TRAMPOLINE_NODES_MAINNET)
elif constants.net.NET_NAME == "testnet":
nodes.update(TRAMPOLINE_NODES_TESTNET)
elif constants.net.NET_NAME == "signet":
nodes.update(TRAMPOLINE_NODES_SIGNET)
nodes.update(_TRAMPOLINE_NODES_UNITTESTS)
return nodes
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, Optional[bytes]]:
"""Returns if we deal with a legacy payment and gives back the possible last
trampoline pubkey.
"""
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, None
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]
forwarder_pubkey = random.choice(singlehop_r_tags)[0][0]
if is_hardcoded_trampoline(forwarder_pubkey):
return False, forwarder_pubkey
# if trampoline receiving is not supported or the forwarder is not known as a trampoline,
# we send a legacy payment
return True, None
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 create_trampoline_route(
*,
amount_msat: int,
min_cltv_expiry: int,
invoice_pubkey: bytes,
invoice_features: int,
my_pubkey: bytes,
trampoline_node_id: bytes, # the first trampoline in the path; which we are directly connected to
r_tags,
trampoline_fee_level: int,
use_two_trampolines: bool
) -> LNPaymentRoute:
# we decide whether to convert to a legacy payment
is_legacy, second_trampoline_pubkey = is_legacy_relay(invoice_features, r_tags)
# we build a route of trampoline hops and extend the route list in place
route = []
# our first trampoline hop is decided by the channel we use
extend_trampoline_route(route, my_pubkey, trampoline_node_id, trampoline_fee_level)
if is_legacy:
# we add another different trampoline hop for privacy
if use_two_trampolines:
trampolines = trampolines_by_id()
del trampolines[trampoline_node_id]
second_trampoline_pubkey = random.choice(list(trampolines.keys()))
extend_trampoline_route(route, trampoline_node_id, second_trampoline_pubkey, 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 second_trampoline_pubkey and trampoline_node_id != second_trampoline_pubkey:
extend_trampoline_route(route, trampoline_node_id, second_trampoline_pubkey, 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.")
_logger.info(f'created route with trampoline fee level={trampoline_fee_level}, is legacy: {is_legacy}')
_logger.info(f'trampoline hops: {[hop.end_node.hex() for hop in route]}')
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
}
_logger.info(f'payload {i} {payload}')
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):
# 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,
trampoline_node_id=node_id,
r_tags=r_tags,
trampoline_fee_level=trampoline_fee_level,
use_two_trampolines=use_two_trampolines)
# 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_onion, amount_with_fees, bucket_cltv_delta