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()