electrum

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

commit 8f41aeb783048ca01a2474d738ece1ee2ade28fb
parent e5b1596b69a1c77b3d8d2b235b65e624a2383921
Author: ThomasV <thomasv@electrum.org>
Date:   Fri, 13 Mar 2020 11:44:29 +0100

Replace wallet backup with channel backups
 - channels can be backed up individually
 - backups are added to lnwatcher
 - AbstractChannel ancestor class

Diffstat:
Melectrum/commands.py | 10++++++++++
Melectrum/gui/kivy/main_window.py | 20+++++++++++++++++---
Melectrum/gui/kivy/uix/dialogs/lightning_channels.py | 126++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Melectrum/gui/qt/__init__.py | 1-
Melectrum/gui/qt/channels_list.py | 51++++++++++++++++++++++++++++++++++++---------------
Melectrum/gui/qt/main_window.py | 59++++++++++++++++++++++++++++++++++++++++++++---------------
Melectrum/gui/qt/settings_dialog.py | 14--------------
Melectrum/lnchannel.py | 402++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Melectrum/lnpeer.py | 26++++++++++++++------------
Melectrum/lnsweep.py | 2+-
Melectrum/lnutil.py | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Melectrum/lnwatcher.py | 1+
Melectrum/lnworker.py | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Melectrum/transaction.py | 4++++
Melectrum/wallet.py | 23+++++++++++++++--------
Melectrum/wallet_db.py | 4+++-
16 files changed, 705 insertions(+), 216 deletions(-)

