From 64a931f21e6f6699f192158e54ff8f2883682604 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Tue, 9 Mar 2021 09:55:55 +0100 Subject: [PATCH] Deterministic NodeID: - use_recoverable_channel is a user setting, available only in standard wallets with a 'segwit' seed_type - if enabled, 'lightning_xprv' is derived from seed - otherwise, wallets use the existing 'lightning_privkey2' Recoverable channels: - channel recovery data is added funding tx using an OP_RETURN - recovery data = 4 magic bytes + node id[0:16] - recovery data is chacha20 encrypted using funding_address as nonce. (this will allow to fund multiple channels in the same tx) GUI: - whether channels are recoverable is shown in wallet info dialog. - if the wallet can have recoverable channels but has an old node_id, users are told to close their channels and restore from seed to have that feature. --- electrum/base_wizard.py | 2 + electrum/bitcoin.py | 4 + electrum/channel_db.py | 7 + electrum/commands.py | 3 + electrum/gui/kivy/main.kv | 20 +++ electrum/gui/kivy/main_window.py | 60 ++++++++ .../kivy/uix/dialogs/lightning_channels.py | 2 +- .../uix/dialogs/lightning_open_channel.py | 4 +- electrum/gui/kivy/uix/dialogs/settings.py | 8 ++ electrum/gui/kivy/uix/ui_screens/status.kv | 11 +- electrum/gui/messages.py | 14 ++ electrum/gui/qt/channels_list.py | 9 +- electrum/gui/qt/main_window.py | 60 ++++++-- electrum/gui/qt/settings_dialog.py | 12 ++ electrum/keystore.py | 5 + electrum/lnchannel.py | 36 +++-- electrum/lnpeer.py | 17 +++ electrum/lnutil.py | 29 ++-- electrum/lnworker.py | 129 +++++++++++++++--- electrum/tests/regtest/regtest.sh | 2 - electrum/tests/test_wallet.py | 2 +- electrum/wallet.py | 16 ++- electrum/wallet_db.py | 19 ++- 23 files changed, 395 insertions(+), 76 deletions(-) create mode 100644 electrum/gui/messages.py diff --git a/electrum/base_wizard.py b/electrum/base_wizard.py index ac8f76df4..fdaa96933 100644 --- a/electrum/base_wizard.py +++ b/electrum/base_wizard.py @@ -545,6 +545,8 @@ class BaseWizard(Logger): def create_keystore(self, seed, passphrase): k = keystore.from_seed(seed, passphrase, self.wallet_type == 'multisig') + if self.wallet_type == 'standard' and self.seed_type == 'segwit': + self.data['lightning_xprv'] = k.get_lightning_xprv(None) self.on_keystore(k) def on_bip43(self, seed, passphrase, derivation, script_type): diff --git a/electrum/bitcoin.py b/electrum/bitcoin.py index 68cb14353..bd107a7b5 100644 --- a/electrum/bitcoin.py +++ b/electrum/bitcoin.py @@ -295,6 +295,10 @@ def push_script(data: str) -> str: return _op_push(data_len) + bh2u(data) +def make_op_return(x:bytes) -> bytes: + return bytes([opcodes.OP_RETURN]) + bytes.fromhex(push_script(x.hex())) + + def add_number_to_script(i: int) -> bytes: return bfh(push_script(script_num_to_hex(i))) diff --git a/electrum/channel_db.py b/electrum/channel_db.py index 49c0934bf..dc258953a 100644 --- a/electrum/channel_db.py +++ b/electrum/channel_db.py @@ -867,6 +867,13 @@ class ChannelDB(SqlDB): with self.lock: return self._policies.copy() + def get_node_by_prefix(self, prefix): + with self.lock: + for k in self._addresses.keys(): + if k.startswith(prefix): + return k + raise Exception('node not found') + def to_dict(self) -> dict: """ Generates a graph representation in terms of a dictionary. diff --git a/electrum/commands.py b/electrum/commands.py index 1d002ec89..9346eb958 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -54,6 +54,7 @@ from .address_synchronizer import TX_HEIGHT_LOCAL from .mnemonic import Mnemonic from .lnutil import SENT, RECEIVED from .lnutil import LnFeatures +from .lnutil import extract_nodeid from .lnpeer import channel_id_from_funding_tx from .plugin import run_hook from .version import ELECTRUM_VERSION @@ -997,9 +998,11 @@ class Commands: funding_sat = satoshis(amount) push_sat = satoshis(push_amount) coins = wallet.get_spendable_coins(None) + node_id, rest = extract_nodeid(connection_string) funding_tx = wallet.lnworker.mktx_for_open_channel( coins=coins, funding_sat=funding_sat, + node_id=node_id, fee_est=None) chan, funding_tx = await wallet.lnworker._open_channel_coroutine( connect_str=connection_string, diff --git a/electrum/gui/kivy/main.kv b/electrum/gui/kivy/main.kv index ae611147b..cd5ba1079 100644 --- a/electrum/gui/kivy/main.kv +++ b/electrum/gui/kivy/main.kv @@ -98,6 +98,26 @@ id: lbl2 text: root.value + + text: '' + value: '' + size_hint_y: None + height: max(lbl1.height, lbl2.height) + TopLabel + id: lbl1 + text: root.text + pos_hint: {'top':1} + Button + id: lbl2 + text: root.value + background_color: (0,0,0,0) + bold: True + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + on_release: + root.callback() + address: '' value: '' diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py index 932a4fab7..27af16634 100644 --- a/electrum/gui/kivy/main_window.py +++ b/electrum/gui/kivy/main_window.py @@ -208,6 +208,10 @@ class ElectrumWindow(App, Logger): def on_use_unconfirmed(self, instance, x): self.electrum_config.set_key('confirmed_only', not self.use_unconfirmed, True) + use_recoverable_channels = BooleanProperty(True) + def on_use_recoverable_channels(self, instance, x): + self.electrum_config.set_key('use_recoverable_channels', self.use_recoverable_channels, True) + def switch_to_send_screen(func): # try until send_screen is available def wrapper(self, *args): @@ -1352,3 +1356,59 @@ class ElectrumWindow(App, Logger): self.show_error("failed to import backup" + '\n' + str(e)) return self.lightning_channels_dialog() + + def lightning_status(self): + if self.wallet.has_lightning(): + if self.wallet.lnworker.has_deterministic_node_id(): + status = _('Enabled') + else: + status = _('Enabled, non-recoverable channels') + if self.wallet.db.get('seed_type') == 'segwit': + msg = _("Your channels cannot be recovered from seed, because they were created with an old version of Electrum. " + "This means that you must save a backup of your wallet everytime you create a new channel.\n\n" + "If you want this wallet to have recoverable channels, you must close your existing channels and restore this wallet from seed") + else: + msg = _("Your channels cannot be recovered from seed. " + "This means that you must save a backup of your wallet everytime you create a new channel.\n\n" + "If you want to have recoverable channels, you must create a new wallet with an Electrum seed") + else: + if self.wallet.can_have_lightning(): + status = _('Not enabled') + else: + status = _("Not available for this wallet.") + return status + + def on_lightning_status(self, root): + if self.wallet.has_lightning(): + if self.wallet.lnworker.has_deterministic_node_id(): + pass + else: + if self.wallet.db.get('seed_type') == 'segwit': + msg = _("Your channels cannot be recovered from seed, because they were created with an old version of Electrum. " + "This means that you must save a backup of your wallet everytime you create a new channel.\n\n" + "If you want this wallet to have recoverable channels, you must close your existing channels and restore this wallet from seed") + else: + msg = _("Your channels cannot be recovered from seed. " + "This means that you must save a backup of your wallet everytime you create a new channel.\n\n" + "If you want to have recoverable channels, you must create a new wallet with an Electrum seed") + self.show_info(msg) + else: + if self.wallet.can_have_lightning(): + root.dismiss() + msg = _( + "Warning: this wallet type does not support channel recovery from seed. " + "You will need to backup your wallet everytime you create a new wallet. " + "Create lightning keys?") + d = Question(msg, self._enable_lightning, title=_('Enable Lightning?')) + d.open() + else: + pass + + def _enable_lightning(self, b): + if not b: + return + wallet_path = self.get_wallet_path() + self.wallet.init_lightning() + self.show_info(_('Lightning keys have been initialized.')) + self.stop_wallet() + self.load_wallet_by_name(wallet_path) diff --git a/electrum/gui/kivy/uix/dialogs/lightning_channels.py b/electrum/gui/kivy/uix/dialogs/lightning_channels.py index 35eec6ed9..bd8be068e 100644 --- a/electrum/gui/kivy/uix/dialogs/lightning_channels.py +++ b/electrum/gui/kivy/uix/dialogs/lightning_channels.py @@ -528,7 +528,7 @@ class ChannelDetailsPopup(Popup, Logger): to_self_delay = self.chan.config[REMOTE].to_self_delay help_text = ' '.join([ _('If you force-close this channel, the funds you have in it will not be available for {} blocks.').format(to_self_delay), - _('During that time, funds will not be recoverabe from your seed, and may be lost if you lose your device.'), + _('During that time, funds will not be recoverable from your seed, and may be lost if you lose your device.'), _('To prevent that, please save this channel backup.'), _('It may be imported in another wallet with the same seed.') ]) diff --git a/electrum/gui/kivy/uix/dialogs/lightning_open_channel.py b/electrum/gui/kivy/uix/dialogs/lightning_open_channel.py index 89f2309bf..54b52f21b 100644 --- a/electrum/gui/kivy/uix/dialogs/lightning_open_channel.py +++ b/electrum/gui/kivy/uix/dialogs/lightning_open_channel.py @@ -9,7 +9,7 @@ from electrum.util import bh2u from electrum.bitcoin import COIN import electrum.simple_config as config from electrum.logging import Logger -from electrum.lnutil import ln_dummy_address +from electrum.lnutil import ln_dummy_address, extract_nodeid from .label_dialog import LabelDialog from .confirm_tx_dialog import ConfirmTxDialog @@ -178,9 +178,11 @@ class LightningOpenChannelDialog(Factory.Popup, Logger): self.dismiss() lnworker = self.app.wallet.lnworker coins = self.app.wallet.get_spendable_coins(None, nonlocal_only=True) + node_id, rest = extract_nodeid(conn_str) make_tx = lambda rbf: lnworker.mktx_for_open_channel( coins=coins, funding_sat=amount, + node_id=node_id, fee_est=None) on_pay = lambda tx: self.app.protected('Create a new channel?', self.do_open_channel, (tx, conn_str)) d = ConfirmTxDialog( diff --git a/electrum/gui/kivy/uix/dialogs/settings.py b/electrum/gui/kivy/uix/dialogs/settings.py index fbd5bac85..0cb2430ad 100644 --- a/electrum/gui/kivy/uix/dialogs/settings.py +++ b/electrum/gui/kivy/uix/dialogs/settings.py @@ -16,6 +16,7 @@ from .choice_dialog import ChoiceDialog Builder.load_string(''' #:import partial functools.partial #:import _ electrum.gui.kivy.i18n._ +#:import messages electrum.gui.messages id: settings @@ -80,6 +81,13 @@ 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: _('Yes') if app.use_recoverable_channels else _('No') + title: _('Use recoverable channels') + ': ' + self.status + description: _("Add channel recovery data to funding transaction.") + message: _(messages.MSG_RECOVERABLE_CHANNELS) + action: partial(root.boolean_dialog, 'use_recoverable_channels', _('Use recoverable_channels'), self.message) + CardSeparator SettingsItem: status: _('Trampoline') if not app.use_gossip else _('Gossip') title: _('Lightning Routing') + ': ' + self.status diff --git a/electrum/gui/kivy/uix/ui_screens/status.kv b/electrum/gui/kivy/uix/ui_screens/status.kv index 41f75b7a8..1a7c5652f 100644 --- a/electrum/gui/kivy/uix/ui_screens/status.kv +++ b/electrum/gui/kivy/uix/ui_screens/status.kv @@ -31,22 +31,21 @@ Popup: BoxLabel: text: _("Wallet type:") value: app.wallet.wallet_type - BoxLabel: + BoxButton: text: _("Lightning:") - value: (_('Enabled') if app.wallet.has_lightning() else _('Disabled')) if app.wallet.can_have_lightning() else _('Not available') + value: app.lightning_status() + callback: lambda: app.on_lightning_status(root) BoxLabel: text: _("Balance") + ':' value: app.format_amount_and_units(root.confirmed + root.unconfirmed + root.unmatured + root.lightning) BoxLabel: - text: _("Onchain") + ':' + text: ' - ' + _("Onchain") + ':' value: app.format_amount_and_units(root.confirmed + root.unconfirmed + root.unmatured) opacity: 1 if root.lightning else 0 BoxLabel: - text: _("Lightning") + ':' + text: ' - ' + _("Lightning") + ':' opacity: 1 if root.lightning else 0 value: app.format_amount_and_units(root.lightning) - - GridLayout: cols: 1 height: self.minimum_height diff --git a/electrum/gui/messages.py b/electrum/gui/messages.py new file mode 100644 index 000000000..6b28dc6e2 --- /dev/null +++ b/electrum/gui/messages.py @@ -0,0 +1,14 @@ +# note: qt and kivy use different i18n methods + +MSG_RECOVERABLE_CHANNELS = """ +Add extra data to your channel funding transactions, so that a static backup can be +recovered from your seed. + +Note that static backups only allow you to request a force-close with the remote node. +This assumes that the remote node is still online, did not lose its data, and accepts +to force close the channel. + +If this is enabled, other nodes cannot open a channel to you. Channel recovery data +is encrypted, so that only your wallet can decrypt it. However, blockchain analysis +will be able to tell that the transaction was probably created by Electrum. +""" diff --git a/electrum/gui/qt/channels_list.py b/electrum/gui/qt/channels_list.py index 9c8ec41e7..9cc2f2d0d 100644 --- a/electrum/gui/qt/channels_list.py +++ b/electrum/gui/qt/channels_list.py @@ -345,13 +345,14 @@ class ChannelsList(MyTreeView): def new_channel_with_warning(self): if not self.parent.wallet.lnworker.channels: - warning1 = _("Lightning support in Electrum is experimental. " + warning = _("Lightning support in Electrum is experimental. " "Do not put large amounts in lightning channels.") - warning2 = _("Funds stored in lightning channels are not recoverable from your seed. " - "You must backup your wallet file everytime you create a new channel.") + if not self.parent.wallet.lnworker.has_recoverable_channels(): + warning += _("Funds stored in lightning channels are not recoverable from your seed. " + "You must backup your wallet file everytime you create a new channel.") answer = self.parent.question( _('Do you want to create your first channel?') + '\n\n' + - _('WARNINGS') + ': ' + '\n\n' + warning1 + '\n\n' + warning2) + _('WARNING') + ': ' + '\n\n' + warning) if answer: self.new_channel_dialog() else: diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 1450ec091..90cef6877 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -1796,23 +1796,24 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): WaitingDialog(self, _('Broadcasting transaction...'), broadcast_thread, broadcast_done, self.on_error) - def mktx_for_open_channel(self, funding_sat): + def mktx_for_open_channel(self, funding_sat, node_id): coins = self.get_coins(nonlocal_only=True) make_tx = lambda fee_est: self.wallet.lnworker.mktx_for_open_channel( coins=coins, funding_sat=funding_sat, + node_id=node_id, fee_est=fee_est) return make_tx def open_channel(self, connect_str, funding_sat, push_amt): try: - extract_nodeid(connect_str) + node_id, rest = extract_nodeid(connect_str) except ConnStringFormatError as e: self.show_error(str(e)) return # use ConfirmTxDialog # we need to know the fee before we broadcast, because the txid is required - make_tx = self.mktx_for_open_channel(funding_sat) + make_tx = self.mktx_for_open_channel(funding_sat, node_id) d = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=funding_sat, is_sweep=False) # disable preview button because the user must not broadcast tx before establishment_flow d.preview_button.setEnabled(False) @@ -2365,9 +2366,17 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): if d.exec_(): self.set_contact(line2.text(), line1.text()) + def init_lightning_dialog(self): + if self.question(_( + "Warning: this wallet type does not support channel recovery from seed. " + "You will need to backup your wallet everytime you create a new wallet. " + "Create lightning keys?")): + self.wallet.init_lightning() + self.show_message("Lightning keys created. Please restart Electrum") + def show_wallet_info(self): dialog = WindowModalDialog(self, _("Wallet Information")) - dialog.setMinimumSize(500, 100) + dialog.setMinimumSize(800, 100) vbox = QVBoxLayout() wallet_type = self.wallet.db.get('wallet_type', '') if self.wallet.is_watching_only(): @@ -2390,15 +2399,42 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): grid.addWidget(QLabel(ks_type), 4, 1) # lightning grid.addWidget(QLabel(_('Lightning') + ':'), 5, 0) - if self.wallet.can_have_lightning(): - grid.addWidget(QLabel(_('Enabled')), 5, 1) - local_nodeid = QLabel(bh2u(self.wallet.lnworker.node_keypair.pubkey)) - local_nodeid.setTextInteractionFlags(Qt.TextSelectableByMouse) - grid.addWidget(QLabel(_('Lightning Node ID:')), 6, 0) - grid.addWidget(local_nodeid, 6, 1, 1, 3) + from .util import IconLabel + if self.wallet.has_lightning(): + if self.wallet.lnworker.has_deterministic_node_id(): + grid.addWidget(QLabel(_('Enabled')), 5, 1) + else: + label = IconLabel(text='Enabled, non-recoverable channels') + label.setIcon(read_QIcon('warning.png')) + grid.addWidget(label, 5, 1) + if self.wallet.db.get('seed_type') == 'segwit': + msg = _("Your channels cannot be recovered from seed, because they were created with an old version of Electrum. " + "This means that you must save a backup of your wallet everytime you create a new channel.\n\n" + "If you want this wallet to have recoverable channels, you must close your existing channels and restore this wallet from seed") + else: + msg = _("Your channels cannot be recovered from seed. " + "This means that you must save a backup of your wallet everytime you create a new channel.\n\n" + "If you want to have recoverable channels, you must create a new wallet with an Electrum seed") + grid.addWidget(HelpButton(msg), 5, 3) + grid.addWidget(QLabel(_('Lightning Node ID:')), 7, 0) + # TODO: ButtonsLineEdit should have a addQrButton method + nodeid_text = self.wallet.lnworker.node_keypair.pubkey.hex() + nodeid_e = ButtonsLineEdit(nodeid_text) + qr_icon = "qrcode_white.png" if ColorScheme.dark_scheme else "qrcode.png" + nodeid_e.addButton(qr_icon, lambda: self.show_qrcode(nodeid_text, _("Node ID")), _("Show QR Code")) + nodeid_e.addCopyButton(self.app) + nodeid_e.setReadOnly(True) + nodeid_e.setFont(QFont(MONOSPACE_FONT)) + grid.addWidget(nodeid_e, 8, 0, 1, 4) else: - grid.addWidget(QLabel(_("Not available for this wallet.")), 5, 1) - grid.addWidget(HelpButton(_("Lightning is currently restricted to HD wallets with p2wpkh addresses.")), 5, 2) + if self.wallet.can_have_lightning(): + grid.addWidget(QLabel('Not enabled'), 5, 1) + button = QPushButton(_("Enable")) + button.pressed.connect(self.init_lightning_dialog) + grid.addWidget(button, 5, 3) + else: + grid.addWidget(QLabel(_("Not available for this wallet.")), 5, 1) + grid.addWidget(HelpButton(_("Lightning is currently restricted to HD wallets with p2wpkh addresses.")), 5, 2) vbox.addLayout(grid) labels_clayout = None diff --git a/electrum/gui/qt/settings_dialog.py b/electrum/gui/qt/settings_dialog.py index eb6ce9aa0..5eb05473f 100644 --- a/electrum/gui/qt/settings_dialog.py +++ b/electrum/gui/qt/settings_dialog.py @@ -41,6 +41,7 @@ from .util import (ColorScheme, WindowModalDialog, HelpLabel, Buttons, from electrum.i18n import languages from electrum import qrscanner +from electrum.gui import messages if TYPE_CHECKING: from electrum.simple_config import SimpleConfig @@ -130,6 +131,17 @@ class SettingsDialog(WindowModalDialog): # lightning lightning_widgets = [] + if self.wallet.lnworker and self.wallet.lnworker.has_deterministic_node_id(): + help_recov = _(messages.MSG_RECOVERABLE_CHANNELS) + recov_cb = QCheckBox(_("Create recoverable channels")) + recov_cb.setToolTip(help_recov) + recov_cb.setChecked(bool(self.config.get('use_recoverable_channels', True))) + def on_recov_checked(x): + self.config.set_key('use_recoverable_channels', bool(x)) + recov_cb.stateChanged.connect(on_recov_checked) + recov_cb.setEnabled(not bool(self.config.get('lightning_listen'))) + lightning_widgets.append((recov_cb, None)) + 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")) diff --git a/electrum/keystore.py b/electrum/keystore.py index a29f93461..356acab0a 100644 --- a/electrum/keystore.py +++ b/electrum/keystore.py @@ -613,6 +613,11 @@ class BIP32_KeyStore(Xpub, Deterministic_KeyStore): cK = ecc.ECPrivkey(k).get_public_key_bytes() return cK, k + def get_lightning_xprv(self, password): + xprv = self.get_master_private_key(password) + rootnode = BIP32Node.from_xkey(xprv) + node = rootnode.subkey_at_private_derivation("m/67'/") + return node.to_xprv() class Old_KeyStore(MasterPublicKeyMixin, Deterministic_KeyStore): diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py index 3fcd13ddc..3a301b9d0 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -59,7 +59,7 @@ from .lnhtlc import HTLCManager from .lnmsg import encode_msg, decode_msg from .address_synchronizer import TX_HEIGHT_LOCAL from .lnutil import CHANNEL_OPENING_TIMEOUT -from .lnutil import ChannelBackupStorage +from .lnutil import ChannelBackupStorage, ImportedChannelBackupStorage from .lnutil import format_short_channel_id if TYPE_CHECKING: @@ -301,9 +301,15 @@ class AbstractChannel(Logger, ABC): if conf > 0: self.set_state(ChannelState.CLOSED) else: - # we must not trust the server with unconfirmed transactions - # if the remote force closed, we remain OPEN until the closing tx is confirmed - pass + if not self.is_backup(): + # we must not trust the server with unconfirmed transactions, + # because the state transition is irreversible. if the remote + # force closed, we remain OPEN until the closing tx is confirmed + pass + else: + # for a backup, that state change will only affect the GUI + self.set_state(ChannelState.FORCE_CLOSING) + if self.get_state() == ChannelState.CLOSED and not keep_watching: self.set_state(ChannelState.REDEEMED) @@ -400,11 +406,21 @@ class ChannelBackup(AbstractChannel): self.name = None Logger.__init__(self) self.cb = cb + self.is_imported = isinstance(self.cb, ImportedChannelBackupStorage) self._sweep_info = {} self._fallback_sweep_address = sweep_address self.storage = {} # dummy storage self._state = ChannelState.OPENING + self.node_id = cb.node_id if self.is_imported else cb.node_id_prefix + self.channel_id = cb.channel_id() + self.funding_outpoint = cb.funding_outpoint() + self.lnworker = lnworker + self.short_channel_id = None self.config = {} + if self.is_imported: + self.init_config(cb) + + def init_config(self, cb): self.config[LOCAL] = LocalConfig.from_seed( channel_seed=cb.channel_seed, to_self_delay=cb.local_delay, @@ -440,11 +456,6 @@ class ChannelBackup(AbstractChannel): next_per_commitment_point=None, current_per_commitment_point=None, upfront_shutdown_script='') - self.node_id = cb.node_id - self.channel_id = cb.channel_id() - self.funding_outpoint = cb.funding_outpoint() - self.lnworker = lnworker - self.short_channel_id = None def get_capacity(self): return self.lnworker.lnwatcher.get_tx_delta(self.funding_outpoint.txid, self.cb.funding_address) @@ -455,6 +466,13 @@ class ChannelBackup(AbstractChannel): def create_sweeptxs_for_their_ctx(self, ctx): return {} + def create_sweeptxs_for_our_ctx(self, ctx): + if self.is_imported: + return create_sweeptxs_for_our_ctx(chan=self, ctx=ctx, sweep_address=self.sweep_address) + else: + # backup from op_return + return {} + def get_funding_address(self): return self.cb.funding_address diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 94c3b282b..f9a3be142 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -24,6 +24,7 @@ from . import constants from .util import (bh2u, bfh, log_exceptions, ignore_exceptions, chunks, SilentTaskGroup, UnrelatedTransactionException) from . import transaction +from .bitcoin import make_op_return from .transaction import PartialTxOutput, match_script_against_template from .logging import Logger from .lnonion import (new_onion_packet, OnionFailureCode, calc_hops_data_for_payment, @@ -681,6 +682,19 @@ class Peer(Logger): funding_tx._outputs.remove(dummy_output) if dummy_output in funding_tx.outputs(): raise Exception("LN dummy output (err 2)") funding_tx.add_outputs([funding_output]) + # find and encrypt op_return data associated to funding_address + if self.lnworker and self.lnworker.has_recoverable_channels(): + backup_data = self.lnworker.cb_data(self.pubkey) + dummy_scriptpubkey = make_op_return(backup_data) + for o in funding_tx.outputs(): + if o.scriptpubkey == dummy_scriptpubkey: + encrypted_data = self.lnworker.encrypt_cb_data(backup_data, funding_address) + assert len(encrypted_data) == len(backup_data) + o.scriptpubkey = make_op_return(encrypted_data) + break + else: + raise Exception('op_return output not found in funding tx') + # must not be malleable funding_tx.set_rbf(False) if not funding_tx.is_segwit(): raise Exception('Funding transaction is not segwit') @@ -755,6 +769,9 @@ class Peer(Logger): Channel configurations are initialized in this method. """ + if self.lnworker.has_recoverable_channels(): + # FIXME: we might want to keep the connection open + raise Exception('not accepting channels') # <- open_channel if payload['chain_hash'] != constants.net.rev_genesis_bytes(): raise Exception('wrong chain_hash') diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 8263c9101..b3885075f 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -169,21 +169,13 @@ class ChannelConstraints(StoredObject): CHANNEL_BACKUP_VERSION = 0 + @attr.s class ChannelBackupStorage(StoredObject): - node_id = attr.ib(type=bytes, converter=hex_to_bytes) - privkey = attr.ib(type=bytes, converter=hex_to_bytes) funding_txid = attr.ib(type=str) funding_index = attr.ib(type=int, converter=int) funding_address = attr.ib(type=str) - host = attr.ib(type=str) - port = attr.ib(type=int, converter=int) is_initiator = attr.ib(type=bool) - channel_seed = attr.ib(type=bytes, converter=hex_to_bytes) - local_delay = attr.ib(type=int, converter=int) - remote_delay = attr.ib(type=int, converter=int) - remote_payment_pubkey = attr.ib(type=bytes, converter=hex_to_bytes) - remote_revocation_pubkey = attr.ib(type=bytes, converter=hex_to_bytes) def funding_outpoint(self): return Outpoint(self.funding_txid, self.funding_index) @@ -192,6 +184,22 @@ class ChannelBackupStorage(StoredObject): chan_id, _ = channel_id_from_funding_tx(self.funding_txid, self.funding_index) return chan_id +@attr.s +class OnchainChannelBackupStorage(ChannelBackupStorage): + node_id_prefix = attr.ib(type=bytes, converter=hex_to_bytes) + +@attr.s +class ImportedChannelBackupStorage(ChannelBackupStorage): + node_id = attr.ib(type=bytes, converter=hex_to_bytes) + privkey = attr.ib(type=bytes, converter=hex_to_bytes) + host = attr.ib(type=str) + port = attr.ib(type=int, converter=int) + channel_seed = attr.ib(type=bytes, converter=hex_to_bytes) + local_delay = attr.ib(type=int, converter=int) + remote_delay = attr.ib(type=int, converter=int) + remote_payment_pubkey = attr.ib(type=bytes, converter=hex_to_bytes) + remote_revocation_pubkey = attr.ib(type=bytes, converter=hex_to_bytes) + def to_bytes(self) -> bytes: vds = BCDataStream() vds.write_int16(CHANNEL_BACKUP_VERSION) @@ -217,7 +225,7 @@ class ChannelBackupStorage(StoredObject): version = vds.read_int16() if version != CHANNEL_BACKUP_VERSION: raise Exception(f"unknown version for channel backup: {version}") - return ChannelBackupStorage( + return ImportedChannelBackupStorage( is_initiator = vds.read_boolean(), privkey = vds.read_bytes(32).hex(), channel_seed = vds.read_bytes(32).hex(), @@ -1245,6 +1253,7 @@ class LnKeyFamily(IntEnum): DELAY_BASE = 4 | BIP32_PRIME REVOCATION_ROOT = 5 | BIP32_PRIME NODE_KEY = 6 + BACKUP_CIPHER = 7 | BIP32_PRIME def generate_keypair(node: BIP32Node, key_family: LnKeyFamily) -> Keypair: diff --git a/electrum/lnworker.py b/electrum/lnworker.py index a7c58c5b1..d91c7c943 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -32,10 +32,13 @@ from .util import NetworkRetryManager, JsonRPCClient from .lnutil import LN_MAX_FUNDING_SAT from .keystore import BIP32_KeyStore from .bitcoin import COIN +from .bitcoin import opcodes, make_op_return, address_to_script from .transaction import Transaction +from .transaction import get_script_type_from_output_script from .crypto import sha256 from .bip32 import BIP32Node from .util import bh2u, bfh, InvoiceError, resolve_dns_srv, is_ip_address, log_exceptions +from .crypto import chacha20_encrypt, chacha20_decrypt from .util import ignore_exceptions, make_aiohttp_session, SilentTaskGroup from .util import timestamp_to_datetime, random_shuffled_copy from .util import MyEncoder, is_private_netaddress @@ -71,7 +74,7 @@ from .address_synchronizer import TX_HEIGHT_LOCAL from . import lnsweep from .lnwatcher import LNWalletWatcher from .crypto import pw_encode_with_version_and_mac, pw_decode_with_version_and_mac -from .lnutil import ChannelBackupStorage +from .lnutil import ImportedChannelBackupStorage, OnchainChannelBackupStorage from .lnchannel import ChannelBackup from .channel_db import UpdateStatus from .channel_db import get_mychannel_info, get_mychannel_policy @@ -92,6 +95,10 @@ SAVED_PR_STATUS = [PR_PAID, PR_UNPAID] # status that are persisted NUM_PEERS_TARGET = 4 +# onchain channel backup data +CB_VERSION = 0 +CB_MAGIC_BYTES = bytes([0, 0, 0, CB_VERSION]) + FALLBACK_NODE_LIST_TESTNET = ( LNPeerAddr(host='203.132.95.10', port=9735, pubkey=bfh('038863cf8ab91046230f561cd5b386cbff8309fa02e3f0c3ed161a3aeb64a643b9')), @@ -189,6 +196,7 @@ class LNWorker(Logger, NetworkRetryManager[LNPeerAddr]): ) self.lock = threading.RLock() self.node_keypair = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.NODE_KEY) + self.backup_key = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.BACKUP_CIPHER).privkey self._peers = {} # type: Dict[bytes, Peer] # pubkey -> Peer # needs self.lock self.taskgroup = SilentTaskGroup() self.listen_server = None # type: Optional[asyncio.AbstractServer] @@ -612,9 +620,11 @@ class LNWallet(LNWorker): self._channels[bfh(channel_id)] = Channel(c, sweep_address=self.sweep_address, lnworker=self) self._channel_backups = {} # type: Dict[bytes, ChannelBackup] - channel_backups = self.db.get_dict("channel_backups") - for channel_id, cb in random_shuffled_copy(channel_backups.items()): - self._channel_backups[bfh(channel_id)] = ChannelBackup(cb, sweep_address=self.sweep_address, lnworker=self) + # order is important: imported should overwrite onchain + for name in ["onchain_channel_backups", "imported_channel_backups"]: + channel_backups = self.db.get_dict(name) + for channel_id, storage in channel_backups.items(): + self._channel_backups[bfh(channel_id)] = ChannelBackup(storage, sweep_address=self.sweep_address, lnworker=self) self.sent_htlcs = defaultdict(asyncio.Queue) # type: Dict[bytes, asyncio.Queue[HtlcLog]] self.sent_htlcs_routes = dict() # (RHASH, scid, htlc_id) -> route, payment_secret, amount_msat, bucket_msat @@ -629,6 +639,15 @@ class LNWallet(LNWorker): self.trampoline_forwarding_failures = {} # todo: should be persisted + def has_deterministic_node_id(self): + return bool(self.db.get('lightning_xprv')) + + def has_recoverable_channels(self): + # TODO: expose use_recoverable_channels in preferences + return self.has_deterministic_node_id() \ + and self.config.get('use_recoverable_channels', True) \ + and not (self.config.get('lightning_listen')) + @property def channels(self) -> Mapping[bytes, Channel]: """Returns a read-only copy of channels.""" @@ -990,13 +1009,29 @@ class LNWallet(LNWorker): self.remove_channel(chan.channel_id) raise + def cb_data(self, node_id): + return CB_MAGIC_BYTES + node_id[0:16] + + def decrypt_cb_data(self, encrypted_data, funding_address): + funding_scriptpubkey = bytes.fromhex(address_to_script(funding_address)) + nonce = funding_scriptpubkey[0:12] + return chacha20_decrypt(key=self.backup_key, data=encrypted_data, nonce=nonce) + + def encrypt_cb_data(self, data, funding_address): + funding_scriptpubkey = bytes.fromhex(address_to_script(funding_address)) + nonce = funding_scriptpubkey[0:12] + return chacha20_encrypt(key=self.backup_key, data=data, nonce=nonce) + def mktx_for_open_channel( self, *, coins: Sequence[PartialTxInput], funding_sat: int, + node_id: bytes, fee_est=None) -> PartialTransaction: - dummy_address = ln_dummy_address() - outputs = [PartialTxOutput.from_address_and_value(dummy_address, funding_sat)] + outputs = [PartialTxOutput.from_address_and_value(ln_dummy_address(), funding_sat)] + if self.has_recoverable_channels(): + dummy_scriptpubkey = make_op_return(self.cb_data(node_id)) + outputs.append(PartialTxOutput(scriptpubkey=dummy_scriptpubkey, value=0)) tx = self.wallet.make_unsigned_transaction( coins=coins, outputs=outputs, @@ -1986,7 +2021,7 @@ class LNWallet(LNWorker): assert chan.is_static_remotekey_enabled() peer_addresses = list(chan.get_peer_addresses()) peer_addr = peer_addresses[0] - return ChannelBackupStorage( + return ImportedChannelBackupStorage( node_id = chan.node_id, privkey = self.node_keypair.privkey, funding_txid = chan.funding_outpoint.txid, @@ -2004,7 +2039,7 @@ class LNWallet(LNWorker): def export_channel_backup(self, channel_id): xpub = self.wallet.get_fingerprint() backup_bytes = self.create_channel_backup(channel_id).to_bytes() - assert backup_bytes == ChannelBackupStorage.from_bytes(backup_bytes).to_bytes(), "roundtrip failed" + assert backup_bytes == ImportedChannelBackupStorage.from_bytes(backup_bytes).to_bytes(), "roundtrip failed" encrypted = pw_encode_with_version_and_mac(backup_bytes, xpub) assert backup_bytes == pw_decode_with_version_and_mac(encrypted, xpub), "encrypt failed" return 'channel_backup:' + encrypted @@ -2030,22 +2065,22 @@ class LNWallet(LNWorker): encrypted = data[15:] xpub = self.wallet.get_fingerprint() decrypted = pw_decode_with_version_and_mac(encrypted, xpub) - cb_storage = ChannelBackupStorage.from_bytes(decrypted) + cb_storage = ImportedChannelBackupStorage.from_bytes(decrypted) channel_id = cb_storage.channel_id() if channel_id.hex() in self.db.get_dict("channels"): raise Exception('Channel already in wallet') self.logger.info(f'importing channel backup: {channel_id.hex()}') - cb = ChannelBackup(cb_storage, sweep_address=self.sweep_address, lnworker=self) - d = self.db.get_dict("channel_backups") + d = self.db.get_dict("imported_channel_backups") d[channel_id.hex()] = cb_storage with self.lock: + cb = ChannelBackup(cb_storage, sweep_address=self.sweep_address, lnworker=self) self._channel_backups[channel_id] = cb self.wallet.save_db() util.trigger_callback('channels_updated', self.wallet) self.lnwatcher.add_channel(cb.funding_outpoint.to_str(), cb.get_funding_address()) def remove_channel_backup(self, channel_id): - d = self.db.get_dict("channel_backups") + d = self.db.get_dict("imported_channel_backups") if channel_id.hex() not in d: raise Exception('Channel not found') with self.lock: @@ -2061,11 +2096,65 @@ class LNWallet(LNWorker): raise Exception(f'channel backup not found {self.channel_backups}') cb = cb.cb # storage self.logger.info(f'requesting channel force close: {channel_id.hex()}') - # TODO also try network addresses from gossip db (as it might have changed) - peer_addr = LNPeerAddr(cb.host, cb.port, cb.node_id) - transport = LNTransport(cb.privkey, peer_addr, proxy=self.network.proxy) - peer = Peer(self, cb.node_id, transport, is_channel_backup=True) - async with TaskGroup(wait=any) as group: - await group.spawn(peer._message_loop()) - await group.spawn(peer.trigger_force_close(channel_id)) - return True + if isinstance(cb, ImportedChannelBackupStorage): + node_id = cb.node_id + addresses = [(cb.host, cb.port, 0)] + # TODO also try network addresses from gossip db (as it might have changed) + else: + assert isinstance(cb, OnchainChannelBackupStorage) + if not self.channel_db: + raise Exception('Enable gossip first') + node_id = self.network.channel_db.get_node_by_prefix(cb.node_id_prefix) + addresses = self.network.channel_db.get_node_addresses(node_id) + if not addresses: + raise Exception('Peer not found in gossip database') + for host, port, timestamp in addresses: + peer_addr = LNPeerAddr(host, port, node_id) + transport = LNTransport(self.node_keypair.privkey, peer_addr, proxy=self.network.proxy) + peer = Peer(self, node_id, transport, is_channel_backup=True) + try: + async with TaskGroup(wait=any) as group: + await group.spawn(peer._message_loop()) + await group.spawn(peer.trigger_force_close(channel_id)) + return + except Exception as e: + self.logger.info(f'failed to connect {host} {e}') + continue + else: + raise Exception('failed to connect') + + def maybe_add_backup_from_tx(self, tx): + funding_address = None + node_id_prefix = None + for i, o in enumerate(tx.outputs()): + script_type = get_script_type_from_output_script(o.scriptpubkey) + if script_type == 'p2wsh': + funding_index = i + funding_address = o.address + for o2 in tx.outputs(): + if o2.scriptpubkey.startswith(bytes([opcodes.OP_RETURN])): + encrypted_data = o2.scriptpubkey[2:] + data = self.decrypt_cb_data(encrypted_data, funding_address) + if data.startswith(CB_MAGIC_BYTES): + node_id_prefix = data[4:] + if node_id_prefix is None: + return + funding_txid = tx.txid() + cb_storage = OnchainChannelBackupStorage( + node_id_prefix = node_id_prefix, + funding_txid = funding_txid, + funding_index = funding_index, + funding_address = funding_address, + is_initiator = True) + channel_id = cb_storage.channel_id().hex() + if channel_id in self.db.get_dict("channels"): + return + self.logger.info(f"adding backup from tx") + d = self.db.get_dict("onchain_channel_backups") + d[channel_id] = cb_storage + cb = ChannelBackup(cb_storage, sweep_address=self.sweep_address, lnworker=self) + self.wallet.save_db() + with self.lock: + self._channel_backups[bfh(channel_id)] = cb + util.trigger_callback('channels_updated', self.wallet) + self.lnwatcher.add_channel(cb.funding_outpoint.to_str(), cb.get_funding_address()) diff --git a/electrum/tests/regtest/regtest.sh b/electrum/tests/regtest/regtest.sh index 2af4f348a..471948eaf 100755 --- a/electrum/tests/regtest/regtest.sh +++ b/electrum/tests/regtest/regtest.sh @@ -141,7 +141,6 @@ if [[ $1 == "backup" ]]; then echo "channel point: $channel" new_blocks 3 wait_until_channel_open alice - backup=$($alice export_channel_backup $channel) request=$($bob add_lightning_request 0.01 -m "blah" | jq -r ".invoice") echo "alice pays" $alice lnpay $request @@ -151,7 +150,6 @@ if [[ $1 == "backup" ]]; then $alice -o restore "$seed" $alice daemon -d $alice load_wallet - $alice import_channel_backup $backup $alice request_force_close $channel wait_for_balance alice 0.989 fi diff --git a/electrum/tests/test_wallet.py b/electrum/tests/test_wallet.py index 916522e35..74c3bca57 100644 --- a/electrum/tests/test_wallet.py +++ b/electrum/tests/test_wallet.py @@ -167,7 +167,7 @@ class TestCreateRestoreWallet(WalletTestCase): wallet = d['wallet'] # type: Standard_Wallet # lightning initialization - self.assertTrue(wallet.db.get('lightning_privkey2').startswith('xprv')) + self.assertTrue(wallet.db.get('lightning_xprv').startswith('zprv')) wallet.check_password(password) self.assertEqual(passphrase, wallet.keystore.get_passphrase(password)) diff --git a/electrum/wallet.py b/electrum/wallet.py index 6061c2567..575cf0e80 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -326,6 +326,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC): for chan_id, chan in self.lnworker.channels.items(): channel_backups[chan_id.hex()] = self.lnworker.create_channel_backup(chan_id) new_db.put('channels', None) + new_db.put('lightning_xprv', None) new_db.put('lightning_privkey2', None) new_path = os.path.join(backup_dir, self.basename() + '.backup') @@ -345,6 +346,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC): def init_lightning(self): assert self.can_have_lightning() + assert self.db.get('lightning_xprv') is None if self.db.get('lightning_privkey2'): return # TODO derive this deterministically from wallet.keystore at keystore generation time @@ -887,9 +889,10 @@ class Abstract_Wallet(AddressSynchronizer, ABC): def add_transaction(self, tx, *, allow_unrelated=False): tx_was_added = super().add_transaction(tx, allow_unrelated=allow_unrelated) - if tx_was_added: self._maybe_set_tx_label_based_on_invoices(tx) + if self.lnworker: + self.lnworker.maybe_add_backup_from_tx(tx) return tx_was_added @profiler @@ -2758,11 +2761,8 @@ class Deterministic_Wallet(Abstract_Wallet): # generate addresses now. note that without libsecp this might block # for a few seconds! self.synchronize() - - # create lightning keys - if self.can_have_lightning(): - self.init_lightning() - ln_xprv = self.db.get('lightning_privkey2') + # lightning_privkey2 is not deterministic (legacy wallets, bip39) + ln_xprv = self.db.get('lightning_xprv') or self.db.get('lightning_privkey2') # lnworker can only be initialized once receiving addresses are available # therefore we instantiate lnworker in DeterministicWallet self.lnworker = LNWallet(self, ln_xprv) if ln_xprv else None @@ -3143,6 +3143,8 @@ def create_new_wallet(*, path, config: SimpleConfig, passphrase=None, password=N k = keystore.from_seed(seed, passphrase) db.put('keystore', k.dump()) db.put('wallet_type', 'standard') + if keystore.seed_type(seed) == 'segwit': + db.put('lightning_xprv', k.get_lightning_xprv(None)) if gap_limit is not None: db.put('gap_limit', gap_limit) wallet = Wallet(db, storage, config=config) @@ -3185,6 +3187,8 @@ def restore_wallet_from_text(text, *, path, config: SimpleConfig, k = keystore.from_master_key(text) elif keystore.is_seed(text): k = keystore.from_seed(text, passphrase) + if keystore.seed_type(text) == 'segwit': + db.put('lightning_xprv', k.get_lightning_xprv(None)) else: raise Exception("Seed or key not recognized") db.put('keystore', k.dump()) diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py index 73443814d..9528815d7 100644 --- a/electrum/wallet_db.py +++ b/electrum/wallet_db.py @@ -37,7 +37,8 @@ from .invoices import PR_TYPE_ONCHAIN, Invoice from .keystore import bip44_derivation from .transaction import Transaction, TxOutpoint, tx_from_any, PartialTransaction, PartialTxOutput from .logging import Logger -from .lnutil import LOCAL, REMOTE, FeeUpdate, UpdateAddHtlc, LocalConfig, RemoteConfig, Keypair, OnlyPubkeyKeypair, RevocationStore, ChannelBackupStorage +from .lnutil import LOCAL, REMOTE, FeeUpdate, UpdateAddHtlc, LocalConfig, RemoteConfig, Keypair, OnlyPubkeyKeypair, RevocationStore +from .lnutil import ImportedChannelBackupStorage, OnchainChannelBackupStorage from .lnutil import ChannelConstraints, Outpoint, ShachainElement from .json_db import StoredDict, JsonDB, locked, modifier from .plugin import run_hook, plugin_loaders @@ -52,7 +53,7 @@ if TYPE_CHECKING: OLD_SEED_VERSION = 4 # electrum versions < 2.0 NEW_SEED_VERSION = 11 # electrum versions >= 2.0 -FINAL_SEED_VERSION = 38 # electrum >= 2.7 will set this to prevent +FINAL_SEED_VERSION = 39 # electrum >= 2.7 will set this to prevent # old versions from overwriting new format @@ -186,6 +187,7 @@ class WalletDB(JsonDB): self._convert_version_36() self._convert_version_37() self._convert_version_38() + self._convert_version_39() self.put('seed_version', FINAL_SEED_VERSION) # just to be sure self._after_upgrade_tasks() @@ -778,6 +780,13 @@ class WalletDB(JsonDB): del d[key] self.data['seed_version'] = 38 + def _convert_version_39(self): + # this upgrade prevents initialization of lightning_privkey2 after lightning_xprv has been set + if not self._is_upgrade_method_needed(38, 38): + return + self.data['imported_channel_backups'] = self.data.pop('channel_backups', {}) + self.data['seed_version'] = 39 + def _convert_imported(self): if not self._is_upgrade_method_needed(0, 13): return @@ -1273,8 +1282,10 @@ class WalletDB(JsonDB): v = dict((k, FeeUpdate(**x)) for k, x in v.items()) elif key == 'submarine_swaps': v = dict((k, SwapData(**x)) for k, x in v.items()) - elif key == 'channel_backups': - v = dict((k, ChannelBackupStorage(**x)) for k, x in v.items()) + elif key == 'imported_channel_backups': + v = dict((k, ImportedChannelBackupStorage(**x)) for k, x in v.items()) + elif key == 'onchain_channel_backups': + v = dict((k, OnchainChannelBackupStorage(**x)) for k, x in v.items()) elif key == 'tx_fees': v = dict((k, TxFeesValue(*x)) for k, x in v.items()) elif key == 'prevouts_by_scripthash':