electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

commit ded449233ebf7cd863bc63da9af19c5561813128
parent f4fe80dfd1ed2e1032796582676dcd6a370e4e05
Author: ThomasV <thomasv@electrum.org>
Date:   Wed, 11 Nov 2020 11:03:31 +0100

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

Diffstat:
Melectrum/daemon.py | 3++-
Melectrum/gui/kivy/main_window.py | 9+++++++++
Melectrum/gui/kivy/uix/dialogs/lightning_channels.py | 10++++++++++
Melectrum/gui/kivy/uix/dialogs/lightning_open_channel.py | 6++----
Melectrum/gui/kivy/uix/dialogs/settings.py | 11+++++++++++
Melectrum/gui/qt/channels_list.py | 17++++++++++++++---
Melectrum/gui/qt/main_window.py | 10+++++-----
Melectrum/gui/qt/settings_dialog.py | 18++++++++++++++++++
Melectrum/lnaddr.py | 11+++++++++++
Melectrum/lnchannel.py | 2++
Melectrum/lnonion.py | 83+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Melectrum/lnpeer.py | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Melectrum/lnrouter.py | 10++++++++++
Melectrum/lnutil.py | 10++++++++++
Melectrum/lnwire/onion_wire.csv | 11+++++++++++
Melectrum/lnworker.py | 290+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Melectrum/network.py | 21++++++++++++---------
Melectrum/sql_db.py | 6+++++-
Melectrum/tests/regtest/regtest.sh | 1+
19 files changed, 541 insertions(+), 107 deletions(-)

diff --git a/electrum/daemon.py b/electrum/daemon.py @@ -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) diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py @@ -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 diff --git a/electrum/gui/kivy/uix/dialogs/lightning_channels.py b/electrum/gui/kivy/uix/dialogs/lightning_channels.py @@ -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() diff --git a/electrum/gui/kivy/uix/dialogs/lightning_open_channel.py b/electrum/gui/kivy/uix/dialogs/lightning_open_channel.py @@ -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)." diff --git a/electrum/gui/kivy/uix/dialogs/settings.py b/electrum/gui/kivy/uix/dialogs/settings.py @@ -96,6 +96,17 @@ Builder.load_string(''' 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 description: _("Backup wallet to external storage.") diff --git a/electrum/gui/qt/channels_list.py b/electrum/gui/qt/channels_list.py @@ -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( diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py @@ -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}") diff --git a/electrum/gui/qt/settings_dialog.py b/electrum/gui/qt/settings_dialog.py @@ -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.""") diff --git a/electrum/lnaddr.py b/electrum/lnaddr.py @@ -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: diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py @@ -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: diff --git a/electrum/lnonion.py b/electrum/lnonion.py @@ -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 diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py @@ -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) diff --git a/electrum/lnrouter.py b/electrum/lnrouter.py @@ -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] diff --git a/electrum/lnutil.py b/electrum/lnutil.py @@ -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 ) diff --git a/electrum/lnwire/onion_wire.csv b/electrum/lnwire/onion_wire.csv @@ -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 diff --git a/electrum/lnworker.py b/electrum/lnworker.py @@ -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,11 +248,14 @@ 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.""" with self.lock: @@ -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. diff --git a/electrum/network.py b/electrum/network.py @@ -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' diff --git a/electrum/sql_db.py b/electrum/sql_db.py @@ -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: diff --git a/electrum/tests/regtest/regtest.sh b/electrum/tests/regtest/regtest.sh @@ -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