electrum

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

channels_list.py (21382B)


      1 # -*- coding: utf-8 -*-
      2 import traceback
      3 from enum import IntEnum
      4 from typing import Sequence, Optional, Dict
      5 
      6 from PyQt5 import QtCore, QtGui
      7 from PyQt5.QtCore import Qt
      8 from PyQt5.QtWidgets import (QMenu, QHBoxLayout, QLabel, QVBoxLayout, QGridLayout, QLineEdit,
      9                              QPushButton, QAbstractItemView, QComboBox)
     10 from PyQt5.QtGui import QFont, QStandardItem, QBrush
     11 
     12 from electrum.util import bh2u, NotEnoughFunds, NoDynamicFeeEstimates
     13 from electrum.i18n import _
     14 from electrum.lnchannel import AbstractChannel, PeerState, ChannelBackup, Channel
     15 from electrum.wallet import Abstract_Wallet
     16 from electrum.lnutil import LOCAL, REMOTE, format_short_channel_id, LN_MAX_FUNDING_SAT
     17 from electrum.lnworker import LNWallet
     18 
     19 from .util import (MyTreeView, WindowModalDialog, Buttons, OkButton, CancelButton,
     20                    EnterButton, WaitingDialog, MONOSPACE_FONT, ColorScheme)
     21 from .amountedit import BTCAmountEdit, FreezableLineEdit
     22 
     23 
     24 ROLE_CHANNEL_ID = Qt.UserRole
     25 
     26 
     27 class ChannelsList(MyTreeView):
     28     update_rows = QtCore.pyqtSignal(Abstract_Wallet)
     29     update_single_row = QtCore.pyqtSignal(Abstract_Wallet, AbstractChannel)
     30     gossip_db_loaded = QtCore.pyqtSignal()
     31 
     32     class Columns(IntEnum):
     33         SHORT_CHANID = 0
     34         NODE_ALIAS = 1
     35         CAPACITY = 2
     36         LOCAL_BALANCE = 3
     37         REMOTE_BALANCE = 4
     38         CHANNEL_STATUS = 5
     39 
     40     headers = {
     41         Columns.SHORT_CHANID: _('Short Channel ID'),
     42         Columns.NODE_ALIAS: _('Node alias'),
     43         Columns.CAPACITY: _('Capacity'),
     44         Columns.LOCAL_BALANCE: _('Can send'),
     45         Columns.REMOTE_BALANCE: _('Can receive'),
     46         Columns.CHANNEL_STATUS: _('Status'),
     47     }
     48 
     49     filter_columns = [
     50         Columns.SHORT_CHANID,
     51         Columns.NODE_ALIAS,
     52         Columns.CHANNEL_STATUS,
     53     ]
     54 
     55     _default_item_bg_brush = None  # type: Optional[QBrush]
     56 
     57     def __init__(self, parent):
     58         super().__init__(parent, self.create_menu, stretch_column=self.Columns.NODE_ALIAS,
     59                          editable_columns=[])
     60         self.setModel(QtGui.QStandardItemModel(self))
     61         self.setSelectionMode(QAbstractItemView.ExtendedSelection)
     62         self.main_window = parent
     63         self.gossip_db_loaded.connect(self.on_gossip_db)
     64         self.update_rows.connect(self.do_update_rows)
     65         self.update_single_row.connect(self.do_update_single_row)
     66         self.network = self.parent.network
     67         self.lnworker = self.parent.wallet.lnworker
     68         self.setSortingEnabled(True)
     69 
     70     def format_fields(self, chan: AbstractChannel) -> Dict['ChannelsList.Columns', str]:
     71         labels = {}
     72         for subject in (REMOTE, LOCAL):
     73             if isinstance(chan, Channel):
     74                 can_send = chan.available_to_spend(subject) / 1000
     75                 label = self.parent.format_amount(can_send)
     76                 other = subject.inverted()
     77                 bal_other = chan.balance(other)//1000
     78                 bal_minus_htlcs_other = chan.balance_minus_outgoing_htlcs(other)//1000
     79                 if bal_other != bal_minus_htlcs_other:
     80                     label += ' (+' + self.parent.format_amount(bal_other - bal_minus_htlcs_other) + ')'
     81             else:
     82                 assert isinstance(chan, ChannelBackup)
     83                 label = ''
     84             labels[subject] = label
     85         status = chan.get_state_for_GUI()
     86         closed = chan.is_closed()
     87         node_alias = self.lnworker.get_node_alias(chan.node_id) or chan.node_id.hex()
     88         capacity_str = self.parent.format_amount(chan.get_capacity(), whitespaces=True)
     89         return {
     90             self.Columns.SHORT_CHANID: chan.short_id_for_GUI(),
     91             self.Columns.NODE_ALIAS: node_alias,
     92             self.Columns.CAPACITY: capacity_str,
     93             self.Columns.LOCAL_BALANCE: '' if closed else labels[LOCAL],
     94             self.Columns.REMOTE_BALANCE: '' if closed else labels[REMOTE],
     95             self.Columns.CHANNEL_STATUS: status,
     96         }
     97 
     98     def on_success(self, txid):
     99         self.main_window.show_error('Channel closed' + '\n' + txid)
    100 
    101     def on_failure(self, exc_info):
    102         type_, e, tb = exc_info
    103         traceback.print_tb(tb)
    104         self.main_window.show_error('Failed to close channel:\n{}'.format(repr(e)))
    105 
    106     def close_channel(self, channel_id):
    107         msg = _('Close channel?')
    108         if not self.parent.question(msg):
    109             return
    110         def task():
    111             coro = self.lnworker.close_channel(channel_id)
    112             return self.network.run_from_another_thread(coro)
    113         WaitingDialog(self, 'please wait..', task, self.on_success, self.on_failure)
    114 
    115     def force_close(self, channel_id):
    116         chan = self.lnworker.channels[channel_id]
    117         to_self_delay = chan.config[REMOTE].to_self_delay
    118         msg = _('Force-close channel?') + '\n\n'\
    119               + _('Funds retrieved from this channel will not be available before {} blocks after forced closure.').format(to_self_delay) + ' '\
    120               + _('After that delay, funds will be sent to an address derived from your wallet seed.') + '\n\n'\
    121               + _('In the meantime, channel funds will not be recoverable from your seed, and might be lost if you lose your wallet.') + ' '\
    122               + _('To prevent that, you should have a backup of this channel on another device.')
    123         if self.parent.question(msg):
    124             def task():
    125                 coro = self.lnworker.force_close_channel(channel_id)
    126                 return self.network.run_from_another_thread(coro)
    127             WaitingDialog(self, 'please wait..', task, self.on_success, self.on_failure)
    128 
    129     def remove_channel(self, channel_id):
    130         if self.main_window.question(_('Are you sure you want to delete this channel? This will purge associated transactions from your wallet history.')):
    131             self.lnworker.remove_channel(channel_id)
    132 
    133     def remove_channel_backup(self, channel_id):
    134         if self.main_window.question(_('Remove channel backup?')):
    135             self.lnworker.remove_channel_backup(channel_id)
    136 
    137     def export_channel_backup(self, channel_id):
    138         msg = ' '.join([
    139             _("Channel backups can be imported in another instance of the same wallet, by scanning this QR code."),
    140             _("Please note that channel backups cannot be used to restore your channels."),
    141             _("If you lose your wallet file, the only thing you can do with a backup is to request your channel to be closed, so that your funds will be sent on-chain."),
    142         ])
    143         data = self.lnworker.export_channel_backup(channel_id)
    144         self.main_window.show_qrcode(data, 'channel backup', help_text=msg,
    145                                      show_copy_text_btn=True)
    146 
    147     def request_force_close(self, channel_id):
    148         def task():
    149             coro = self.lnworker.request_force_close_from_backup(channel_id)
    150             return self.network.run_from_another_thread(coro)
    151         def on_success(b):
    152             self.main_window.show_message('success')
    153         WaitingDialog(self, 'please wait..', task, on_success, self.on_failure)
    154 
    155     def freeze_channel_for_sending(self, chan, b):
    156         if self.lnworker.channel_db or self.lnworker.is_trampoline_peer(chan.node_id):
    157             chan.set_frozen_for_sending(b)
    158         else:
    159             msg = ' '.join([
    160                 _("Trampoline routing is enabled, but this channel is with a non-trampoline node."),
    161                 _("This channel may still be used for receiving, but it is frozen for sending."),
    162                 _("If you want to keep using this channel, you need to disable trampoline routing in your preferences."),
    163             ])
    164             self.main_window.show_warning(msg, title=_('Channel is frozen for sending'))
    165 
    166     def create_menu(self, position):
    167         menu = QMenu()
    168         menu.setSeparatorsCollapsible(True)  # consecutive separators are merged together
    169         selected = self.selected_in_column(self.Columns.NODE_ALIAS)
    170         if not selected:
    171             menu.addAction(_("Import channel backup"), lambda: self.parent.do_process_from_text_channel_backup())
    172             menu.exec_(self.viewport().mapToGlobal(position))
    173             return
    174         multi_select = len(selected) > 1
    175         if multi_select:
    176             return
    177         idx = self.indexAt(position)
    178         if not idx.isValid():
    179             return
    180         item = self.model().itemFromIndex(idx)
    181         if not item:
    182             return
    183         channel_id = idx.sibling(idx.row(), self.Columns.NODE_ALIAS).data(ROLE_CHANNEL_ID)
    184         if channel_id in self.lnworker.channel_backups:
    185             menu.addAction(_("Request force-close"), lambda: self.request_force_close(channel_id))
    186             menu.addAction(_("Delete"), lambda: self.remove_channel_backup(channel_id))
    187             menu.exec_(self.viewport().mapToGlobal(position))
    188             return
    189         chan = self.lnworker.channels[channel_id]
    190         menu.addAction(_("Details..."), lambda: self.parent.show_channel(channel_id))
    191         cc = self.add_copy_menu(menu, idx)
    192         cc.addAction(_("Node ID"), lambda: self.place_text_on_clipboard(
    193             chan.node_id.hex(), title=_("Node ID")))
    194         cc.addAction(_("Long Channel ID"), lambda: self.place_text_on_clipboard(
    195             channel_id.hex(), title=_("Long Channel ID")))
    196         if not chan.is_closed():
    197             if not chan.is_frozen_for_sending():
    198                 menu.addAction(_("Freeze (for sending)"), lambda: self.freeze_channel_for_sending(chan, True))
    199             else:
    200                 menu.addAction(_("Unfreeze (for sending)"), lambda: self.freeze_channel_for_sending(chan, False))
    201             if not chan.is_frozen_for_receiving():
    202                 menu.addAction(_("Freeze (for receiving)"), lambda: chan.set_frozen_for_receiving(True))
    203             else:
    204                 menu.addAction(_("Unfreeze (for receiving)"), lambda: chan.set_frozen_for_receiving(False))
    205 
    206         funding_tx = self.parent.wallet.db.get_transaction(chan.funding_outpoint.txid)
    207         if funding_tx:
    208             menu.addAction(_("View funding transaction"), lambda: self.parent.show_transaction(funding_tx))
    209         if not chan.is_closed():
    210             menu.addSeparator()
    211             if chan.peer_state == PeerState.GOOD:
    212                 menu.addAction(_("Close channel"), lambda: self.close_channel(channel_id))
    213             menu.addAction(_("Force-close channel"), lambda: self.force_close(channel_id))
    214         else:
    215             item = chan.get_closing_height()
    216             if item:
    217                 txid, height, timestamp = item
    218                 closing_tx = self.lnworker.lnwatcher.db.get_transaction(txid)
    219                 if closing_tx:
    220                     menu.addAction(_("View closing transaction"), lambda: self.parent.show_transaction(closing_tx))
    221         menu.addSeparator()
    222         menu.addAction(_("Export backup"), lambda: self.export_channel_backup(channel_id))
    223         if chan.is_redeemed():
    224             menu.addSeparator()
    225             menu.addAction(_("Delete"), lambda: self.remove_channel(channel_id))
    226         menu.exec_(self.viewport().mapToGlobal(position))
    227 
    228     @QtCore.pyqtSlot(Abstract_Wallet, AbstractChannel)
    229     def do_update_single_row(self, wallet: Abstract_Wallet, chan: AbstractChannel):
    230         if wallet != self.parent.wallet:
    231             return
    232         for row in range(self.model().rowCount()):
    233             item = self.model().item(row, self.Columns.NODE_ALIAS)
    234             if item.data(ROLE_CHANNEL_ID) != chan.channel_id:
    235                 continue
    236             for column, v in self.format_fields(chan).items():
    237                 self.model().item(row, column).setData(v, QtCore.Qt.DisplayRole)
    238             items = [self.model().item(row, column) for column in self.Columns]
    239             self._update_chan_frozen_bg(chan=chan, items=items)
    240         if wallet.lnworker:
    241             self.update_can_send(wallet.lnworker)
    242 
    243     @QtCore.pyqtSlot()
    244     def on_gossip_db(self):
    245         self.do_update_rows(self.parent.wallet)
    246 
    247     @QtCore.pyqtSlot(Abstract_Wallet)
    248     def do_update_rows(self, wallet):
    249         if wallet != self.parent.wallet:
    250             return
    251         channels = list(wallet.lnworker.channels.values()) if wallet.lnworker else []
    252         backups = list(wallet.lnworker.channel_backups.values()) if wallet.lnworker else []
    253         if wallet.lnworker:
    254             self.update_can_send(wallet.lnworker)
    255         self.model().clear()
    256         self.update_headers(self.headers)
    257         for chan in channels + backups:
    258             field_map = self.format_fields(chan)
    259             items = [QtGui.QStandardItem(field_map[col]) for col in sorted(field_map)]
    260             self.set_editability(items)
    261             if self._default_item_bg_brush is None:
    262                 self._default_item_bg_brush = items[self.Columns.NODE_ALIAS].background()
    263             items[self.Columns.NODE_ALIAS].setData(chan.channel_id, ROLE_CHANNEL_ID)
    264             items[self.Columns.NODE_ALIAS].setFont(QFont(MONOSPACE_FONT))
    265             items[self.Columns.LOCAL_BALANCE].setFont(QFont(MONOSPACE_FONT))
    266             items[self.Columns.REMOTE_BALANCE].setFont(QFont(MONOSPACE_FONT))
    267             items[self.Columns.CAPACITY].setFont(QFont(MONOSPACE_FONT))
    268             self._update_chan_frozen_bg(chan=chan, items=items)
    269             self.model().insertRow(0, items)
    270 
    271         self.sortByColumn(self.Columns.SHORT_CHANID, Qt.DescendingOrder)
    272 
    273     def _update_chan_frozen_bg(self, *, chan: AbstractChannel, items: Sequence[QStandardItem]):
    274         assert self._default_item_bg_brush is not None
    275         # frozen for sending
    276         item = items[self.Columns.LOCAL_BALANCE]
    277         if chan.is_frozen_for_sending():
    278             item.setBackground(ColorScheme.BLUE.as_color(True))
    279             item.setToolTip(_("This channel is frozen for sending. It will not be used for outgoing payments."))
    280         else:
    281             item.setBackground(self._default_item_bg_brush)
    282             item.setToolTip("")
    283         # frozen for receiving
    284         item = items[self.Columns.REMOTE_BALANCE]
    285         if chan.is_frozen_for_receiving():
    286             item.setBackground(ColorScheme.BLUE.as_color(True))
    287             item.setToolTip(_("This channel is frozen for receiving. It will not be included in invoices."))
    288         else:
    289             item.setBackground(self._default_item_bg_brush)
    290             item.setToolTip("")
    291 
    292     def update_can_send(self, lnworker: LNWallet):
    293         msg = _('Can send') + ' ' + self.parent.format_amount(lnworker.num_sats_can_send())\
    294               + ' ' + self.parent.base_unit() + '; '\
    295               + _('can receive') + ' ' + self.parent.format_amount(lnworker.num_sats_can_receive())\
    296               + ' ' + self.parent.base_unit()
    297         self.can_send_label.setText(msg)
    298         self.update_swap_button(lnworker)
    299 
    300     def update_swap_button(self, lnworker: LNWallet):
    301         if lnworker.num_sats_can_send() or lnworker.num_sats_can_receive():
    302             self.swap_button.setEnabled(True)
    303         else:
    304             self.swap_button.setEnabled(False)
    305 
    306     def get_toolbar(self):
    307         h = QHBoxLayout()
    308         self.can_send_label = QLabel('')
    309         h.addWidget(self.can_send_label)
    310         h.addStretch()
    311         self.swap_button = EnterButton(_('Swap'), self.swap_dialog)
    312         self.swap_button.setToolTip("Have at least one channel to do swaps.")
    313         self.swap_button.setDisabled(True)
    314         self.new_channel_button = EnterButton(_('Open Channel'), self.new_channel_with_warning)
    315         self.new_channel_button.setEnabled(self.parent.wallet.has_lightning())
    316         h.addWidget(self.new_channel_button)
    317         h.addWidget(self.swap_button)
    318         return h
    319 
    320     def new_channel_with_warning(self):
    321         if not self.parent.wallet.lnworker.channels:
    322             warning1 = _("Lightning support in Electrum is experimental. "
    323                          "Do not put large amounts in lightning channels.")
    324             warning2 = _("Funds stored in lightning channels are not recoverable from your seed. "
    325                          "You must backup your wallet file everytime you create a new channel.")
    326             answer = self.parent.question(
    327                 _('Do you want to create your first channel?') + '\n\n' +
    328                 _('WARNINGS') + ': ' + '\n\n' + warning1 + '\n\n' + warning2)
    329             if answer:
    330                 self.new_channel_dialog()
    331         else:
    332             self.new_channel_dialog()
    333 
    334     def statistics_dialog(self):
    335         channel_db = self.parent.network.channel_db
    336         capacity = self.parent.format_amount(channel_db.capacity()) + ' '+ self.parent.base_unit()
    337         d = WindowModalDialog(self.parent, _('Lightning Network Statistics'))
    338         d.setMinimumWidth(400)
    339         vbox = QVBoxLayout(d)
    340         h = QGridLayout()
    341         h.addWidget(QLabel(_('Nodes') + ':'), 0, 0)
    342         h.addWidget(QLabel('{}'.format(channel_db.num_nodes)), 0, 1)
    343         h.addWidget(QLabel(_('Channels') + ':'), 1, 0)
    344         h.addWidget(QLabel('{}'.format(channel_db.num_channels)), 1, 1)
    345         h.addWidget(QLabel(_('Capacity') + ':'), 2, 0)
    346         h.addWidget(QLabel(capacity), 2, 1)
    347         vbox.addLayout(h)
    348         vbox.addLayout(Buttons(OkButton(d)))
    349         d.exec_()
    350 
    351     def new_channel_dialog(self):
    352         lnworker = self.parent.wallet.lnworker
    353         d = WindowModalDialog(self.parent, _('Open Channel'))
    354         vbox = QVBoxLayout(d)
    355         if self.parent.network.channel_db:
    356             vbox.addWidget(QLabel(_('Enter Remote Node ID or connection string or invoice')))
    357             remote_nodeid = QLineEdit()
    358             remote_nodeid.setMinimumWidth(700)
    359             suggest_button = QPushButton(d, text=_('Suggest Peer'))
    360             def on_suggest():
    361                 self.parent.wallet.network.start_gossip()
    362                 nodeid = bh2u(lnworker.suggest_peer() or b'')
    363                 if not nodeid:
    364                     remote_nodeid.setText("")
    365                     remote_nodeid.setPlaceholderText(
    366                         "Please wait until the graph is synchronized to 30%, and then try again.")
    367                 else:
    368                     remote_nodeid.setText(nodeid)
    369                 remote_nodeid.repaint()  # macOS hack for #6269
    370             suggest_button.clicked.connect(on_suggest)
    371         else:
    372             from electrum.lnworker import hardcoded_trampoline_nodes
    373             trampolines = hardcoded_trampoline_nodes()
    374             trampoline_names = list(trampolines.keys())
    375             trampoline_combo = QComboBox()
    376             trampoline_combo.addItems(trampoline_names)
    377             trampoline_combo.setCurrentIndex(1)
    378 
    379         amount_e = BTCAmountEdit(self.parent.get_decimal_point)
    380         # max button
    381         def spend_max():
    382             amount_e.setFrozen(max_button.isChecked())
    383             if not max_button.isChecked():
    384                 return
    385             make_tx = self.parent.mktx_for_open_channel('!')
    386             try:
    387                 tx = make_tx(None)
    388             except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
    389                 max_button.setChecked(False)
    390                 amount_e.setFrozen(False)
    391                 self.main_window.show_error(str(e))
    392                 return
    393             amount = tx.output_value()
    394             amount = min(amount, LN_MAX_FUNDING_SAT)
    395             amount_e.setAmount(amount)
    396         max_button = EnterButton(_("Max"), spend_max)
    397         max_button.setFixedWidth(100)
    398         max_button.setCheckable(True)
    399 
    400         clear_button = QPushButton(d, text=_('Clear'))
    401         def on_clear():
    402             amount_e.setText('')
    403             amount_e.setFrozen(False)
    404             amount_e.repaint()  # macOS hack for #6269
    405             if self.parent.network.channel_db:
    406                 remote_nodeid.setText('')
    407                 remote_nodeid.repaint()  # macOS hack for #6269
    408             max_button.setChecked(False)
    409             max_button.repaint()  # macOS hack for #6269
    410         clear_button.clicked.connect(on_clear)
    411         clear_button.setFixedWidth(100)
    412         h = QGridLayout()
    413         if self.parent.network.channel_db:
    414             h.addWidget(QLabel(_('Remote Node ID')), 0, 0)
    415             h.addWidget(remote_nodeid, 0, 1, 1, 4)
    416             h.addWidget(suggest_button, 0, 5)
    417         else:
    418             h.addWidget(QLabel(_('Trampoline Node')), 0, 0)
    419             h.addWidget(trampoline_combo, 0, 1, 1, 3)
    420 
    421         h.addWidget(QLabel('Amount'), 2, 0)
    422         h.addWidget(amount_e, 2, 1)
    423         h.addWidget(max_button, 2, 2)
    424         h.addWidget(clear_button, 2, 3)
    425         vbox.addLayout(h)
    426         ok_button = OkButton(d)
    427         ok_button.setDefault(True)
    428         vbox.addLayout(Buttons(CancelButton(d), ok_button))
    429         if not d.exec_():
    430             return
    431         if max_button.isChecked() and amount_e.get_amount() < LN_MAX_FUNDING_SAT:
    432             # if 'max' enabled and amount is strictly less than max allowed,
    433             # that means we have fewer coins than max allowed, and hence we can
    434             # spend all coins
    435             funding_sat = '!'
    436         else:
    437             funding_sat = amount_e.get_amount()
    438         if self.parent.network.channel_db:
    439             connect_str = str(remote_nodeid.text()).strip()
    440         else:
    441             name = trampoline_names[trampoline_combo.currentIndex()]
    442             connect_str = str(trampolines[name])
    443         if not connect_str or not funding_sat:
    444             return
    445         self.parent.open_channel(connect_str, funding_sat, 0)
    446 
    447     def swap_dialog(self):
    448         from .swap_dialog import SwapDialog
    449         d = SwapDialog(self.parent)
    450         d.run()