Trampoline routing.
- trampoline is enabled by default in config, to prevent download of `gossip_db`.
(if disabled, `gossip_db` will be downloaded, regardless of the existence of channels)
- if trampoline is enabled:
- the wallet can only open channels with trampoline nodes
- already-existing channels with non-trampoline nodes are frozen for sending.
- there are two types of trampoline payments: legacy and end-to-end (e2e).
- we decide to perform legacy or e2e based on the invoice:
- we use trampoline_routing_opt in features to detect Eclair and Phoenix invoices
- we use trampoline_routing_hints to detect Electrum invoices
- when trying a legacy payment, we add a second trampoline to the path to preserve privacy.
(we fall back to a single trampoline if the payment fails for all trampolines)
- the trampoline list is hardcoded, it will remain so until `trampoline_routing_opt` feature flag is in INIT.
- there are currently only two nodes in the hardcoded list, it would be nice to have more.
- similar to Phoenix, we find the fee/cltv by trial-and-error.
- if there is a second trampoline in the path, we use the same fee for both.
- the final spec should add fee info in error messages, so we will be able to fine-tune fees
This commit is contained in:
@@ -450,7 +450,8 @@ class Daemon(Logger):
|
||||
if self.network:
|
||||
self.network.start(jobs=[self.fx.run])
|
||||
# prepare lightning functionality, also load channel db early
|
||||
self.network.init_channel_db()
|
||||
if self.config.get('use_gossip', False):
|
||||
self.network.start_gossip()
|
||||
|
||||
self.taskgroup = TaskGroup()
|
||||
asyncio.run_coroutine_threadsafe(self._run(jobs=daemon_jobs), self.asyncio_loop)
|
||||
|
||||
@@ -183,6 +183,14 @@ class ElectrumWindow(App, Logger):
|
||||
def on_use_rbf(self, instance, x):
|
||||
self.electrum_config.set_key('use_rbf', self.use_rbf, True)
|
||||
|
||||
use_gossip = BooleanProperty(False)
|
||||
def on_use_gossip(self, instance, x):
|
||||
self.electrum_config.set_key('use_gossip', self.use_gossip, True)
|
||||
if self.use_gossip:
|
||||
self.network.start_gossip()
|
||||
else:
|
||||
self.network.stop_gossip()
|
||||
|
||||
android_backups = BooleanProperty(False)
|
||||
def on_android_backups(self, instance, x):
|
||||
self.electrum_config.set_key('android_backups', self.android_backups, True)
|
||||
@@ -394,6 +402,7 @@ class ElectrumWindow(App, Logger):
|
||||
self.fx = self.daemon.fx
|
||||
self.use_rbf = config.get('use_rbf', True)
|
||||
self.android_backups = config.get('android_backups', False)
|
||||
self.use_gossip = config.get('use_gossip', False)
|
||||
self.use_unconfirmed = not config.get('confirmed_only', False)
|
||||
|
||||
# create triggers so as to minimize updating a max of 2 times a sec
|
||||
|
||||
@@ -234,6 +234,7 @@ Builder.load_string(r'''
|
||||
can_send:''
|
||||
can_receive:''
|
||||
is_open:False
|
||||
warning: ''
|
||||
BoxLayout:
|
||||
padding: '12dp', '12dp', '12dp', '12dp'
|
||||
spacing: '12dp'
|
||||
@@ -246,6 +247,9 @@ Builder.load_string(r'''
|
||||
height: self.minimum_height
|
||||
size_hint_y: None
|
||||
spacing: '5dp'
|
||||
TopLabel:
|
||||
text: root.warning
|
||||
color: .905, .709, .509, 1
|
||||
BoxLabel:
|
||||
text: _('Channel ID')
|
||||
value: root.short_id
|
||||
@@ -470,6 +474,12 @@ class ChannelDetailsPopup(Popup, Logger):
|
||||
closed = chan.get_closing_height()
|
||||
if closed:
|
||||
self.closing_txid, closing_height, closing_timestamp = closed
|
||||
msg = ' '.join([
|
||||
_("Trampoline routing is enabled, but this channel is with a non-trampoline node."),
|
||||
_("This channel may still be used for receiving, but it is frozen for sending."),
|
||||
_("If you want to keep using this channel, you need to disable trampoline routing in your preferences."),
|
||||
])
|
||||
self.warning = '' if self.app.wallet.lnworker.channel_db or chan.is_trampoline() else _('Warning') + ': ' + msg
|
||||
|
||||
def close(self):
|
||||
Question(_('Close channel?'), self._close).open()
|
||||
|
||||
@@ -107,13 +107,11 @@ class LightningOpenChannelDialog(Factory.Popup, Logger):
|
||||
d.open()
|
||||
|
||||
def suggest_node(self):
|
||||
self.app.wallet.network.start_gossip()
|
||||
suggested = self.app.wallet.lnworker.lnrater.suggest_peer()
|
||||
_, _, percent = self.app.wallet.network.lngossip.get_sync_progress_estimate()
|
||||
|
||||
suggested = self.app.wallet.lnworker.suggest_peer()
|
||||
if suggested:
|
||||
self.pubkey = suggested.hex()
|
||||
else:
|
||||
_, _, percent = self.app.wallet.network.lngossip.get_sync_progress_estimate()
|
||||
if percent is None:
|
||||
percent = "??"
|
||||
self.pubkey = f"Please wait, graph is updating ({percent}% / 30% done)."
|
||||
|
||||
@@ -95,6 +95,17 @@ Builder.load_string('''
|
||||
description: _('Change your password') if app._use_single_password else _("Change your password for this wallet.")
|
||||
action: root.change_password
|
||||
CardSeparator
|
||||
SettingsItem:
|
||||
status: _('Trampoline') if not app.use_gossip else _('Gossip')
|
||||
title: _('Lightning Routing') + ': ' + self.status
|
||||
description: _("Use trampoline routing or gossip.")
|
||||
message:
|
||||
_('Lightning payments require finding a path through the Lightning Network.')\
|
||||
+ ' ' + ('You may use trampoline routing, or local routing (gossip).')\
|
||||
+ ' ' + ('Downloading the network gossip uses quite some bandwidth and storage, and is not recommended on mobile devices.')\
|
||||
+ ' ' + ('If you use trampoline, you can only open channels with trampoline nodes.')
|
||||
action: partial(root.boolean_dialog, 'use_gossip', _('Download Gossip'), self.message)
|
||||
CardSeparator
|
||||
SettingsItem:
|
||||
status: _('Yes') if app.android_backups else _('No')
|
||||
title: _('Backups') + ': ' + self.status
|
||||
|
||||
@@ -145,6 +145,17 @@ class ChannelsList(MyTreeView):
|
||||
self.main_window.show_message('success')
|
||||
WaitingDialog(self, 'please wait..', task, on_success, self.on_failure)
|
||||
|
||||
def freeze_channel_for_sending(self, chan, b):
|
||||
if self.lnworker.channel_db or self.lnworker.is_trampoline_peer(chan.node_id):
|
||||
chan.set_frozen_for_sending(b)
|
||||
else:
|
||||
msg = ' '.join([
|
||||
_("Trampoline routing is enabled, but this channel is with a non-trampoline node."),
|
||||
_("This channel may still be used for receiving, but it is frozen for sending."),
|
||||
_("If you want to keep using this channel, you need to disable trampoline routing in your preferences."),
|
||||
])
|
||||
self.main_window.show_warning(msg, title=_('Channel is frozen for sending'))
|
||||
|
||||
def create_menu(self, position):
|
||||
menu = QMenu()
|
||||
menu.setSeparatorsCollapsible(True) # consecutive separators are merged together
|
||||
@@ -177,9 +188,9 @@ class ChannelsList(MyTreeView):
|
||||
channel_id.hex(), title=_("Long Channel ID")))
|
||||
if not chan.is_closed():
|
||||
if not chan.is_frozen_for_sending():
|
||||
menu.addAction(_("Freeze (for sending)"), lambda: chan.set_frozen_for_sending(True))
|
||||
menu.addAction(_("Freeze (for sending)"), lambda: self.freeze_channel_for_sending(chan, True))
|
||||
else:
|
||||
menu.addAction(_("Unfreeze (for sending)"), lambda: chan.set_frozen_for_sending(False))
|
||||
menu.addAction(_("Unfreeze (for sending)"), lambda: self.freeze_channel_for_sending(chan, False))
|
||||
if not chan.is_frozen_for_receiving():
|
||||
menu.addAction(_("Freeze (for receiving)"), lambda: chan.set_frozen_for_receiving(True))
|
||||
else:
|
||||
@@ -359,7 +370,7 @@ class ChannelsList(MyTreeView):
|
||||
suggest_button = QPushButton(d, text=_('Suggest Peer'))
|
||||
def on_suggest():
|
||||
self.parent.wallet.network.start_gossip()
|
||||
nodeid = bh2u(lnworker.lnrater.suggest_peer() or b'')
|
||||
nodeid = bh2u(lnworker.suggest_peer() or b'')
|
||||
if not nodeid:
|
||||
remote_nodeid.setText("")
|
||||
remote_nodeid.setPlaceholderText(
|
||||
|
||||
@@ -742,7 +742,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
|
||||
tools_menu.addAction(_("Electrum preferences"), self.settings_dialog)
|
||||
|
||||
tools_menu.addAction(_("&Network"), self.gui_object.show_network_dialog).setEnabled(bool(self.network))
|
||||
tools_menu.addAction(_("&Lightning Network"), self.gui_object.show_lightning_dialog).setEnabled(bool(self.wallet.has_lightning() and self.network))
|
||||
tools_menu.addAction(_("&Lightning Gossip"), self.gui_object.show_lightning_dialog).setEnabled(bool(self.wallet.has_lightning() and self.network))
|
||||
tools_menu.addAction(_("Local &Watchtower"), self.gui_object.show_watchtower_dialog).setEnabled(bool(self.network and self.network.local_watchtower))
|
||||
tools_menu.addAction(_("&Plugins"), self.plugins_dialog)
|
||||
tools_menu.addSeparator()
|
||||
@@ -2205,8 +2205,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
|
||||
self.seed_button = StatusBarButton(read_QIcon("seed.png"), _("Seed"), self.show_seed_dialog )
|
||||
sb.addPermanentWidget(self.seed_button)
|
||||
self.lightning_button = None
|
||||
if self.wallet.has_lightning() and self.network:
|
||||
self.lightning_button = StatusBarButton(read_QIcon("lightning_disconnected.png"), _("Lightning Network"), self.gui_object.show_lightning_dialog)
|
||||
if self.wallet.has_lightning():
|
||||
self.lightning_button = StatusBarButton(read_QIcon("lightning.png"), _("Lightning Network"), self.gui_object.show_lightning_dialog)
|
||||
self.update_lightning_icon()
|
||||
sb.addPermanentWidget(self.lightning_button)
|
||||
self.status_button = None
|
||||
@@ -2247,10 +2247,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
|
||||
if self.lightning_button is None:
|
||||
return
|
||||
if self.network.lngossip is None:
|
||||
self.lightning_button.setVisible(False)
|
||||
return
|
||||
|
||||
# display colorful lightning icon to signal connection
|
||||
self.lightning_button.setIcon(read_QIcon("lightning.png"))
|
||||
self.lightning_button.setVisible(True)
|
||||
|
||||
cur, total, progress_percent = self.network.lngossip.get_sync_progress_estimate()
|
||||
# self.logger.debug(f"updating lngossip sync progress estimate: cur={cur}, total={total}")
|
||||
|
||||
@@ -130,6 +130,24 @@ class SettingsDialog(WindowModalDialog):
|
||||
# lightning
|
||||
lightning_widgets = []
|
||||
|
||||
help_gossip = _("""If this option is enabled, Electrum will download the network
|
||||
channels graph and compute payment path locally, instead of using trampoline payments. """)
|
||||
gossip_cb = QCheckBox(_("Download network graph"))
|
||||
gossip_cb.setToolTip(help_gossip)
|
||||
gossip_cb.setChecked(bool(self.config.get('use_gossip', False)))
|
||||
def on_gossip_checked(x):
|
||||
use_gossip = bool(x)
|
||||
self.config.set_key('use_gossip', use_gossip)
|
||||
if use_gossip:
|
||||
self.window.network.start_gossip()
|
||||
else:
|
||||
self.window.network.stop_gossip()
|
||||
util.trigger_callback('ln_gossip_sync_progress')
|
||||
# FIXME: update all wallet windows
|
||||
util.trigger_callback('channels_updated', self.wallet)
|
||||
gossip_cb.stateChanged.connect(on_gossip_checked)
|
||||
lightning_widgets.append((gossip_cb, None))
|
||||
|
||||
help_local_wt = _("""If this option is checked, Electrum will
|
||||
run a local watchtower and protect your channels even if your wallet is not
|
||||
open. For this to work, your computer needs to be online regularly.""")
|
||||
|
||||
@@ -215,6 +215,10 @@ def lnencode(addr: 'LnAddr', privkey) -> str:
|
||||
pubkey, channel, feebase, feerate, cltv = step
|
||||
route.append(bitstring.BitArray(pubkey) + bitstring.BitArray(channel) + bitstring.pack('intbe:32', feebase) + bitstring.pack('intbe:32', feerate) + bitstring.pack('intbe:16', cltv))
|
||||
data += tagged('r', route)
|
||||
elif k == 't':
|
||||
pubkey, feebase, feerate, cltv = v
|
||||
route = bitstring.BitArray(pubkey) + bitstring.pack('intbe:32', feebase) + bitstring.pack('intbe:32', feerate) + bitstring.pack('intbe:16', cltv)
|
||||
data += tagged('t', route)
|
||||
elif k == 'f':
|
||||
data += encode_fallback(v, addr.currency)
|
||||
elif k == 'd':
|
||||
@@ -409,6 +413,13 @@ def lndecode(invoice: str, *, verbose=False, expected_hrp=None) -> LnAddr:
|
||||
s.read(32).uintbe,
|
||||
s.read(16).uintbe))
|
||||
addr.tags.append(('r',route))
|
||||
elif tag == 't':
|
||||
s = bitstring.ConstBitStream(tagdata)
|
||||
e = (s.read(264).tobytes(),
|
||||
s.read(32).uintbe,
|
||||
s.read(32).uintbe,
|
||||
s.read(16).uintbe)
|
||||
addr.tags.append(('t', e))
|
||||
elif tag == 'f':
|
||||
fallback = parse_fallback(tagdata, addr.currency)
|
||||
if fallback:
|
||||
|
||||
@@ -720,6 +720,8 @@ class Channel(AbstractChannel):
|
||||
return self.can_send_ctx_updates() and not self.is_closing()
|
||||
|
||||
def is_frozen_for_sending(self) -> bool:
|
||||
if self.lnworker and self.lnworker.channel_db is None and not self.lnworker.is_trampoline_peer(self.node_id):
|
||||
return True
|
||||
return self.storage.get('frozen_for_sending', False)
|
||||
|
||||
def set_frozen_for_sending(self, b: bool) -> None:
|
||||
|
||||
@@ -40,8 +40,8 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
HOPS_DATA_SIZE = 1300 # also sometimes called routingInfoSize in bolt-04
|
||||
TRAMPOLINE_HOPS_DATA_SIZE = 400
|
||||
LEGACY_PER_HOP_FULL_SIZE = 65
|
||||
NUM_STREAM_BYTES = 2 * HOPS_DATA_SIZE
|
||||
PER_HOP_HMAC_SIZE = 32
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ class OnionPacket:
|
||||
|
||||
def __init__(self, public_key: bytes, hops_data: bytes, hmac: bytes):
|
||||
assert len(public_key) == 33
|
||||
assert len(hops_data) == HOPS_DATA_SIZE
|
||||
assert len(hops_data) in [ HOPS_DATA_SIZE, TRAMPOLINE_HOPS_DATA_SIZE ]
|
||||
assert len(hmac) == PER_HOP_HMAC_SIZE
|
||||
self.version = 0
|
||||
self.public_key = public_key
|
||||
@@ -183,21 +183,21 @@ class OnionPacket:
|
||||
ret += self.public_key
|
||||
ret += self.hops_data
|
||||
ret += self.hmac
|
||||
if len(ret) != 1366:
|
||||
if len(ret) - 66 not in [ HOPS_DATA_SIZE, TRAMPOLINE_HOPS_DATA_SIZE ]:
|
||||
raise Exception('unexpected length {}'.format(len(ret)))
|
||||
return ret
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, b: bytes):
|
||||
if len(b) != 1366:
|
||||
if len(b) - 66 not in [ HOPS_DATA_SIZE, TRAMPOLINE_HOPS_DATA_SIZE ]:
|
||||
raise Exception('unexpected length {}'.format(len(b)))
|
||||
version = b[0]
|
||||
if version != 0:
|
||||
raise UnsupportedOnionPacketVersion('version {} is not supported'.format(version))
|
||||
return OnionPacket(
|
||||
public_key=b[1:34],
|
||||
hops_data=b[34:1334],
|
||||
hmac=b[1334:]
|
||||
hops_data=b[34:-32],
|
||||
hmac=b[-32:]
|
||||
)
|
||||
|
||||
|
||||
@@ -226,25 +226,26 @@ def get_shared_secrets_along_route(payment_path_pubkeys: Sequence[bytes],
|
||||
|
||||
|
||||
def new_onion_packet(payment_path_pubkeys: Sequence[bytes], session_key: bytes,
|
||||
hops_data: Sequence[OnionHopsDataSingle], associated_data: bytes) -> OnionPacket:
|
||||
hops_data: Sequence[OnionHopsDataSingle], associated_data: bytes, trampoline=False) -> OnionPacket:
|
||||
num_hops = len(payment_path_pubkeys)
|
||||
assert num_hops == len(hops_data)
|
||||
hop_shared_secrets = get_shared_secrets_along_route(payment_path_pubkeys, session_key)
|
||||
|
||||
filler = _generate_filler(b'rho', hops_data, hop_shared_secrets)
|
||||
data_size = TRAMPOLINE_HOPS_DATA_SIZE if trampoline else HOPS_DATA_SIZE
|
||||
filler = _generate_filler(b'rho', hops_data, hop_shared_secrets, data_size)
|
||||
next_hmac = bytes(PER_HOP_HMAC_SIZE)
|
||||
|
||||
# Our starting packet needs to be filled out with random bytes, we
|
||||
# generate some deterministically using the session private key.
|
||||
pad_key = get_bolt04_onion_key(b'pad', session_key)
|
||||
mix_header = generate_cipher_stream(pad_key, HOPS_DATA_SIZE)
|
||||
mix_header = generate_cipher_stream(pad_key, data_size)
|
||||
|
||||
# compute routing info and MAC for each hop
|
||||
for i in range(num_hops-1, -1, -1):
|
||||
rho_key = get_bolt04_onion_key(b'rho', hop_shared_secrets[i])
|
||||
mu_key = get_bolt04_onion_key(b'mu', hop_shared_secrets[i])
|
||||
hops_data[i].hmac = next_hmac
|
||||
stream_bytes = generate_cipher_stream(rho_key, HOPS_DATA_SIZE)
|
||||
stream_bytes = generate_cipher_stream(rho_key, data_size)
|
||||
hop_data_bytes = hops_data[i].to_bytes()
|
||||
mix_header = mix_header[:-len(hop_data_bytes)]
|
||||
mix_header = hop_data_bytes + mix_header
|
||||
@@ -283,21 +284,28 @@ def calc_hops_data_for_payment(route: 'LNPaymentRoute', amount_msat: int,
|
||||
# payloads, backwards from last hop (but excluding the first edge):
|
||||
for edge_index in range(len(route) - 1, 0, -1):
|
||||
route_edge = route[edge_index]
|
||||
is_trampoline = route_edge.is_trampoline()
|
||||
if is_trampoline:
|
||||
amt += route_edge.fee_for_edge(amt)
|
||||
cltv += route_edge.cltv_expiry_delta
|
||||
hop_payload = {
|
||||
"amt_to_forward": {"amt_to_forward": amt},
|
||||
"outgoing_cltv_value": {"outgoing_cltv_value": cltv},
|
||||
"short_channel_id": {"short_channel_id": route_edge.short_channel_id},
|
||||
}
|
||||
hops_data += [OnionHopsDataSingle(is_tlv_payload=route[edge_index-1].has_feature_varonion(),
|
||||
payload=hop_payload)]
|
||||
amt += route_edge.fee_for_edge(amt)
|
||||
cltv += route_edge.cltv_expiry_delta
|
||||
hops_data.append(
|
||||
OnionHopsDataSingle(
|
||||
is_tlv_payload=route[edge_index-1].has_feature_varonion(),
|
||||
payload=hop_payload))
|
||||
if not is_trampoline:
|
||||
amt += route_edge.fee_for_edge(amt)
|
||||
cltv += route_edge.cltv_expiry_delta
|
||||
hops_data.reverse()
|
||||
return hops_data, amt, cltv
|
||||
|
||||
|
||||
def _generate_filler(key_type: bytes, hops_data: Sequence[OnionHopsDataSingle],
|
||||
shared_secrets: Sequence[bytes]) -> bytes:
|
||||
shared_secrets: Sequence[bytes], data_size:int) -> bytes:
|
||||
num_hops = len(hops_data)
|
||||
|
||||
# generate filler that matches all but the last hop (no HMAC for last hop)
|
||||
@@ -308,16 +316,16 @@ def _generate_filler(key_type: bytes, hops_data: Sequence[OnionHopsDataSingle],
|
||||
|
||||
for i in range(0, num_hops-1): # -1, as last hop does not obfuscate
|
||||
# Sum up how many frames were used by prior hops.
|
||||
filler_start = HOPS_DATA_SIZE
|
||||
filler_start = data_size
|
||||
for hop_data in hops_data[:i]:
|
||||
filler_start -= len(hop_data.to_bytes())
|
||||
# The filler is the part dangling off of the end of the
|
||||
# routingInfo, so offset it from there, and use the current
|
||||
# hop's frame count as its size.
|
||||
filler_end = HOPS_DATA_SIZE + len(hops_data[i].to_bytes())
|
||||
filler_end = data_size + len(hops_data[i].to_bytes())
|
||||
|
||||
stream_key = get_bolt04_onion_key(key_type, shared_secrets[i])
|
||||
stream_bytes = generate_cipher_stream(stream_key, NUM_STREAM_BYTES)
|
||||
stream_bytes = generate_cipher_stream(stream_key, 2 * data_size)
|
||||
filler = xor_bytes(filler, stream_bytes[filler_start:filler_end])
|
||||
filler += bytes(filler_size - len(filler)) # right pad with zeroes
|
||||
|
||||
@@ -334,48 +342,59 @@ class ProcessedOnionPacket(NamedTuple):
|
||||
are_we_final: bool
|
||||
hop_data: OnionHopsDataSingle
|
||||
next_packet: OnionPacket
|
||||
trampoline_onion_packet: OnionPacket
|
||||
|
||||
|
||||
# TODO replay protection
|
||||
def process_onion_packet(onion_packet: OnionPacket, associated_data: bytes,
|
||||
our_onion_private_key: bytes) -> ProcessedOnionPacket:
|
||||
def process_onion_packet(
|
||||
onion_packet: OnionPacket,
|
||||
associated_data: bytes,
|
||||
our_onion_private_key: bytes) -> ProcessedOnionPacket:
|
||||
if not ecc.ECPubkey.is_pubkey_bytes(onion_packet.public_key):
|
||||
raise InvalidOnionPubkey()
|
||||
shared_secret = get_ecdh(our_onion_private_key, onion_packet.public_key)
|
||||
|
||||
# check message integrity
|
||||
mu_key = get_bolt04_onion_key(b'mu', shared_secret)
|
||||
calculated_mac = hmac_oneshot(mu_key, msg=onion_packet.hops_data+associated_data,
|
||||
digest=hashlib.sha256)
|
||||
calculated_mac = hmac_oneshot(
|
||||
mu_key, msg=onion_packet.hops_data+associated_data,
|
||||
digest=hashlib.sha256)
|
||||
if onion_packet.hmac != calculated_mac:
|
||||
raise InvalidOnionMac()
|
||||
|
||||
# peel an onion layer off
|
||||
rho_key = get_bolt04_onion_key(b'rho', shared_secret)
|
||||
stream_bytes = generate_cipher_stream(rho_key, NUM_STREAM_BYTES)
|
||||
stream_bytes = generate_cipher_stream(rho_key, 2 * HOPS_DATA_SIZE)
|
||||
padded_header = onion_packet.hops_data + bytes(HOPS_DATA_SIZE)
|
||||
next_hops_data = xor_bytes(padded_header, stream_bytes)
|
||||
next_hops_data_fd = io.BytesIO(next_hops_data)
|
||||
|
||||
hop_data = OnionHopsDataSingle.from_fd(next_hops_data_fd)
|
||||
# trampoline
|
||||
trampoline_onion_packet = hop_data.payload.get('trampoline_onion_packet')
|
||||
if trampoline_onion_packet:
|
||||
top_version = trampoline_onion_packet.get('version')
|
||||
top_public_key = trampoline_onion_packet.get('public_key')
|
||||
top_hops_data = trampoline_onion_packet.get('hops_data')
|
||||
top_hops_data_fd = io.BytesIO(top_hops_data)
|
||||
top_hmac = trampoline_onion_packet.get('hmac')
|
||||
trampoline_onion_packet = OnionPacket(
|
||||
public_key=top_public_key,
|
||||
hops_data=top_hops_data_fd.read(TRAMPOLINE_HOPS_DATA_SIZE),
|
||||
hmac=top_hmac)
|
||||
# calc next ephemeral key
|
||||
blinding_factor = sha256(onion_packet.public_key + shared_secret)
|
||||
blinding_factor_int = int.from_bytes(blinding_factor, byteorder="big")
|
||||
next_public_key_int = ecc.ECPubkey(onion_packet.public_key) * blinding_factor_int
|
||||
next_public_key = next_public_key_int.get_public_key_bytes()
|
||||
|
||||
hop_data = OnionHopsDataSingle.from_fd(next_hops_data_fd)
|
||||
next_onion_packet = OnionPacket(
|
||||
public_key=next_public_key,
|
||||
hops_data=next_hops_data_fd.read(HOPS_DATA_SIZE),
|
||||
hmac=hop_data.hmac
|
||||
)
|
||||
hmac=hop_data.hmac)
|
||||
if hop_data.hmac == bytes(PER_HOP_HMAC_SIZE):
|
||||
# we are the destination / exit node
|
||||
are_we_final = True
|
||||
else:
|
||||
# we are an intermediate node; forwarding
|
||||
are_we_final = False
|
||||
return ProcessedOnionPacket(are_we_final, hop_data, next_onion_packet)
|
||||
return ProcessedOnionPacket(are_we_final, hop_data, next_onion_packet, trampoline_onion_packet)
|
||||
|
||||
|
||||
class FailedToDecodeOnionError(Exception): pass
|
||||
@@ -498,6 +517,8 @@ class OnionFailureCode(IntEnum):
|
||||
EXPIRY_TOO_FAR = 21
|
||||
INVALID_ONION_PAYLOAD = PERM | 22
|
||||
MPP_TIMEOUT = 23
|
||||
TRAMPOLINE_FEE_INSUFFICIENT = NODE | 51
|
||||
TRAMPOLINE_EXPIRY_TOO_SOON = NODE | 52
|
||||
|
||||
|
||||
# don't use these elsewhere, the names are ambiguous without context
|
||||
|
||||
@@ -247,14 +247,17 @@ class Peer(Logger):
|
||||
self.maybe_set_initialized()
|
||||
|
||||
def on_node_announcement(self, payload):
|
||||
self.gossip_queue.put_nowait(('node_announcement', payload))
|
||||
if self.lnworker.channel_db:
|
||||
self.gossip_queue.put_nowait(('node_announcement', payload))
|
||||
|
||||
def on_channel_announcement(self, payload):
|
||||
self.gossip_queue.put_nowait(('channel_announcement', payload))
|
||||
if self.lnworker.channel_db:
|
||||
self.gossip_queue.put_nowait(('channel_announcement', payload))
|
||||
|
||||
def on_channel_update(self, payload):
|
||||
self.maybe_save_remote_update(payload)
|
||||
self.gossip_queue.put_nowait(('channel_update', payload))
|
||||
if self.lnworker.channel_db:
|
||||
self.gossip_queue.put_nowait(('channel_update', payload))
|
||||
|
||||
def maybe_save_remote_update(self, payload):
|
||||
if not self.channels:
|
||||
@@ -312,6 +315,8 @@ class Peer(Logger):
|
||||
async def process_gossip(self):
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
if not self.network.lngossip:
|
||||
continue
|
||||
chan_anns = []
|
||||
chan_upds = []
|
||||
node_anns = []
|
||||
@@ -330,7 +335,8 @@ class Peer(Logger):
|
||||
# verify in peer's TaskGroup so that we fail the connection
|
||||
self.verify_channel_announcements(chan_anns)
|
||||
self.verify_node_announcements(node_anns)
|
||||
await self.network.lngossip.process_gossip(chan_anns, node_anns, chan_upds)
|
||||
if self.network.lngossip:
|
||||
await self.network.lngossip.process_gossip(chan_anns, node_anns, chan_upds)
|
||||
|
||||
def verify_channel_announcements(self, chan_anns):
|
||||
for payload in chan_anns:
|
||||
@@ -579,6 +585,9 @@ class Peer(Logger):
|
||||
"""
|
||||
# will raise if init fails
|
||||
await asyncio.wait_for(self.initialized, LN_P2P_NETWORK_TIMEOUT)
|
||||
# trampoline is not yet in features
|
||||
if not self.lnworker.channel_db and not self.lnworker.is_trampoline_peer(self.pubkey):
|
||||
raise Exception(_('Not a trampoline node') + str(self.their_features))
|
||||
|
||||
feerate = self.lnworker.current_feerate_per_kw()
|
||||
local_config = self.make_local_config(funding_sat, push_msat, LOCAL)
|
||||
@@ -1195,21 +1204,68 @@ class Peer(Logger):
|
||||
# add features learned during "init" for direct neighbour:
|
||||
route[0].node_features |= self.features
|
||||
local_height = self.network.get_local_height()
|
||||
# create onion packet
|
||||
final_cltv = local_height + min_final_cltv_expiry
|
||||
hops_data, amount_msat, cltv = calc_hops_data_for_payment(route, amount_msat, final_cltv,
|
||||
payment_secret=payment_secret)
|
||||
self.logger.info(f"lnpeer.pay len(route)={len(route)}")
|
||||
for i in range(len(route)):
|
||||
self.logger.info(f" {i}: edge={route[i].short_channel_id} hop_data={hops_data[i]!r}")
|
||||
assert final_cltv <= cltv, (final_cltv, cltv)
|
||||
secret_key = os.urandom(32)
|
||||
onion = new_onion_packet([x.node_id for x in route], secret_key, hops_data, associated_data=payment_hash)
|
||||
session_key = os.urandom(32) # session_key
|
||||
# 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-1):
|
||||
route_edge = route[i]
|
||||
next_edge = route[i+1]
|
||||
if route_edge.is_trampoline():
|
||||
assert next_edge.is_trampoline()
|
||||
self.logger.info(f'trampoline hop at position {i}')
|
||||
hops_data[i].payload["outgoing_node_id"] = {"outgoing_node_id":next_edge.node_id}
|
||||
if route_edge.invoice_features:
|
||||
hops_data[i].payload["invoice_features"] = {"invoice_features":route_edge.invoice_features}
|
||||
if route_edge.invoice_routing_info:
|
||||
hops_data[i].payload["invoice_routing_info"] = {"invoice_routing_info":route_edge.invoice_routing_info}
|
||||
|
||||
# create trampoline onion
|
||||
for i in range(num_hops):
|
||||
route_edge = route[i]
|
||||
if route_edge.is_trampoline():
|
||||
self.logger.info(f'first trampoline hop at position {i}')
|
||||
self.logger.info(f'inner onion: {hops_data[i:]}')
|
||||
trampoline_session_key = os.urandom(32)
|
||||
trampoline_onion = new_onion_packet(payment_path_pubkeys[i:], trampoline_session_key, hops_data[i:], associated_data=payment_hash, trampoline=True)
|
||||
# drop hop_data
|
||||
payment_path_pubkeys = payment_path_pubkeys[:i]
|
||||
hops_data = hops_data[:i]
|
||||
# we must generate a different secret for the outer onion
|
||||
outer_payment_secret = os.urandom(32)
|
||||
# trampoline_payload is a final payload
|
||||
trampoline_payload = hops_data[i-1].payload
|
||||
p = trampoline_payload.pop('short_channel_id')
|
||||
amt_to_forward = trampoline_payload["amt_to_forward"]["amt_to_forward"]
|
||||
trampoline_payload["payment_data"] = {
|
||||
"payment_secret":outer_payment_secret,
|
||||
"total_msat": amt_to_forward
|
||||
}
|
||||
trampoline_payload["trampoline_onion_packet"] = {
|
||||
"version": trampoline_onion.version,
|
||||
"public_key": trampoline_onion.public_key,
|
||||
"hops_data": trampoline_onion.hops_data,
|
||||
"hmac": trampoline_onion.hmac
|
||||
}
|
||||
break
|
||||
# create onion packet
|
||||
onion = new_onion_packet(payment_path_pubkeys, session_key, hops_data, associated_data=payment_hash) # must use another sessionkey
|
||||
|
||||
self.logger.info(f"starting payment. len(route)={len(hops_data)}.")
|
||||
# create htlc
|
||||
if cltv > local_height + lnutil.NBLOCK_CLTV_EXPIRY_TOO_FAR_INTO_FUTURE:
|
||||
raise PaymentFailure(f"htlc expiry too far into future. (in {cltv-local_height} blocks)")
|
||||
htlc = UpdateAddHtlc(amount_msat=amount_msat, payment_hash=payment_hash, cltv_expiry=cltv, timestamp=int(time.time()))
|
||||
htlc = chan.add_htlc(htlc)
|
||||
chan.set_onion_key(htlc.htlc_id, secret_key)
|
||||
self.logger.info(f"starting payment. len(route)={len(route)}. route: {route}. "
|
||||
f"htlc: {htlc}. hops_data={hops_data!r}")
|
||||
chan.set_onion_key(htlc.htlc_id, session_key) # should it be the outer onion secret?
|
||||
self.logger.info(f"starting payment. htlc: {htlc}")
|
||||
self.send_message(
|
||||
"update_add_htlc",
|
||||
channel_id=chan.channel_id,
|
||||
@@ -1372,20 +1428,9 @@ class Peer(Logger):
|
||||
self, *,
|
||||
chan: Channel,
|
||||
htlc: UpdateAddHtlc,
|
||||
processed_onion: ProcessedOnionPacket) -> Tuple[Optional[bytes], Optional[OnionRoutingFailure]]:
|
||||
processed_onion: ProcessedOnionPacket,
|
||||
is_trampoline:bool = False) -> Optional[bytes]:
|
||||
|
||||
info = self.lnworker.get_payment_info(htlc.payment_hash)
|
||||
if info is None:
|
||||
raise OnionRoutingFailure(code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=b'')
|
||||
preimage = self.lnworker.get_preimage(htlc.payment_hash)
|
||||
try:
|
||||
payment_secret_from_onion = processed_onion.hop_data.payload["payment_data"]["payment_secret"]
|
||||
except:
|
||||
pass # skip
|
||||
else:
|
||||
if payment_secret_from_onion != derive_payment_secret_from_payment_preimage(preimage):
|
||||
raise OnionRoutingFailure(code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=b'')
|
||||
expected_received_msat = info.amount_msat
|
||||
# Check that our blockchain tip is sufficiently recent so that we have an approx idea of the height.
|
||||
# We should not release the preimage for an HTLC that its sender could already time out as
|
||||
# then they might try to force-close and it becomes a race.
|
||||
@@ -1412,12 +1457,38 @@ class Peer(Logger):
|
||||
except:
|
||||
total_msat = amt_to_forward # fall back to "amt_to_forward"
|
||||
|
||||
if amt_to_forward != htlc.amount_msat:
|
||||
if not is_trampoline and amt_to_forward != htlc.amount_msat:
|
||||
raise OnionRoutingFailure(
|
||||
code=OnionFailureCode.FINAL_INCORRECT_HTLC_AMOUNT,
|
||||
data=total_msat.to_bytes(8, byteorder="big"))
|
||||
|
||||
# if there is a trampoline_onion, perform the above checks on it
|
||||
if processed_onion.trampoline_onion_packet:
|
||||
trampoline_onion = process_onion_packet(
|
||||
processed_onion.trampoline_onion_packet,
|
||||
associated_data=htlc.payment_hash,
|
||||
our_onion_private_key=self.privkey)
|
||||
return self.maybe_fulfill_htlc(
|
||||
chan=chan,
|
||||
htlc=htlc,
|
||||
processed_onion=trampoline_onion,
|
||||
is_trampoline=True)
|
||||
|
||||
info = self.lnworker.get_payment_info(htlc.payment_hash)
|
||||
if info is None:
|
||||
raise OnionRoutingFailure(code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=b'')
|
||||
preimage = self.lnworker.get_preimage(htlc.payment_hash)
|
||||
try:
|
||||
payment_secret_from_onion = processed_onion.hop_data.payload["payment_data"]["payment_secret"]
|
||||
except:
|
||||
pass # skip
|
||||
else:
|
||||
if payment_secret_from_onion != derive_payment_secret_from_payment_preimage(preimage):
|
||||
raise OnionRoutingFailure(code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=b'')
|
||||
expected_received_msat = info.amount_msat
|
||||
if expected_received_msat is None:
|
||||
return preimage
|
||||
|
||||
if not (expected_received_msat <= total_msat <= 2 * expected_received_msat):
|
||||
raise OnionRoutingFailure(code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=b'')
|
||||
accepted, expired = self.lnworker.htlc_received(chan.short_channel_id, htlc, expected_received_msat)
|
||||
|
||||
@@ -99,6 +99,16 @@ class RouteEdge(PathEdge):
|
||||
features = self.node_features
|
||||
return bool(features & LnFeatures.VAR_ONION_REQ or features & LnFeatures.VAR_ONION_OPT)
|
||||
|
||||
def is_trampoline(self):
|
||||
return False
|
||||
|
||||
@attr.s
|
||||
class TrampolineEdge(RouteEdge):
|
||||
invoice_routing_info = attr.ib(type=bytes, default=None)
|
||||
invoice_features = attr.ib(type=int, default=None)
|
||||
short_channel_id = attr.ib(0)
|
||||
def is_trampoline(self):
|
||||
return True
|
||||
|
||||
LNPaymentPath = Sequence[PathEdge]
|
||||
LNPaymentRoute = Sequence[RouteEdge]
|
||||
|
||||
@@ -949,6 +949,15 @@ class LnFeatures(IntFlag):
|
||||
_ln_feature_contexts[OPTION_SUPPORT_LARGE_CHANNEL_OPT] = (LNFC.INIT | LNFC.NODE_ANN | LNFC.CHAN_ANN_ALWAYS_EVEN)
|
||||
_ln_feature_contexts[OPTION_SUPPORT_LARGE_CHANNEL_REQ] = (LNFC.INIT | LNFC.NODE_ANN | LNFC.CHAN_ANN_ALWAYS_EVEN)
|
||||
|
||||
OPTION_TRAMPOLINE_ROUTING_REQ = 1 << 50
|
||||
OPTION_TRAMPOLINE_ROUTING_OPT = 1 << 51
|
||||
|
||||
# We do not set trampoline_routing_opt in invoices, because the spec is not ready.
|
||||
# This ensures that current version of Phoenix can pay us
|
||||
# It also prevents Electrum from using t_tags from future implementations
|
||||
_ln_feature_contexts[OPTION_TRAMPOLINE_ROUTING_REQ] = (LNFC.INIT | LNFC.NODE_ANN) # | LNFC.INVOICE)
|
||||
_ln_feature_contexts[OPTION_TRAMPOLINE_ROUTING_OPT] = (LNFC.INIT | LNFC.NODE_ANN) # | LNFC.INVOICE)
|
||||
|
||||
def validate_transitive_dependencies(self) -> bool:
|
||||
# for all even bit set, set corresponding odd bit:
|
||||
features = self # copy
|
||||
@@ -1014,6 +1023,7 @@ LN_FEATURES_IMPLEMENTED = (
|
||||
| LnFeatures.OPTION_STATIC_REMOTEKEY_OPT | LnFeatures.OPTION_STATIC_REMOTEKEY_REQ
|
||||
| LnFeatures.VAR_ONION_OPT | LnFeatures.VAR_ONION_REQ
|
||||
| LnFeatures.PAYMENT_SECRET_OPT | LnFeatures.PAYMENT_SECRET_REQ
|
||||
| LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT | LnFeatures.OPTION_TRAMPOLINE_ROUTING_REQ
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,17 @@ tlvdata,tlv_payload,short_channel_id,short_channel_id,short_channel_id,
|
||||
tlvtype,tlv_payload,payment_data,8
|
||||
tlvdata,tlv_payload,payment_data,payment_secret,byte,32
|
||||
tlvdata,tlv_payload,payment_data,total_msat,tu64,
|
||||
tlvtype,tlv_payload,invoice_features,66097
|
||||
tlvdata,tlv_payload,invoice_features,invoice_features,u64,
|
||||
tlvtype,tlv_payload,outgoing_node_id,66098
|
||||
tlvdata,tlv_payload,outgoing_node_id,outgoing_node_id,byte,33
|
||||
tlvtype,tlv_payload,invoice_routing_info,66099
|
||||
tlvdata,tlv_payload,invoice_routing_info,invoice_routing_info,byte,...
|
||||
tlvtype,tlv_payload,trampoline_onion_packet,66100
|
||||
tlvdata,tlv_payload,trampoline_onion_packet,version,byte,1
|
||||
tlvdata,tlv_payload,trampoline_onion_packet,public_key,byte,33
|
||||
tlvdata,tlv_payload,trampoline_onion_packet,hops_data,byte,400
|
||||
tlvdata,tlv_payload,trampoline_onion_packet,hmac,byte,32
|
||||
msgtype,invalid_realm,PERM|1
|
||||
msgtype,temporary_node_failure,NODE|2
|
||||
msgtype,permanent_node_failure,PERM|NODE|2
|
||||
|
||||
|
@@ -58,6 +58,7 @@ from .lnutil import (Outpoint, LNPeerAddr,
|
||||
UpdateAddHtlc, Direction, LnFeatures, ShortChannelID,
|
||||
HtlcLog, derive_payment_secret_from_payment_preimage)
|
||||
from .lnutil import ln_dummy_address, ln_compare_features, IncompatibleLightningFeatures
|
||||
from .lnrouter import TrampolineEdge
|
||||
from .transaction import PartialTxOutput, PartialTransaction, PartialTxInput
|
||||
from .lnonion import OnionFailureCode, process_onion_packet, OnionPacket, OnionRoutingFailure
|
||||
from .lnmsg import decode_msg
|
||||
@@ -73,6 +74,7 @@ from .lnchannel import ChannelBackup
|
||||
from .channel_db import UpdateStatus
|
||||
from .channel_db import get_mychannel_info, get_mychannel_policy
|
||||
from .submarine_swaps import SwapManager
|
||||
from .channel_db import ChannelInfo, Policy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .network import Network
|
||||
@@ -136,6 +138,60 @@ FALLBACK_NODE_LIST_MAINNET = [
|
||||
]
|
||||
|
||||
|
||||
# hardcoded list
|
||||
TRAMPOLINE_NODES_MAINNET = {
|
||||
'ACINQ': LNPeerAddr(host='34.239.230.56', port=9735, pubkey=bfh('03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f')),
|
||||
'Electrum trampoline': LNPeerAddr(host='144.76.99.209', port=9740, pubkey=bfh('03ecef675be448b615e6176424070673ef8284e0fd19d8be062a6cb5b130a0a0d1')),
|
||||
}
|
||||
|
||||
def hardcoded_trampoline_nodes():
|
||||
return TRAMPOLINE_NODES_MAINNET if constants.net in (constants.BitcoinMainnet, ) else {}
|
||||
|
||||
def trampolines_by_id():
|
||||
return dict([(x.pubkey, x) for x in hardcoded_trampoline_nodes().values()])
|
||||
|
||||
is_hardcoded_trampoline = lambda node_id: node_id in trampolines_by_id().keys()
|
||||
|
||||
# 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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class PaymentInfo(NamedTuple):
|
||||
payment_hash: bytes
|
||||
amount_msat: Optional[int]
|
||||
@@ -165,7 +221,8 @@ LNWALLET_FEATURES = BASE_FEATURES\
|
||||
| LnFeatures.OPTION_DATA_LOSS_PROTECT_REQ\
|
||||
| LnFeatures.OPTION_STATIC_REMOTEKEY_REQ\
|
||||
| LnFeatures.GOSSIP_QUERIES_REQ\
|
||||
| LnFeatures.BASIC_MPP_OPT
|
||||
| LnFeatures.BASIC_MPP_OPT\
|
||||
| LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT
|
||||
|
||||
LNGOSSIP_FEATURES = BASE_FEATURES\
|
||||
| LnFeatures.GOSSIP_QUERIES_OPT\
|
||||
@@ -191,10 +248,13 @@ class LNWorker(Logger, NetworkRetryManager[LNPeerAddr]):
|
||||
self.features = features
|
||||
self.network = None # type: Optional[Network]
|
||||
self.config = None # type: Optional[SimpleConfig]
|
||||
self.channel_db = None # type: Optional[ChannelDB]
|
||||
|
||||
util.register_callback(self.on_proxy_changed, ['proxy_set'])
|
||||
|
||||
@property
|
||||
def channel_db(self):
|
||||
return self.network.channel_db if self.network else None
|
||||
|
||||
@property
|
||||
def peers(self) -> Mapping[bytes, Peer]:
|
||||
"""Returns a read-only copy of peers."""
|
||||
@@ -209,7 +269,12 @@ class LNWorker(Logger, NetworkRetryManager[LNPeerAddr]):
|
||||
node_info = self.channel_db.get_node_info_for_node_id(node_id)
|
||||
node_alias = (node_info.alias if node_info else '') or node_id.hex()
|
||||
else:
|
||||
node_alias = ''
|
||||
for k, v in hardcoded_trampoline_nodes().items():
|
||||
if v.pubkey == node_id:
|
||||
node_alias = k
|
||||
break
|
||||
else:
|
||||
node_alias = 'unknown'
|
||||
return node_alias
|
||||
|
||||
async def maybe_listen(self):
|
||||
@@ -294,7 +359,6 @@ class LNWorker(Logger, NetworkRetryManager[LNPeerAddr]):
|
||||
assert network
|
||||
self.network = network
|
||||
self.config = network.config
|
||||
self.channel_db = self.network.channel_db
|
||||
self._add_peers_from_config()
|
||||
asyncio.run_coroutine_threadsafe(self.main_loop(), self.network.asyncio_loop)
|
||||
|
||||
@@ -451,10 +515,16 @@ class LNWorker(Logger, NetworkRetryManager[LNPeerAddr]):
|
||||
if rest is not None:
|
||||
host, port = split_host_port(rest)
|
||||
else:
|
||||
addrs = self.channel_db.get_node_addresses(node_id)
|
||||
if not addrs:
|
||||
raise ConnStringFormatError(_('Don\'t know any addresses for node:') + ' ' + bh2u(node_id))
|
||||
host, port, timestamp = self.choose_preferred_address(list(addrs))
|
||||
if not self.channel_db:
|
||||
addr = trampolines_by_id().get(node_id)
|
||||
if not addr:
|
||||
raise ConnStringFormatError(_('Address unknown for node:') + ' ' + bh2u(node_id))
|
||||
host, port = addr.host, addr.port
|
||||
else:
|
||||
addrs = self.channel_db.get_node_addresses(node_id)
|
||||
if not addrs:
|
||||
raise ConnStringFormatError(_('Don\'t know any addresses for node:') + ' ' + bh2u(node_id))
|
||||
host, port, timestamp = self.choose_preferred_address(list(addrs))
|
||||
port = int(port)
|
||||
# Try DNS-resolving the host (if needed). This is simply so that
|
||||
# the caller gets a nice exception if it cannot be resolved.
|
||||
@@ -648,7 +718,6 @@ class LNWallet(LNWorker):
|
||||
assert network
|
||||
self.network = network
|
||||
self.config = network.config
|
||||
self.channel_db = self.network.channel_db
|
||||
self.lnwatcher = LNWalletWatcher(self, network)
|
||||
self.lnwatcher.start_network(network)
|
||||
self.swap_manager.start_network(network=network, lnwatcher=self.lnwatcher)
|
||||
@@ -923,10 +992,6 @@ class LNWallet(LNWorker):
|
||||
chan, funding_tx = fut.result()
|
||||
except concurrent.futures.TimeoutError:
|
||||
raise Exception(_("open_channel timed out"))
|
||||
# at this point the channel opening was successful
|
||||
# if this is the first channel that got opened, we start gossiping
|
||||
if self.channels:
|
||||
self.network.start_gossip()
|
||||
return chan, funding_tx
|
||||
|
||||
def get_channel_by_short_id(self, short_channel_id: bytes) -> Optional[Channel]:
|
||||
@@ -958,6 +1023,7 @@ class LNWallet(LNWorker):
|
||||
invoice_pubkey = lnaddr.pubkey.serialize()
|
||||
invoice_features = lnaddr.get_tag('9') or 0
|
||||
r_tags = lnaddr.get_routing_info('r')
|
||||
t_tags = lnaddr.get_routing_info('t')
|
||||
amount_to_pay = lnaddr.get_amount_msat()
|
||||
status = self.get_payment_status(payment_hash)
|
||||
if status == PR_PAID:
|
||||
@@ -967,12 +1033,18 @@ class LNWallet(LNWorker):
|
||||
info = PaymentInfo(payment_hash, amount_to_pay, SENT, PR_UNPAID)
|
||||
self.save_payment_info(info)
|
||||
self.wallet.set_label(key, lnaddr.get_description())
|
||||
|
||||
if self.channel_db is None:
|
||||
self.trampoline_fee_level = 0
|
||||
self.trampoline2_list = list(trampolines_by_id().keys())
|
||||
random.shuffle(self.trampoline2_list)
|
||||
|
||||
self.set_invoice_status(key, PR_INFLIGHT)
|
||||
util.trigger_callback('invoice_status', self.wallet, key)
|
||||
try:
|
||||
await self.pay_to_node(
|
||||
invoice_pubkey, payment_hash, payment_secret, amount_to_pay,
|
||||
min_cltv_expiry, r_tags, invoice_features,
|
||||
min_cltv_expiry, r_tags, t_tags, invoice_features,
|
||||
attempts=attempts, full_path=full_path)
|
||||
success = True
|
||||
except PaymentFailure as e:
|
||||
@@ -991,7 +1063,7 @@ class LNWallet(LNWorker):
|
||||
|
||||
async def pay_to_node(
|
||||
self, node_pubkey, payment_hash, payment_secret, amount_to_pay,
|
||||
min_cltv_expiry, r_tags, invoice_features, *, attempts: int = 1,
|
||||
min_cltv_expiry, r_tags, t_tags, invoice_features, *, attempts: int = 1,
|
||||
full_path: LNPaymentPath = None):
|
||||
|
||||
self.logs[payment_hash.hex()] = log = []
|
||||
@@ -1002,9 +1074,14 @@ class LNWallet(LNWorker):
|
||||
# 1. create a set of routes for remaining amount.
|
||||
# note: path-finding runs in a separate thread so that we don't block the asyncio loop
|
||||
# graph updates might occur during the computation
|
||||
routes = await run_in_thread(partial(
|
||||
self.create_routes_for_payment, amount_to_send, node_pubkey,
|
||||
min_cltv_expiry, r_tags, invoice_features, full_path=full_path))
|
||||
if self.channel_db:
|
||||
routes = await run_in_thread(partial(
|
||||
self.create_routes_for_payment, amount_to_send, node_pubkey,
|
||||
min_cltv_expiry, r_tags, invoice_features, full_path=full_path))
|
||||
else:
|
||||
route = await self.create_trampoline_route(
|
||||
amount_to_send, node_pubkey, invoice_features, r_tags, t_tags)
|
||||
routes = [(route, amount_to_send)]
|
||||
# 2. send htlcs
|
||||
for route, amount_msat in routes:
|
||||
await self.pay_to_route(route, amount_msat, payment_hash, payment_secret, min_cltv_expiry)
|
||||
@@ -1049,6 +1126,21 @@ class LNWallet(LNWorker):
|
||||
code, data = failure_msg.code, failure_msg.data
|
||||
self.logger.info(f"UPDATE_FAIL_HTLC {repr(code)} {data}")
|
||||
self.logger.info(f"error reported by {bh2u(route[sender_idx].node_id)}")
|
||||
if code == OnionFailureCode.MPP_TIMEOUT:
|
||||
raise PaymentFailure(failure_msg.code_name())
|
||||
# trampoline
|
||||
if self.channel_db is None:
|
||||
if code == OnionFailureCode.TRAMPOLINE_FEE_INSUFFICIENT:
|
||||
# todo: parse the node parameters here (not returned by eclair yet)
|
||||
self.trampoline_fee_level += 1
|
||||
return
|
||||
elif len(route) > 2:
|
||||
edge = route[2]
|
||||
if edge.is_trampoline() and edge.node_id in self.trampoline2_list:
|
||||
self.logger.info(f"blacklisting second trampoline {edge.node_id.hex()}")
|
||||
self.trampoline2_list.remove(edge.node_id)
|
||||
return
|
||||
raise PaymentFailure(failure_msg.code_name())
|
||||
# handle some specific error codes
|
||||
failure_codes = {
|
||||
OnionFailureCode.TEMPORARY_CHANNEL_FAILURE: 0,
|
||||
@@ -1113,7 +1205,6 @@ class LNWallet(LNWorker):
|
||||
if not (blacklist or update):
|
||||
raise PaymentFailure(htlc_log.failure_msg.code_name())
|
||||
|
||||
|
||||
@classmethod
|
||||
def _decode_channel_update_msg(cls, chan_upd_msg: bytes) -> Optional[Dict[str, Any]]:
|
||||
channel_update_as_received = chan_upd_msg
|
||||
@@ -1152,6 +1243,131 @@ class LNWallet(LNWorker):
|
||||
f"min_final_cltv_expiry: {addr.get_min_final_cltv_expiry()}"))
|
||||
return addr
|
||||
|
||||
def encode_routing_info(self, r_tags):
|
||||
import bitstring
|
||||
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_trampoline_peer(self, node_id):
|
||||
# until trampoline is advertised in lnfeatures, check against hardcoded list
|
||||
if is_hardcoded_trampoline(node_id):
|
||||
return True
|
||||
peer = self._peers.get(node_id)
|
||||
if peer and bool(peer.their_features & LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT):
|
||||
return True
|
||||
return False
|
||||
|
||||
def suggest_peer(self):
|
||||
return self.lnrater.suggest_peer() if self.channel_db else random.choice(list(hardcoded_trampoline_nodes().values())).pubkey
|
||||
|
||||
@log_exceptions
|
||||
async def create_trampoline_route(
|
||||
self, amount_msat:int, invoice_pubkey:bytes, invoice_features:int,
|
||||
r_tags, t_tags) -> LNPaymentRoute:
|
||||
""" return the route that leads to trampoline, and the trampoline fake edge"""
|
||||
|
||||
# We do not set trampoline_routing_opt in our invoices, because the spec is not ready
|
||||
# Do not use t_tags if the flag is set, because we the format is not decided yet
|
||||
if invoice_features & LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT:
|
||||
is_legacy = False
|
||||
if len(r_tags) > 0 and len(r_tags[0]) == 1:
|
||||
pubkey, scid, feebase, feerate, cltv = r_tags[0][0]
|
||||
t_tag = pubkey, feebase, feerate, cltv
|
||||
else:
|
||||
t_tag = None
|
||||
elif len(t_tags) > 0:
|
||||
is_legacy = False
|
||||
t_tag = t_tags[0]
|
||||
else:
|
||||
is_legacy = True
|
||||
|
||||
# Find a trampoline. We assume we have a direct channel to trampoline
|
||||
for chan in list(self.channels.values()):
|
||||
if not self.is_trampoline_peer(chan.node_id):
|
||||
continue
|
||||
if chan.is_active() and chan.can_pay(amount_msat, check_frozen=True):
|
||||
trampoline_short_channel_id = chan.short_channel_id
|
||||
trampoline_node_id = chan.node_id
|
||||
break
|
||||
else:
|
||||
raise NoPathFound()
|
||||
# use attempt number to decide fee and second trampoline
|
||||
# we need a state with the list of nodes we have not tried
|
||||
# add optional second trampoline
|
||||
trampoline2 = None
|
||||
if is_legacy:
|
||||
for node_id in self.trampoline2_list:
|
||||
if node_id != trampoline_node_id:
|
||||
trampoline2 = node_id
|
||||
break
|
||||
# fee level. the same fee is used for all trampolines
|
||||
if self.trampoline_fee_level < len(TRAMPOLINE_FEES):
|
||||
params = TRAMPOLINE_FEES[self.trampoline_fee_level]
|
||||
else:
|
||||
raise NoPathFound()
|
||||
self.logger.info(f'create route with trampoline: fee_level={self.trampoline_fee_level}, is legacy: {is_legacy}')
|
||||
self.logger.info(f'first trampoline: {trampoline_node_id.hex()}')
|
||||
self.logger.info(f'second trampoline: {trampoline2.hex() if trampoline2 else None}')
|
||||
self.logger.info(f'params: {params}')
|
||||
# node_features is only used to determine is_tlv
|
||||
trampoline_features = LnFeatures.VAR_ONION_OPT
|
||||
# hop to trampoline
|
||||
route = [
|
||||
RouteEdge(
|
||||
node_id=trampoline_node_id,
|
||||
short_channel_id=trampoline_short_channel_id,
|
||||
fee_base_msat=0,
|
||||
fee_proportional_millionths=0,
|
||||
cltv_expiry_delta=0,
|
||||
node_features=trampoline_features)
|
||||
]
|
||||
# trampoline hop
|
||||
route.append(
|
||||
TrampolineEdge(
|
||||
node_id=trampoline_node_id,
|
||||
fee_base_msat=params['fee_base_msat'],
|
||||
fee_proportional_millionths=params['fee_proportional_millionths'],
|
||||
cltv_expiry_delta=params['cltv_expiry_delta'],
|
||||
node_features=trampoline_features))
|
||||
if trampoline2:
|
||||
route.append(
|
||||
TrampolineEdge(
|
||||
node_id=trampoline2,
|
||||
fee_base_msat=params['fee_base_msat'],
|
||||
fee_proportional_millionths=params['fee_proportional_millionths'],
|
||||
cltv_expiry_delta=params['cltv_expiry_delta'],
|
||||
node_features=trampoline_features))
|
||||
# add routing info
|
||||
if is_legacy:
|
||||
invoice_routing_info = self.encode_routing_info(r_tags)
|
||||
route[-1].invoice_routing_info = invoice_routing_info
|
||||
route[-1].invoice_features = invoice_features
|
||||
else:
|
||||
if t_tag:
|
||||
pubkey, feebase, feerate, cltv = t_tag
|
||||
if route[-1].node_id != pubkey:
|
||||
route.append(
|
||||
TrampolineEdge(
|
||||
node_id=pubkey,
|
||||
fee_base_msat=feebase,
|
||||
fee_proportional_millionths=feerate,
|
||||
cltv_expiry_delta=cltv,
|
||||
node_features=trampoline_features))
|
||||
# Fake edge (not part of actual route, needed by calc_hops_data)
|
||||
route.append(
|
||||
TrampolineEdge(
|
||||
node_id=invoice_pubkey,
|
||||
fee_base_msat=0,
|
||||
fee_proportional_millionths=0,
|
||||
cltv_expiry_delta=0,
|
||||
node_features=trampoline_features))
|
||||
return route
|
||||
|
||||
@profiler
|
||||
def create_routes_for_payment(
|
||||
self,
|
||||
@@ -1256,6 +1472,16 @@ class LNWallet(LNWorker):
|
||||
if not routing_hints:
|
||||
self.logger.info("Warning. No routing hints added to invoice. "
|
||||
"Other clients will likely not be able to send to us.")
|
||||
|
||||
# if not all hints are trampoline, do not create trampoline invoice
|
||||
invoice_features = self.features.for_invoice()
|
||||
#
|
||||
trampoline_hints = []
|
||||
for r in routing_hints:
|
||||
node_id, short_channel_id, fee_base_msat, fee_proportional_millionths, cltv_expiry_delta = r[1][0]
|
||||
if len(r[1])== 1 and self.is_trampoline_peer(node_id):
|
||||
trampoline_hints.append(('t', (node_id, fee_base_msat, fee_proportional_millionths, cltv_expiry_delta)))
|
||||
|
||||
payment_preimage = os.urandom(32)
|
||||
payment_hash = sha256(payment_preimage)
|
||||
info = PaymentInfo(payment_hash, amount_msat, RECEIVED, PR_UNPAID)
|
||||
@@ -1267,8 +1493,9 @@ class LNWallet(LNWorker):
|
||||
tags=[('d', message),
|
||||
('c', MIN_FINAL_CLTV_EXPIRY_FOR_INVOICE),
|
||||
('x', expiry),
|
||||
('9', self.features.for_invoice())]
|
||||
+ routing_hints,
|
||||
('9', invoice_features)]
|
||||
+ routing_hints
|
||||
+ trampoline_hints,
|
||||
date=timestamp,
|
||||
payment_secret=derive_payment_secret_from_payment_preimage(payment_preimage))
|
||||
invoice = lnencode(lnaddr, self.node_keypair.privkey)
|
||||
@@ -1531,14 +1758,19 @@ class LNWallet(LNWorker):
|
||||
async def reestablish_peer_for_given_channel(self, chan: Channel) -> None:
|
||||
now = time.time()
|
||||
peer_addresses = []
|
||||
# will try last good address first, from gossip
|
||||
last_good_addr = self.channel_db.get_last_good_address(chan.node_id)
|
||||
if last_good_addr:
|
||||
peer_addresses.append(last_good_addr)
|
||||
# will try addresses for node_id from gossip
|
||||
addrs_from_gossip = self.channel_db.get_node_addresses(chan.node_id) or []
|
||||
for host, port, ts in addrs_from_gossip:
|
||||
peer_addresses.append(LNPeerAddr(host, port, chan.node_id))
|
||||
if not self.channel_db:
|
||||
addr = trampolines_by_id().get(chan.node_id)
|
||||
if addr:
|
||||
peer_addresses.append(addr)
|
||||
else:
|
||||
# will try last good address first, from gossip
|
||||
last_good_addr = self.channel_db.get_last_good_address(chan.node_id)
|
||||
if last_good_addr:
|
||||
peer_addresses.append(last_good_addr)
|
||||
# will try addresses for node_id from gossip
|
||||
addrs_from_gossip = self.channel_db.get_node_addresses(chan.node_id) or []
|
||||
for host, port, ts in addrs_from_gossip:
|
||||
peer_addresses.append(LNPeerAddr(host, port, chan.node_id))
|
||||
# will try addresses stored in channel storage
|
||||
peer_addresses += list(chan.get_peer_addresses())
|
||||
# Done gathering addresses.
|
||||
|
||||
@@ -359,22 +359,25 @@ class Network(Logger, NetworkRetryManager[ServerAddr]):
|
||||
def has_channel_db(self):
|
||||
return self.channel_db is not None
|
||||
|
||||
def init_channel_db(self):
|
||||
if self.channel_db is None:
|
||||
from . import lnrouter
|
||||
from . import channel_db
|
||||
def start_gossip(self):
|
||||
from . import lnrouter
|
||||
from . import channel_db
|
||||
from . import lnworker
|
||||
if not self.config.get('use_gossip'):
|
||||
return
|
||||
if self.lngossip is None:
|
||||
self.channel_db = channel_db.ChannelDB(self)
|
||||
self.path_finder = lnrouter.LNPathFinder(self.channel_db)
|
||||
self.channel_db.load_data()
|
||||
|
||||
def start_gossip(self):
|
||||
if self.lngossip is None:
|
||||
from . import lnworker
|
||||
self.lngossip = lnworker.LNGossip()
|
||||
self.lngossip.start_network(self)
|
||||
|
||||
def stop_gossip(self):
|
||||
self.lngossip.stop()
|
||||
if self.lngossip:
|
||||
self.lngossip.stop()
|
||||
self.lngossip = None
|
||||
self.channel_db.stop()
|
||||
self.channel_db = None
|
||||
|
||||
def run_from_another_thread(self, coro, *, timeout=None):
|
||||
assert self._loop_thread != threading.current_thread(), 'must not be called from network thread'
|
||||
|
||||
@@ -24,6 +24,7 @@ class SqlDB(Logger):
|
||||
def __init__(self, asyncio_loop: asyncio.BaseEventLoop, path, commit_interval=None):
|
||||
Logger.__init__(self)
|
||||
self.asyncio_loop = asyncio_loop
|
||||
self.stopping = False
|
||||
self.path = path
|
||||
test_read_write_permissions(path)
|
||||
self.commit_interval = commit_interval
|
||||
@@ -31,6 +32,9 @@ class SqlDB(Logger):
|
||||
self.sql_thread = threading.Thread(target=self.run_sql)
|
||||
self.sql_thread.start()
|
||||
|
||||
def stop(self):
|
||||
self.stopping = True
|
||||
|
||||
def filesize(self):
|
||||
return os.stat(self.path).st_size
|
||||
|
||||
@@ -40,7 +44,7 @@ class SqlDB(Logger):
|
||||
self.logger.info("Creating database")
|
||||
self.create_database()
|
||||
i = 0
|
||||
while self.asyncio_loop.is_running():
|
||||
while not self.stopping and self.asyncio_loop.is_running():
|
||||
try:
|
||||
future, func, args, kwargs = self.db_requests.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
|
||||
@@ -77,6 +77,7 @@ if [[ $1 == "init" ]]; then
|
||||
agent="./run_electrum --regtest -D /tmp/$2"
|
||||
$agent create --offline > /dev/null
|
||||
$agent setconfig --offline log_to_file True
|
||||
$agent setconfig --offline use_gossip True
|
||||
$agent setconfig --offline server 127.0.0.1:51001:t
|
||||
$agent setconfig --offline lightning_to_self_delay 144
|
||||
# alice is funded, bob is listening
|
||||
|
||||
Reference in New Issue
Block a user