URI: 
       tchannels_list.py - electrum - Electrum Bitcoin wallet
  HTML git clone https://git.parazyd.org/electrum
   DIR Log
   DIR Files
   DIR Refs
   DIR Submodules
       ---
       tchannels_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()