diff --git a/electrum/commands.py b/electrum/commands.py @@ -1050,6 +1050,16 @@ class Commands: coro = wallet.lnworker.force_close_channel(chan_id) if force else wallet.lnworker.close_channel(chan_id) return await coro + @command('w') + async def export_channel_backup(self, channel_point, wallet: Abstract_Wallet = None): + txid, index = channel_point.split(':') + chan_id, _ = channel_id_from_funding_tx(txid, int(index)) + return wallet.lnworker.export_channel_backup(chan_id) + + @command('w') + async def import_channel_backup(self, encrypted, wallet: Abstract_Wallet = None): + return wallet.lnworker.import_channel_backup(encrypted) + @command('wn') async def get_channel_ctx(self, channel_point, iknowwhatimdoing=False, wallet: Abstract_Wallet = None): """ return the current commitment transaction of a channel """ diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py @@ -408,6 +408,9 @@ class ElectrumWindow(App): if data.startswith('bitcoin:'): self.set_URI(data) return + if data.startswith('channel_backup:'): + self.import_channel_backup(data[15:]) + return bolt11_invoice = maybe_extract_bolt11_invoice(data) if bolt11_invoice is not None: self.set_ln_invoice(bolt11_invoice) @@ -727,9 +730,6 @@ class ElectrumWindow(App): d.open() def lightning_channels_dialog(self): - if not self.wallet.has_lightning(): - self.show_error('Lightning not enabled on this wallet') - return if self._channels_dialog is None: self._channels_dialog = LightningChannelsDialog(self) self._channels_dialog.open() @@ -1303,3 +1303,17 @@ class ElectrumWindow(App): self.show_error("Invalid PIN") return self.protected(_("Enter your PIN code in order to decrypt your private key"), show_private_key, (addr, pk_label)) + + def import_channel_backup(self, encrypted): + d = Question(_('Import Channel Backup?'), lambda b: self._import_channel_backup(b, encrypted)) + d.open() + + def _import_channel_backup(self, b, encrypted): + if not b: + return + try: + self.wallet.lnbackups.import_channel_backup(encrypted) + except Exception as e: + self.show_error("failed to import backup" + '\n' + str(e)) + return + self.lightning_channels_dialog() diff --git a/electrum/gui/kivy/uix/dialogs/lightning_channels.py b/electrum/gui/kivy/uix/dialogs/lightning_channels.py @@ -198,9 +198,118 @@ Builder.load_string(r''' text: _('Delete') on_release: root.remove_channel() disabled: not root.is_redeemed + +<ChannelBackupPopup@Popup>: + id: popuproot + data: [] + is_closed: False + is_redeemed: False + node_id:'' + short_id:'' + initiator:'' + capacity:'' + funding_txid:'' + closing_txid:'' + state:'' + is_open:False + BoxLayout: + padding: '12dp', '12dp', '12dp', '12dp' + spacing: '12dp' + orientation: 'vertical' + ScrollView: + scroll_type: ['bars', 'content'] + scroll_wheel_distance: dp(114) + BoxLayout: + orientation: 'vertical' + height: self.minimum_height + size_hint_y: None + spacing: '5dp' + BoxLabel: + text: _('Channel ID') + value: root.short_id + BoxLabel: + text: _('State') + value: root.state + BoxLabel: + text: _('Initiator') + value: root.initiator + BoxLabel: + text: _('Capacity') + value: root.capacity + Widget: + size_hint: 1, 0.1 + TopLabel: + text: _('Remote Node ID') + TxHashLabel: + data: root.node_id + name: _('Remote Node ID') + TopLabel: + text: _('Funding Transaction') + TxHashLabel: + data: root.funding_txid + name: _('Funding Transaction') + touch_callback: lambda: app.show_transaction(root.funding_txid) + TopLabel: + text: _('Closing Transaction') + opacity: int(bool(root.closing_txid)) + TxHashLabel: + opacity: int(bool(root.closing_txid)) + data: root.closing_txid + name: _('Closing Transaction') + touch_callback: lambda: app.show_transaction(root.closing_txid) + Widget: + size_hint: 1, 0.1 + Widget: + size_hint: 1, 0.05 + BoxLayout: + size_hint: 1, None + height: '48dp' + Button: + size_hint: 0.5, None + height: '48dp' + text: _('Request force-close') + on_release: root.request_force_close() + disabled: root.is_closed + Button: + size_hint: 0.5, None + height: '48dp' + text: _('Delete') + on_release: root.remove_backup() ''') +class ChannelBackupPopup(Popup): + + def __init__(self, chan, app, **kwargs): + super(ChannelBackupPopup,self).__init__(**kwargs) + self.chan = chan + self.app = app + + def request_force_close(self): + msg = _('Request force close?') + Question(msg, self._request_force_close).open() + + def _request_force_close(self, b): + if not b: + return + loop = self.app.wallet.network.asyncio_loop + coro = asyncio.run_coroutine_threadsafe(self.app.wallet.lnbackups.request_force_close(self.chan.channel_id), loop) + try: + coro.result(5) + self.app.show_info(_('Channel closed')) + except Exception as e: + self.app.show_info(_('Could not close channel: ') + repr(e)) # repr because str(Exception()) == '' + + def remove_backup(self): + msg = _('Delete backup?') + Question(msg, self._remove_backup).open() + + def _remove_backup(self, b): + if not b: + return + self.app.wallet.lnbackups.remove_channel_backup(self.chan.channel_id) + self.dismiss() + class ChannelDetailsPopup(Popup): def __init__(self, chan, app, **kwargs): @@ -282,7 +391,11 @@ class LightningChannelsDialog(Factory.Popup): self.update() def show_item(self, obj): - p = ChannelDetailsPopup(obj._chan, self.app) + chan = obj._chan + if chan.is_backup(): + p = ChannelBackupPopup(chan, self.app) + else: + p = ChannelDetailsPopup(chan, self.app) p.open() def format_fields(self, chan): @@ -305,7 +418,7 @@ class LightningChannelsDialog(Factory.Popup): def update_item(self, item): chan = item._chan item.status = chan.get_state_for_GUI() - item.short_channel_id = format_short_channel_id(chan.short_channel_id) + item.short_channel_id = chan.short_id_for_GUI() l, r = self.format_fields(chan) item.local_balance = _('Local') + ':' + l item.remote_balance = _('Remote') + ': ' + r @@ -317,10 +430,13 @@ class LightningChannelsDialog(Factory.Popup): if not self.app.wallet: return lnworker = self.app.wallet.lnworker - for i in lnworker.channels.values(): + channels = list(lnworker.channels.values()) if lnworker else [] + lnbackups = self.app.wallet.lnbackups + backups = list(lnbackups.channel_backups.values()) + for i in channels + backups: item = Factory.LightningChannelItem() item.screen = self - item.active = i.node_id in lnworker.peers + item.active = i.node_id in (lnworker.peers if lnworker else []) item._chan = i self.update_item(item) channel_cards.add_widget(item) @@ -328,5 +444,7 @@ class LightningChannelsDialog(Factory.Popup): def update_can_send(self): lnworker = self.app.wallet.lnworker + if not lnworker: + return self.can_send = self.app.format_amount_and_units(lnworker.num_sats_can_send()) self.can_receive = self.app.format_amount_and_units(lnworker.num_sats_can_receive()) diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py @@ -235,7 +235,6 @@ class ElectrumGui(Logger): run_hook('on_new_window', w) w.warn_if_testnet() w.warn_if_watching_only() - w.warn_if_lightning_backup() return w def count_wizards_in_progress(func): diff --git a/electrum/gui/qt/channels_list.py b/electrum/gui/qt/channels_list.py @@ -57,6 +57,7 @@ class ChannelsList(MyTreeView): self.update_single_row.connect(self.do_update_single_row) self.network = self.parent.network self.lnworker = self.parent.wallet.lnworker + self.lnbackups = self.parent.wallet.lnbackups self.setSortingEnabled(True) def format_fields(self, chan): @@ -78,7 +79,7 @@ class ChannelsList(MyTreeView): else: node_alias = '' return [ - format_short_channel_id(chan.short_channel_id), + chan.short_id_for_GUI(), bh2u(chan.node_id), node_alias, '' if closed else labels[LOCAL], @@ -106,14 +107,11 @@ class ChannelsList(MyTreeView): def force_close(self, channel_id): chan = self.lnworker.channels[channel_id] to_self_delay = chan.config[REMOTE].to_self_delay - if self.lnworker.wallet.is_lightning_backup(): - msg = _('WARNING: force-closing from an old state might result in fund loss.\nAre you sure?') - else: - msg = _('Force-close channel?') + '\n\n'\ - + _(f'Funds retrieved from this channel will not be available before {to_self_delay} blocks after forced closure.') + ' '\ - + _('After that delay, funds will be sent to an address derived from your wallet seed.') + '\n\n'\ - + _('In the meantime, channel funds will not be recoverable from your seed, and will be lost if you lose your wallet.') + ' '\ - + _('To prevent that, you should backup your wallet if you have not already done so.') + msg = _('Force-close channel?') + '\n\n'\ + + _(f'Funds retrieved from this channel will not be available before {to_self_delay} blocks after forced closure.') + ' '\ + + _('After that delay, funds will be sent to an address derived from your wallet seed.') + '\n\n'\ + + _('In the meantime, channel funds will not be recoverable from your seed, and might be lost if you lose your wallet.') + ' '\ + + _('To prevent that, you should have a backup of this channel on another device.') if self.parent.question(msg): def task(): coro = self.lnworker.force_close_channel(channel_id) @@ -124,6 +122,22 @@ class ChannelsList(MyTreeView): if self.main_window.question(_('Are you sure you want to delete this channel? This will purge associated transactions from your wallet history.')): self.lnworker.remove_channel(channel_id) + def remove_channel_backup(self, channel_id): + if self.main_window.question(_('Remove channel backup?')): + self.lnbackups.remove_channel_backup(channel_id) + + def export_channel_backup(self, channel_id): + data = self.lnworker.export_channel_backup(channel_id) + self.main_window.show_qrcode('channel_backup:' + data, 'channel backup') + + def request_force_close(self, channel_id): + def task(): + coro = self.lnbackups.request_force_close(channel_id) + return self.network.run_from_another_thread(coro) + def on_success(b): + self.main_window.show_message('success') + WaitingDialog(self, 'please wait..', task, on_success, self.on_failure) + def create_menu(self, position): menu = QMenu() menu.setSeparatorsCollapsible(True) # consecutive separators are merged together @@ -140,6 +154,11 @@ class ChannelsList(MyTreeView): if not item: return channel_id = idx.sibling(idx.row(), self.Columns.NODE_ID).data(ROLE_CHANNEL_ID) + if channel_id in self.lnbackups.channel_backups: + menu.addAction(_("Request force-close"), lambda: self.request_force_close(channel_id)) + menu.addAction(_("Delete"), lambda: self.remove_channel_backup(channel_id)) + menu.exec_(self.viewport().mapToGlobal(position)) + return chan = self.lnworker.channels[channel_id] menu.addAction(_("Details..."), lambda: self.parent.show_channel(channel_id)) cc = self.add_copy_menu(menu, idx) @@ -163,7 +182,6 @@ class ChannelsList(MyTreeView): if chan.peer_state == peer_states.GOOD: menu.addAction(_("Close channel"), lambda: self.close_channel(channel_id)) menu.addAction(_("Force-close channel"), lambda: self.force_close(channel_id)) - menu.addSeparator() else: item = chan.get_closing_height() if item: @@ -171,6 +189,8 @@ class ChannelsList(MyTreeView): closing_tx = self.lnworker.lnwatcher.db.get_transaction(txid) if closing_tx: menu.addAction(_("View closing transaction"), lambda: self.parent.show_transaction(closing_tx)) + menu.addSeparator() + menu.addAction(_("Export backup"), lambda: self.export_channel_backup(channel_id)) if chan.is_redeemed(): menu.addSeparator() menu.addAction(_("Delete"), lambda: self.remove_channel(channel_id)) @@ -195,13 +215,13 @@ class ChannelsList(MyTreeView): def do_update_rows(self, wallet): if wallet != self.parent.wallet: return - lnworker = self.parent.wallet.lnworker - if not lnworker: - return - self.update_can_send(lnworker) + channels = list(wallet.lnworker.channels.values()) if wallet.lnworker else [] + backups = list(wallet.lnbackups.channel_backups.values()) + if wallet.lnworker: + self.update_can_send(wallet.lnworker) self.model().clear() self.update_headers(self.headers) - for chan in lnworker.channels.values(): + for chan in channels + backups: items = [QtGui.QStandardItem(x) for x in self.format_fields(chan)] self.set_editability(items) if self._default_item_bg_brush is None: @@ -212,6 +232,7 @@ class ChannelsList(MyTreeView): items[self.Columns.REMOTE_BALANCE].setFont(QFont(MONOSPACE_FONT)) self._update_chan_frozen_bg(chan=chan, items=items) self.model().insertRow(0, items) + self.sortByColumn(self.Columns.SHORT_CHANID, Qt.DescendingOrder) def _update_chan_frozen_bg(self, *, chan: Channel, items: Sequence[QStandardItem]): diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py @@ -221,8 +221,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): tabs.addTab(tab, icon, description.replace("&", "")) add_optional_tab(tabs, self.addresses_tab, read_QIcon("tab_addresses.png"), _("&Addresses"), "addresses") - if self.wallet.has_lightning(): - add_optional_tab(tabs, self.channels_tab, read_QIcon("lightning.png"), _("Channels"), "channels") + add_optional_tab(tabs, self.channels_tab, read_QIcon("lightning.png"), _("Channels"), "channels") add_optional_tab(tabs, self.utxo_tab, read_QIcon("tab_coins.png"), _("Co&ins"), "utxo") add_optional_tab(tabs, self.contacts_tab, read_QIcon("tab_contacts.png"), _("Con&tacts"), "contacts") add_optional_tab(tabs, self.console_tab, read_QIcon("tab_console.png"), _("Con&sole"), "console") @@ -524,18 +523,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): ]) self.show_warning(msg, title=_('Watch-only wallet')) - def warn_if_lightning_backup(self): - if self.wallet.is_lightning_backup(): - msg = '\n\n'.join([ - _("This file is a backup of a lightning wallet."), - _("You will not be able to perform lightning payments using this file, and the lightning balance displayed in this wallet might be outdated.") + ' ' + \ - _("If you have lost the original wallet file, you can use this file to trigger a forced closure of your channels."), - _("Do you want to have your channels force-closed?") - ]) - if self.question(msg, title=_('Lightning Backup')): - self.network.maybe_init_lightning() - self.wallet.lnworker.start_network(self.network) - def warn_if_testnet(self): if not constants.net.TESTNET: return @@ -572,14 +559,44 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): return self.gui_object.new_window(filename) + def select_backup_dir(self, b): + name = self.config.get('backup_dir', '') + dirname = QFileDialog.getExistingDirectory(self, "Select your SSL certificate file", name) + if dirname: + self.config.set_key('backup_dir', dirname) + self.backup_dir_e.setText(dirname) + def backup_wallet(self): + d = WindowModalDialog(self, _("File Backup")) + vbox = QVBoxLayout(d) + grid = QGridLayout() + backup_help = "" + backup_dir = self.config.get('backup_dir') + backup_dir_label = HelpLabel(_('Backup directory') + ':', backup_help) + msg = _('Please select a backup directory') + if self.wallet.lnworker and self.wallet.lnworker.channels: + msg += '\n\n' + ' '.join([ + _("Note that lightning channels will be converted to channel backups."), + _("You cannot use channel backups to perform lightning payments."), + _("Channel backups can only be used to request your channels to be closed.") + ]) + self.backup_dir_e = QPushButton(backup_dir) + self.backup_dir_e.clicked.connect(self.select_backup_dir) + grid.addWidget(backup_dir_label, 1, 0) + grid.addWidget(self.backup_dir_e, 1, 1) + vbox.addLayout(grid) + vbox.addWidget(WWLabel(msg)) + vbox.addLayout(Buttons(CancelButton(d), OkButton(d))) + if not d.exec_(): + return try: new_path = self.wallet.save_backup() except BaseException as reason: self.show_critical(_("Electrum was unable to copy your wallet file to the specified location.") + "\n" + str(reason), title=_("Unable to create backup")) return if new_path: - self.show_message(_("A copy of your wallet file was created in")+" '%s'" % str(new_path), title=_("Wallet backup created")) + msg = _("A copy of your wallet file was created in")+" '%s'" % str(new_path) + self.show_message(msg, title=_("Wallet backup created")) else: self.show_message(_("You need to configure a backup directory in your preferences"), title=_("Backup not created")) @@ -2524,6 +2541,15 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.show_critical(_("Electrum was unable to parse your transaction") + ":\n" + repr(e)) return + def import_channel_backup(self, encrypted): + if not self.question('Import channel backup?'): + return + try: + self.wallet.lnbackups.import_channel_backup(encrypted) + except Exception as e: + self.show_error("failed to import backup" + '\n' + str(e)) + return + def read_tx_from_qrcode(self): from electrum import qrscanner try: @@ -2537,6 +2563,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): if str(data).startswith("bitcoin:"): self.pay_to_URI(data) return + if data.startswith('channel_backup:'): + self.import_channel_backup(data[15:]) + return # else if the user scanned an offline signed tx tx = self.tx_from_text(data) if not tx: diff --git a/electrum/gui/qt/settings_dialog.py b/electrum/gui/qt/settings_dialog.py @@ -146,13 +146,6 @@ class SettingsDialog(WindowModalDialog): # lightning lightning_widgets = [] - backup_help = _("""If you configure a backup directory, a backup of your wallet file will be saved everytime you create a new channel.\n\nA backup file cannot be used as a wallet; it can only be used to retrieve the funds locked in your channels, by requesting your channels to be force closed (using data loss protection).\n\nIf the remote node is online, they will force-close your channels when you open the backup file. Note that a backup is not strictly necessary for that; if the remote party is online but they cannot reach you because you lost your wallet file, they should eventually close your channels, and your funds should be sent to an address recoverable from your seed (using static_remotekey).\n\nIf the remote node is not online, you can use the backup file to force close your channels, but only at the risk of losing all your funds in the channel, because you will be broadcasting an old state.""") - backup_dir = self.config.get('backup_dir') - backup_dir_label = HelpLabel(_('Backup directory') + ':', backup_help) - self.backup_dir_e = QPushButton(backup_dir) - self.backup_dir_e.clicked.connect(self.select_backup_dir) - lightning_widgets.append((backup_dir_label, self.backup_dir_e)) - help_persist = _("""If this option is checked, Electrum will persist as a daemon after you close all your wallet windows. Your local watchtower will keep running, and it will protect your channels even if your wallet is not @@ -554,13 +547,6 @@ that is always connected to the internet. Configure a port if you want it to be if alias: self.window.fetch_alias() - def select_backup_dir(self, b): - name = self.config.get('backup_dir', '') - dirname = QFileDialog.getExistingDirectory(self, "Select your SSL certificate file", name) - if dirname: - self.config.set_key('backup_dir', dirname) - self.backup_dir_e.setText(dirname) - def select_ssl_certfile(self, b): name = self.config.get('ssl_certfile', '') filename, __ = QFileDialog.getOpenFileName(self, "Select your SSL certificate file", name) diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py @@ -56,6 +56,8 @@ 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 format_short_channel_id if TYPE_CHECKING: from .lnworker import LNWallet @@ -121,19 +123,256 @@ def htlcsum(htlcs): return sum([x.amount_msat for x in htlcs]) -class Channel(Logger): +class AbstractChannel(Logger): + + def set_short_channel_id(self, short_id: ShortChannelID) -> None: + self.short_channel_id = short_id + self.storage["short_channel_id"] = short_id + + def get_id_for_log(self) -> str: + scid = self.short_channel_id + if scid: + return str(scid) + return self.channel_id.hex() + + def set_state(self, state: channel_states) -> None: + """ set on-chain state """ + old_state = self._state + if (old_state, state) not in state_transitions: + raise Exception(f"Transition not allowed: {old_state.name} -> {state.name}") + self.logger.debug(f'Setting channel state: {old_state.name} -> {state.name}') + self._state = state + self.storage['state'] = self._state.name + + def get_state(self) -> channel_states: + return self._state + + def is_open(self): + return self.get_state() == channel_states.OPEN + + def is_closing(self): + return self.get_state() in [channel_states.CLOSING, channel_states.FORCE_CLOSING] + + def is_closed(self): + # the closing txid has been saved + return self.get_state() >= channel_states.CLOSED + + def is_redeemed(self): + return self.get_state() == channel_states.REDEEMED + + def save_funding_height(self, txid, height, timestamp): + self.storage['funding_height'] = txid, height, timestamp + + def get_funding_height(self): + return self.storage.get('funding_height') + + def delete_funding_height(self): + self.storage.pop('funding_height', None) + + def save_closing_height(self, txid, height, timestamp): + self.storage['closing_height'] = txid, height, timestamp + + def get_closing_height(self): + return self.storage.get('closing_height') + + def delete_closing_height(self): + self.storage.pop('closing_height', None) + + def create_sweeptxs_for_our_ctx(self, ctx): + return create_sweeptxs_for_our_ctx(chan=self, ctx=ctx, sweep_address=self.sweep_address) + + def create_sweeptxs_for_their_ctx(self, ctx): + return create_sweeptxs_for_their_ctx(chan=self, ctx=ctx, sweep_address=self.sweep_address) + + def is_backup(self): + return False + + def sweep_ctx(self, ctx: Transaction) -> Dict[str, SweepInfo]: + txid = ctx.txid() + if self.sweep_info.get(txid) is None: + our_sweep_info = self.create_sweeptxs_for_our_ctx(ctx) + their_sweep_info = self.create_sweeptxs_for_their_ctx(ctx) + if our_sweep_info is not None: + self.sweep_info[txid] = our_sweep_info + self.logger.info(f'we force closed') + elif their_sweep_info is not None: + self.sweep_info[txid] = their_sweep_info + self.logger.info(f'they force closed.') + else: + self.sweep_info[txid] = {} + self.logger.info(f'not sure who closed.') + return self.sweep_info[txid] + + # ancestor for Channel and ChannelBackup + def update_onchain_state(self, funding_txid, funding_height, closing_txid, closing_height, keep_watching): + # note: state transitions are irreversible, but + # save_funding_height, save_closing_height are reversible + if funding_height.height == TX_HEIGHT_LOCAL: + self.update_unfunded_state() + elif closing_height.height == TX_HEIGHT_LOCAL: + self.update_funded_state(funding_txid, funding_height) + else: + self.update_closed_state(funding_txid, funding_height, closing_txid, closing_height, keep_watching) + + def update_unfunded_state(self): + self.delete_funding_height() + self.delete_closing_height() + if self.get_state() in [channel_states.PREOPENING, channel_states.OPENING, channel_states.FORCE_CLOSING] and self.lnworker: + if self.is_initiator(): + # set channel state to REDEEMED so that it can be removed manually + # to protect ourselves against a server lying by omission, + # we check that funding_inputs have been double spent and deeply mined + inputs = self.storage.get('funding_inputs', []) + if not inputs: + self.logger.info(f'channel funding inputs are not provided') + self.set_state(channel_states.REDEEMED) + for i in inputs: + spender_txid = self.lnworker.wallet.db.get_spent_outpoint(*i) + if spender_txid is None: + continue + if spender_txid != self.funding_outpoint.txid: + tx_mined_height = self.lnworker.wallet.get_tx_height(spender_txid) + if tx_mined_height.conf > lnutil.REDEEM_AFTER_DOUBLE_SPENT_DELAY: + self.logger.info(f'channel is double spent {inputs}') + self.set_state(channel_states.REDEEMED) + break + else: + now = int(time.time()) + if now - self.storage.get('init_timestamp', 0) > CHANNEL_OPENING_TIMEOUT: + self.lnworker.remove_channel(self.channel_id) + + def update_funded_state(self, funding_txid, funding_height): + self.save_funding_height(funding_txid, funding_height.height, funding_height.timestamp) + self.delete_closing_height() + if self.get_state() == channel_states.OPENING: + if self.is_funding_tx_mined(funding_height): + self.set_state(channel_states.FUNDED) + self.set_short_channel_id(ShortChannelID.from_components( + funding_height.height, funding_height.txpos, self.funding_outpoint.output_index)) + self.logger.info(f"save_short_channel_id: {self.short_channel_id}") + + def update_closed_state(self, funding_txid, funding_height, closing_txid, closing_height, keep_watching): + self.save_funding_height(funding_txid, funding_height.height, funding_height.timestamp) + self.save_closing_height(closing_txid, closing_height.height, closing_height.timestamp) + if self.get_state() < channel_states.CLOSED: + conf = closing_height.conf + if conf > 0: + self.set_state(channel_states.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 self.get_state() == channel_states.CLOSED and not keep_watching: + self.set_state(channel_states.REDEEMED) + + +class ChannelBackup(AbstractChannel): + """ + current capabilities: + - detect force close + - request force close + - sweep my ctx to_local + future: + - will need to sweep their ctx to_remote + """ + + def __init__(self, cb: ChannelBackupStorage, *, sweep_address=None, lnworker=None): + self.name = None + Logger.__init__(self) + self.cb = cb + self.sweep_info = {} # type: Dict[str, Dict[str, SweepInfo]] + self.sweep_address = sweep_address + self.storage = {} # dummy storage + self._state = channel_states.OPENING + self.config = {} + self.config[LOCAL] = LocalConfig.from_seed( + channel_seed=cb.channel_seed, + to_self_delay=cb.local_delay, + # dummy values + static_remotekey=None, + dust_limit_sat=None, + max_htlc_value_in_flight_msat=None, + max_accepted_htlcs=None, + initial_msat=None, + reserve_sat=None, + funding_locked_received=False, + was_announced=False, + current_commitment_signature=None, + current_htlc_signatures=b'', + htlc_minimum_msat=1, + ) + self.config[REMOTE] = RemoteConfig( + payment_basepoint=OnlyPubkeyKeypair(cb.remote_payment_pubkey), + revocation_basepoint=OnlyPubkeyKeypair(cb.remote_revocation_pubkey), + to_self_delay=cb.remote_delay, + # dummy values + multisig_key=OnlyPubkeyKeypair(None), + htlc_basepoint=OnlyPubkeyKeypair(None), + delayed_basepoint=OnlyPubkeyKeypair(None), + dust_limit_sat=None, + max_htlc_value_in_flight_msat=None, + max_accepted_htlcs=None, + initial_msat = None, + reserve_sat = None, + htlc_minimum_msat=None, + next_per_commitment_point=None, + current_per_commitment_point=None) + + 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 is_backup(self): + return True + + def create_sweeptxs_for_their_ctx(self, ctx): + return {} + + def get_funding_address(self): + return self.cb.funding_address + + def short_id_for_GUI(self) -> str: + return 'BACKUP' + + def is_initiator(self): + return self.cb.is_initiator + + def get_state_for_GUI(self): + cs = self.get_state() + return cs.name + + def get_oldest_unrevoked_ctn(self, who): + return -1 + + def included_htlcs(self, subject, direction, ctn): + return [] + + def funding_txn_minimum_depth(self): + return 1 + + def is_funding_tx_mined(self, funding_height): + return funding_height.conf > 1 + + def balance_minus_outgoing_htlcs(self, whose: HTLCOwner, *, ctx_owner: HTLCOwner = HTLCOwner.LOCAL, ctn: int = None): + return 0 + + def balance(self, whose: HTLCOwner, *, ctx_owner=HTLCOwner.LOCAL, ctn: int = None) -> int: + return 0 + + def is_frozen_for_sending(self) -> bool: + return False + + def is_frozen_for_receiving(self) -> bool: + return False + + +class Channel(AbstractChannel): # note: try to avoid naming ctns/ctxs/etc as "current" and "pending". # they are ambiguous. Use "oldest_unrevoked" or "latest" or "next". # TODO enforce this ^ - def diagnostic_name(self): - if self.name: - return str(self.name) - try: - return f"lnchannel_{bh2u(self.channel_id[-4:])}" - except: - return super().diagnostic_name() - def __init__(self, state: 'StoredDict', *, sweep_address=None, name=None, lnworker=None, initial_feerate=None): self.name = name Logger.__init__(self) @@ -162,11 +401,22 @@ class Channel(Logger): self._receive_fail_reasons = {} # type: Dict[int, BarePaymentAttemptLog] self._ignore_max_htlc_value = False # used in tests - def get_id_for_log(self) -> str: - scid = self.short_channel_id - if scid: - return str(scid) - return self.channel_id.hex() + def short_id_for_GUI(self) -> str: + return format_short_channel_id(self.short_channel_id) + + def is_initiator(self): + return self.constraints.is_initiator + + def funding_txn_minimum_depth(self): + return self.constraints.funding_txn_minimum_depth + + def diagnostic_name(self): + if self.name: + return str(self.name) + try: + return f"lnchannel_{bh2u(self.channel_id[-4:])}" + except: + return super().diagnostic_name() def set_onion_key(self, key: int, value: bytes): self.onion_keys[key] = value @@ -269,10 +519,6 @@ class Channel(Logger): def is_static_remotekey_enabled(self) -> bool: return bool(self.storage.get('static_remotekey_enabled')) - def set_short_channel_id(self, short_id: ShortChannelID) -> None: - self.short_channel_id = short_id - self.storage["short_channel_id"] = short_id - def get_feerate(self, subject: HTLCOwner, *, ctn: int) -> int: # returns feerate in sat/kw return self.hm.get_feerate(subject, ctn) @@ -322,21 +568,11 @@ class Channel(Logger): self.peer_state = peer_states.GOOD def set_state(self, state: channel_states) -> None: - """ set on-chain state """ - old_state = self._state - if (old_state, state) not in state_transitions: - raise Exception(f"Transition not allowed: {old_state.name} -> {state.name}") - self.logger.debug(f'Setting channel state: {old_state.name} -> {state.name}') - self._state = state - self.storage['state'] = self._state.name - + super().set_state(state) if self.lnworker: self.lnworker.save_channel(self) self.lnworker.network.trigger_callback('channel', self) - def get_state(self) -> channel_states: - return self._state - def get_state_for_GUI(self): # status displayed in the GUI cs = self.get_state() @@ -347,16 +583,6 @@ class Channel(Logger): return ps.name return cs.name - def is_open(self): - return self.get_state() == channel_states.OPEN - - def is_closing(self): - return self.get_state() in [channel_states.CLOSING, channel_states.FORCE_CLOSING] - - def is_closed(self): - # the closing txid has been saved - return self.get_state() >= channel_states.CLOSED - def set_can_send_ctx_updates(self, b: bool) -> None: self._can_send_ctx_updates = b @@ -373,27 +599,6 @@ class Channel(Logger): def can_send_update_add_htlc(self) -> bool: return self.can_send_ctx_updates() and not self.is_closing() - def save_funding_height(self, txid, height, timestamp): - self.storage['funding_height'] = txid, height, timestamp - - def get_funding_height(self): - return self.storage.get('funding_height') - - def delete_funding_height(self): - self.storage.pop('funding_height', None) - - def save_closing_height(self, txid, height, timestamp): - self.storage['closing_height'] = txid, height, timestamp - - def get_closing_height(self): - return self.storage.get('closing_height') - - def delete_closing_height(self): - self.storage.pop('closing_height', None) - - def is_redeemed(self): - return self.get_state() == channel_states.REDEEMED - def is_frozen_for_sending(self) -> bool: """Whether the user has marked this channel as frozen for sending. Frozen channels are not supposed to be used for new outgoing payments. @@ -1039,21 +1244,6 @@ class Channel(Logger): assert tx.is_complete() return tx - def sweep_ctx(self, ctx: Transaction) -> Dict[str, SweepInfo]: - txid = ctx.txid() - if self.sweep_info.get(txid) is None: - our_sweep_info = create_sweeptxs_for_our_ctx(chan=self, ctx=ctx, sweep_address=self.sweep_address) - their_sweep_info = create_sweeptxs_for_their_ctx(chan=self, ctx=ctx, sweep_address=self.sweep_address) - if our_sweep_info is not None: - self.sweep_info[txid] = our_sweep_info - self.logger.info(f'we force closed.') - elif their_sweep_info is not None: - self.sweep_info[txid] = their_sweep_info - self.logger.info(f'they force closed.') - else: - self.sweep_info[txid] = {} - return self.sweep_info[txid] - def sweep_htlc(self, ctx: Transaction, htlc_tx: Transaction) -> Optional[SweepInfo]: # look at the output address, check if it matches return create_sweeptx_for_their_revoked_htlc(self, ctx, htlc_tx, self.sweep_address) @@ -1095,16 +1285,6 @@ class Channel(Logger): 500_000) return total_value_sat > min_value_worth_closing_channel_over_sat - def update_onchain_state(self, funding_txid, funding_height, closing_txid, closing_height, keep_watching): - # note: state transitions are irreversible, but - # save_funding_height, save_closing_height are reversible - if funding_height.height == TX_HEIGHT_LOCAL: - self.update_unfunded_state() - elif closing_height.height == TX_HEIGHT_LOCAL: - self.update_funded_state(funding_txid, funding_height) - else: - self.update_closed_state(funding_txid, funding_height, closing_txid, closing_height, keep_watching) - def is_funding_tx_mined(self, funding_height): """ Checks if Funding TX has been mined. If it has, save the short channel ID in chan; @@ -1114,7 +1294,7 @@ class Channel(Logger): funding_txid = self.funding_outpoint.txid funding_idx = self.funding_outpoint.output_index conf = funding_height.conf - if conf < self.constraints.funding_txn_minimum_depth: + if conf < self.funding_txn_minimum_depth(): self.logger.info(f"funding tx is still not at sufficient depth. actual depth: {conf}") return False assert conf > 0 @@ -1132,53 +1312,3 @@ class Channel(Logger): return False return True - def update_unfunded_state(self): - self.delete_funding_height() - self.delete_closing_height() - if self.get_state() in [channel_states.PREOPENING, channel_states.OPENING, channel_states.FORCE_CLOSING] and self.lnworker: - if self.constraints.is_initiator: - # set channel state to REDEEMED so that it can be removed manually - # to protect ourselves against a server lying by omission, - # we check that funding_inputs have been double spent and deeply mined - inputs = self.storage.get('funding_inputs', []) - if not inputs: - self.logger.info(f'channel funding inputs are not provided') - self.set_state(channel_states.REDEEMED) - for i in inputs: - spender_txid = self.lnworker.wallet.db.get_spent_outpoint(*i) - if spender_txid is None: - continue - if spender_txid != self.funding_outpoint.txid: - tx_mined_height = self.lnworker.wallet.get_tx_height(spender_txid) - if tx_mined_height.conf > lnutil.REDEEM_AFTER_DOUBLE_SPENT_DELAY: - self.logger.info(f'channel is double spent {inputs}') - self.set_state(channel_states.REDEEMED) - break - else: - now = int(time.time()) - if now - self.storage.get('init_timestamp', 0) > CHANNEL_OPENING_TIMEOUT: - self.lnworker.remove_channel(self.channel_id) - - def update_funded_state(self, funding_txid, funding_height): - self.save_funding_height(funding_txid, funding_height.height, funding_height.timestamp) - self.delete_closing_height() - if self.get_state() == channel_states.OPENING: - if self.is_funding_tx_mined(funding_height): - self.set_state(channel_states.FUNDED) - self.set_short_channel_id(ShortChannelID.from_components( - funding_height.height, funding_height.txpos, self.funding_outpoint.output_index)) - self.logger.info(f"save_short_channel_id: {self.short_channel_id}") - - def update_closed_state(self, funding_txid, funding_height, closing_txid, closing_height, keep_watching): - self.save_funding_height(funding_txid, funding_height.height, funding_height.timestamp) - self.save_closing_height(closing_txid, closing_height.height, closing_height.timestamp) - if self.get_state() < channel_states.CLOSED: - conf = closing_height.conf - if conf > 0: - self.set_state(channel_states.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 self.get_state() == channel_states.CLOSED and not keep_watching: - self.set_state(channel_states.REDEEMED) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py @@ -44,7 +44,7 @@ from .lnutil import (Outpoint, LocalConfig, RECEIVED, UpdateAddHtlc, MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED, RemoteMisbehaving, DEFAULT_TO_SELF_DELAY, NBLOCK_OUR_CLTV_EXPIRY_DELTA, format_short_channel_id, ShortChannelID, IncompatibleLightningFeatures, derive_payment_secret_from_payment_preimage) -from .lnutil import FeeUpdate +from .lnutil import FeeUpdate, channel_id_from_funding_tx from .lntransport import LNTransport, LNTransportBase from .lnmsg import encode_msg, decode_msg from .interface import GracefulDisconnect, NetworkException @@ -60,10 +60,6 @@ if TYPE_CHECKING: LN_P2P_NETWORK_TIMEOUT = 20 -def channel_id_from_funding_tx(funding_txid: str, funding_index: int) -> Tuple[bytes, bytes]: - funding_txid_bytes = bytes.fromhex(funding_txid)[::-1] - i = int.from_bytes(funding_txid_bytes, 'big') ^ funding_index - return i.to_bytes(32, 'big'), funding_txid_bytes class Peer(Logger): @@ -222,7 +218,7 @@ class Peer(Logger): if constants.net.rev_genesis_bytes() not in their_chains: raise GracefulDisconnect(f"no common chain found with remote. (they sent: {their_chains})") # all checks passed - if isinstance(self.transport, LNTransport): + if self.channel_db and isinstance(self.transport, LNTransport): self.channel_db.add_recent_peer(self.transport.peer_addr) for chan in self.channels.values(): chan.add_or_update_peer_addr(self.transport.peer_addr) @@ -728,6 +724,17 @@ class Peer(Logger): raise Exception(f'reserve too high: {remote_reserve_sat}, funding_sat: {funding_sat}') return remote_reserve_sat + async def trigger_force_close(self, channel_id): + await self.initialized + latest_point = 0 + self.send_message( + "channel_reestablish", + channel_id=channel_id, + next_local_commitment_number=0, + next_remote_revocation_number=0, + your_last_per_commitment_secret=0, + my_current_per_commitment_point=latest_point) + async def reestablish_channel(self, chan: Channel): await self.initialized chan_id = chan.channel_id @@ -749,8 +756,7 @@ class Peer(Logger): next_remote_ctn = chan.get_next_ctn(REMOTE) assert self.features & LnFeatures.OPTION_DATA_LOSS_PROTECT_OPT # send message - srk_enabled = chan.is_static_remotekey_enabled() - if srk_enabled: + if chan.is_static_remotekey_enabled(): latest_secret, latest_point = chan.get_secret_and_point(LOCAL, 0) else: latest_secret, latest_point = chan.get_secret_and_point(LOCAL, latest_local_ctn) @@ -878,10 +884,6 @@ class Peer(Logger): self.logger.warning(f"channel_reestablish ({chan.get_id_for_log()}): we are ahead of remote! trying to force-close.") await self.lnworker.try_force_closing(chan_id) return - elif self.lnworker.wallet.is_lightning_backup(): - self.logger.warning(f"channel_reestablish ({chan.get_id_for_log()}): force-closing because we are a recent backup") - await self.lnworker.try_force_closing(chan_id) - return chan.peer_state = peer_states.GOOD # note: chan.short_channel_id being set implies the funding txn is already at sufficient depth diff --git a/electrum/lnsweep.py b/electrum/lnsweep.py @@ -18,7 +18,7 @@ from .lnutil import (make_commitment_output_to_remote_address, make_commitment_o from .transaction import (Transaction, TxOutput, construct_witness, PartialTransaction, PartialTxInput, PartialTxOutput, TxOutpoint) from .simple_config import SimpleConfig -from .logging import get_logger +from .logging import get_logger, Logger if TYPE_CHECKING: from .lnchannel import Channel diff --git a/electrum/lnutil.py b/electrum/lnutil.py @@ -24,6 +24,7 @@ from . import segwit_addr from .i18n import _ from .lnaddr import lndecode from .bip32 import BIP32Node, BIP32_PRIME +from .transaction import BCDataStream if TYPE_CHECKING: from .lnchannel import Channel @@ -47,6 +48,11 @@ def ln_dummy_address(): from .json_db import StoredObject +def channel_id_from_funding_tx(funding_txid: str, funding_index: int) -> Tuple[bytes, bytes]: + funding_txid_bytes = bytes.fromhex(funding_txid)[::-1] + i = int.from_bytes(funding_txid_bytes, 'big') ^ funding_index + return i.to_bytes(32, 'big'), funding_txid_bytes + hex_to_bytes = lambda v: v if isinstance(v, bytes) else bytes.fromhex(v) if v is not None else None json_to_keypair = lambda v: v if isinstance(v, OnlyPubkeyKeypair) else Keypair(**v) if len(v)==2 else OnlyPubkeyKeypair(**v) @@ -116,6 +122,66 @@ class ChannelConstraints(StoredObject): is_initiator = attr.ib(type=bool) # note: sometimes also called "funder" funding_txn_minimum_depth = attr.ib(type=int) +@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) + + def channel_id(self): + chan_id, _ = channel_id_from_funding_tx(self.funding_txid, self.funding_index) + return chan_id + + def to_bytes(self): + vds = BCDataStream() + vds.write_boolean(self.is_initiator) + vds.write_bytes(self.privkey, 32) + vds.write_bytes(self.channel_seed, 32) + vds.write_bytes(self.node_id, 33) + vds.write_bytes(bfh(self.funding_txid), 32) + vds.write_int16(self.funding_index) + vds.write_string(self.funding_address) + vds.write_bytes(self.remote_payment_pubkey, 33) + vds.write_bytes(self.remote_revocation_pubkey, 33) + vds.write_int16(self.local_delay) + vds.write_int16(self.remote_delay) + vds.write_string(self.host) + vds.write_int16(self.port) + return vds.input + + @staticmethod + def from_bytes(s): + vds = BCDataStream() + vds.write(s) + return ChannelBackupStorage( + is_initiator = bool(vds.read_bytes(1)), + privkey = vds.read_bytes(32).hex(), + channel_seed = vds.read_bytes(32).hex(), + node_id = vds.read_bytes(33).hex(), + funding_txid = vds.read_bytes(32).hex(), + funding_index = vds.read_int16(), + funding_address = vds.read_string(), + remote_payment_pubkey = vds.read_bytes(33).hex(), + remote_revocation_pubkey = vds.read_bytes(33).hex(), + local_delay = vds.read_int16(), + remote_delay = vds.read_int16(), + host = vds.read_string(), + port = vds.read_int16()) + + class ScriptHtlc(NamedTuple): redeem_script: bytes @@ -716,8 +782,8 @@ def extract_ctn_from_tx(tx: Transaction, txin_index: int, funder_payment_basepoi return get_obscured_ctn(obs, funder_payment_basepoint, fundee_payment_basepoint) def extract_ctn_from_tx_and_chan(tx: Transaction, chan: 'Channel') -> int: - funder_conf = chan.config[LOCAL] if chan.constraints.is_initiator else chan.config[REMOTE] - fundee_conf = chan.config[LOCAL] if not chan.constraints.is_initiator else chan.config[REMOTE] + funder_conf = chan.config[LOCAL] if chan.is_initiator() else chan.config[REMOTE] + fundee_conf = chan.config[LOCAL] if not chan.is_initiator() else chan.config[REMOTE] return extract_ctn_from_tx(tx, txin_index=0, funder_payment_basepoint=funder_conf.payment_basepoint.pubkey, fundee_payment_basepoint=fundee_conf.payment_basepoint.pubkey) diff --git a/electrum/lnwatcher.py b/electrum/lnwatcher.py @@ -15,6 +15,7 @@ from typing import NamedTuple, Dict from .sql_db import SqlDB, sql from .wallet_db import WalletDB from .util import bh2u, bfh, log_exceptions, ignore_exceptions +from .lnutil import Outpoint from . import wallet from .storage import WalletStorage from .address_synchronizer import AddressSynchronizer, TX_HEIGHT_LOCAL, TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED diff --git a/electrum/lnworker.py b/electrum/lnworker.py @@ -64,6 +64,9 @@ from .lnrouter import RouteEdge, LNPaymentRoute, is_route_sane_to_use from .address_synchronizer import TX_HEIGHT_LOCAL from . import lnsweep from .lnwatcher import LNWalletWatcher +from .crypto import pw_encode_bytes, pw_decode_bytes, PW_HASH_VERSION_LATEST +from .lnutil import ChannelBackupStorage +from .lnchannel import ChannelBackup if TYPE_CHECKING: from .network import Network @@ -219,7 +222,8 @@ class LNWorker(Logger): return peer def peer_closed(self, peer: Peer) -> None: - self.peers.pop(peer.pubkey) + if peer.pubkey in self.peers: + self.peers.pop(peer.pubkey) def num_peers(self) -> int: return sum([p.is_initialized() for p in self.peers.values()]) @@ -492,7 +496,8 @@ class LNWallet(LNWorker): self.lnwatcher = LNWalletWatcher(self, network) self.lnwatcher.start_network(network) self.network = network - for chan_id, chan in self.channels.items(): + + for chan in self.channels.values(): self.lnwatcher.add_channel(chan.funding_outpoint.to_str(), chan.get_funding_address()) super().start_network(network) @@ -763,8 +768,6 @@ class LNWallet(LNWorker): def open_channel(self, *, connect_str: str, funding_tx: PartialTransaction, funding_sat: int, push_amt_sat: int, password: str = None, timeout: Optional[int] = 20) -> Tuple[Channel, PartialTransaction]: - if self.wallet.is_lightning_backup(): - raise Exception(_('Cannot create channel: this is a backup file')) if funding_sat > LN_MAX_FUNDING_SAT: raise Exception(_("Requested channel capacity is over protocol allowed maximum.")) coro = self._open_channel_coroutine(connect_str=connect_str, funding_tx=funding_tx, funding_sat=funding_sat, @@ -1319,3 +1322,100 @@ class LNWallet(LNWorker): if feerate_per_kvbyte is None: feerate_per_kvbyte = FEERATE_FALLBACK_STATIC_FEE return max(253, feerate_per_kvbyte // 4) + + def create_channel_backup(self, channel_id): + chan = self.channels[channel_id] + peer_addresses = list(chan.get_peer_addresses()) + peer_addr = peer_addresses[0] + return ChannelBackupStorage( + node_id = chan.node_id, + privkey = self.node_keypair.privkey, + funding_txid = chan.funding_outpoint.txid, + funding_index = chan.funding_outpoint.output_index, + funding_address = chan.get_funding_address(), + host = peer_addr.host, + port = peer_addr.port, + is_initiator = chan.constraints.is_initiator, + channel_seed = chan.config[LOCAL].channel_seed, + local_delay = chan.config[LOCAL].to_self_delay, + remote_delay = chan.config[REMOTE].to_self_delay, + remote_revocation_pubkey = chan.config[REMOTE].revocation_basepoint.pubkey, + remote_payment_pubkey = chan.config[REMOTE].payment_basepoint.pubkey) + + 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" + encrypted = pw_encode_bytes(backup_bytes, xpub, version=PW_HASH_VERSION_LATEST) + assert backup_bytes == pw_decode_bytes(encrypted, xpub, version=PW_HASH_VERSION_LATEST), "encrypt failed" + return encrypted + + +class LNBackups(Logger): + + def __init__(self, wallet: 'Abstract_Wallet'): + Logger.__init__(self) + self.features = LnFeatures(0) + self.features |= LnFeatures.OPTION_DATA_LOSS_PROTECT_OPT + self.features |= LnFeatures.OPTION_STATIC_REMOTEKEY_OPT + self.taskgroup = SilentTaskGroup() + self.lock = threading.RLock() + self.wallet = wallet + self.db = wallet.db + self.sweep_address = wallet.get_receiving_address() + self.channel_backups = {} + for channel_id, cb in self.db.get_dict("channel_backups").items(): + self.channel_backups[bfh(channel_id)] = ChannelBackup(cb, sweep_address=self.sweep_address, lnworker=self) + + def peer_closed(self, chan): + pass + + async def on_channel_update(self, chan): + pass + + def channel_by_txo(self, txo): + with self.lock: + channel_backups = list(self.channel_backups.values()) + for chan in channel_backups: + if chan.funding_outpoint.to_str() == txo: + return chan + + def start_network(self, network: 'Network'): + assert network + self.lnwatcher = LNWalletWatcher(self, network) + self.lnwatcher.start_network(network) + self.network = network + for cb in self.channel_backups.values(): + self.lnwatcher.add_channel(cb.funding_outpoint.to_str(), cb.get_funding_address()) + + def import_channel_backup(self, encrypted): + xpub = self.wallet.get_fingerprint() + x = pw_decode_bytes(encrypted, xpub, version=PW_HASH_VERSION_LATEST) + cb = ChannelBackupStorage.from_bytes(x) + channel_id = cb.channel_id().hex() + d = self.db.get_dict("channel_backups") + if channel_id in d: + raise Exception('Channel already in wallet') + d[channel_id] = cb + self.channel_backups[bfh(channel_id)] = ChannelBackup(cb, sweep_address=self.sweep_address, lnworker=self) + self.wallet.save_db() + self.network.trigger_callback('channels_updated', self.wallet) + + def remove_channel_backup(self, channel_id): + d = self.db.get_dict("channel_backups") + if channel_id.hex() not in d: + raise Exception('Channel not found') + d.pop(channel_id.hex()) + self.channel_backups.pop(channel_id) + self.wallet.save_db() + self.network.trigger_callback('channels_updated', self.wallet) + + @log_exceptions + async def request_force_close(self, channel_id): + cb = self.channel_backups[channel_id].cb + peer_addr = LNPeerAddr(cb.host, cb.port, cb.node_id) + transport = LNTransport(cb.privkey, peer_addr) + peer = Peer(self, cb.node_id, transport) + await self.taskgroup.spawn(peer._message_loop()) + await peer.initialized + await self.taskgroup.spawn(peer.trigger_force_close(channel_id)) diff --git a/electrum/transaction.py b/electrum/transaction.py @@ -289,6 +289,10 @@ class BCDataStream(object): else: raise SerializationError('attempt to read past end of buffer') + def write_bytes(self, _bytes: Union[bytes, bytearray], length: int): + assert len(_bytes) == length, len(_bytes) + self.write(_bytes) + def can_read_more(self) -> bool: if not self.input: return False diff --git a/electrum/wallet.py b/electrum/wallet.py @@ -72,7 +72,7 @@ from .contacts import Contacts from .interface import NetworkException from .mnemonic import Mnemonic from .logging import get_logger -from .lnworker import LNWallet +from .lnworker import LNWallet, LNBackups from .paymentrequest import PaymentRequest if TYPE_CHECKING: @@ -259,6 +259,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC): # lightning ln_xprv = self.db.get('lightning_privkey2') self.lnworker = LNWallet(self, ln_xprv) if ln_xprv else None + self.lnbackups = LNBackups(self) def save_db(self): if self.storage: @@ -269,7 +270,14 @@ class Abstract_Wallet(AddressSynchronizer, ABC): if backup_dir is None: return new_db = WalletDB(self.db.dump(), manual_upgrades=False) - new_db.put('is_backup', True) + + if self.lnworker: + channel_backups = new_db.get_dict('channel_backups') + 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_privkey2', None) + new_path = os.path.join(backup_dir, self.basename() + '.backup') new_storage = WalletStorage(new_path) new_storage._encryption_version = self.storage._encryption_version @@ -305,9 +313,6 @@ class Abstract_Wallet(AddressSynchronizer, ABC): self.db.put('lightning_privkey2', None) self.save_db() - def is_lightning_backup(self): - return self.has_lightning() and self.db.get('is_backup') - def stop_threads(self): super().stop_threads() if any([ks.is_requesting_to_be_rewritten_to_wallet_file for ks in self.get_keystores()]): @@ -324,9 +329,11 @@ class Abstract_Wallet(AddressSynchronizer, ABC): def start_network(self, network): AddressSynchronizer.start_network(self, network) - if self.lnworker and network and not self.is_lightning_backup(): - network.maybe_init_lightning() - self.lnworker.start_network(network) + if network: + if self.lnworker: + network.maybe_init_lightning() + self.lnworker.start_network(network) + self.lnbackups.start_network(network) def load_and_cleanup(self): self.load_keystore() diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py @@ -36,7 +36,7 @@ from .util import profiler, WalletFileException, multisig_type, TxMinedInfo, bfh 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 +from .lnutil import LOCAL, REMOTE, FeeUpdate, UpdateAddHtlc, LocalConfig, RemoteConfig, Keypair, OnlyPubkeyKeypair, RevocationStore, ChannelBackupStorage from .lnutil import ChannelConstraints, Outpoint, ShachainElement from .json_db import StoredDict, JsonDB, locked, modifier from .plugin import run_hook, plugin_loaders @@ -1101,6 +1101,8 @@ class WalletDB(JsonDB): v = dict((k, UpdateAddHtlc.from_tuple(*x)) for k, x in v.items()) elif key == 'fee_updates': v = dict((k, FeeUpdate(**x)) for k, x in v.items()) + elif key == 'channel_backups': + v = dict((k, ChannelBackupStorage(**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':