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