1# -*- coding: utf-8 -*-
2import traceback
3from enum import IntEnum
4from typing import Sequence, Optional, Dict
5from abc import abstractmethod, ABC
6
7from PyQt5 import QtCore, QtGui
8from PyQt5.QtCore import Qt, QRect, QSize
9from PyQt5.QtWidgets import (QMenu, QHBoxLayout, QLabel, QVBoxLayout, QGridLayout, QLineEdit,
10                             QPushButton, QAbstractItemView, QComboBox, QCheckBox,
11                             QToolTip)
12from PyQt5.QtGui import QFont, QStandardItem, QBrush, QPainter, QIcon, QHelpEvent
13
14from electrum.util import bh2u, NotEnoughFunds, NoDynamicFeeEstimates
15from electrum.i18n import _
16from electrum.lnchannel import AbstractChannel, PeerState, ChannelBackup, Channel, ChannelState
17from electrum.wallet import Abstract_Wallet
18from electrum.lnutil import LOCAL, REMOTE, format_short_channel_id, LN_MAX_FUNDING_SAT
19from electrum.lnworker import LNWallet
20from electrum import ecc
21from electrum.gui import messages
22
23from .util import (MyTreeView, WindowModalDialog, Buttons, OkButton, CancelButton,
24                   EnterButton, WaitingDialog, MONOSPACE_FONT, ColorScheme)
25from .amountedit import BTCAmountEdit, FreezableLineEdit
26from .util import read_QIcon
27
28
29ROLE_CHANNEL_ID = Qt.UserRole
30
31
32class ChannelsList(MyTreeView):
33    update_rows = QtCore.pyqtSignal(Abstract_Wallet)
34    update_single_row = QtCore.pyqtSignal(Abstract_Wallet, AbstractChannel)
35    gossip_db_loaded = QtCore.pyqtSignal()
36
37    class Columns(IntEnum):
38        FEATURES = 0
39        SHORT_CHANID = 1
40        NODE_ALIAS = 2
41        CAPACITY = 3
42        LOCAL_BALANCE = 4
43        REMOTE_BALANCE = 5
44        CHANNEL_STATUS = 6
45
46    headers = {
47        Columns.SHORT_CHANID: _('Short Channel ID'),
48        Columns.NODE_ALIAS: _('Node alias'),
49        Columns.FEATURES: "",
50        Columns.CAPACITY: _('Capacity'),
51        Columns.LOCAL_BALANCE: _('Can send'),
52        Columns.REMOTE_BALANCE: _('Can receive'),
53        Columns.CHANNEL_STATUS: _('Status'),
54    }
55
56    filter_columns = [
57        Columns.SHORT_CHANID,
58        Columns.NODE_ALIAS,
59        Columns.CHANNEL_STATUS,
60    ]
61
62    _default_item_bg_brush = None  # type: Optional[QBrush]
63
64    def __init__(self, parent):
65        super().__init__(parent, self.create_menu, stretch_column=self.Columns.NODE_ALIAS)
66        self.setModel(QtGui.QStandardItemModel(self))
67        self.setSelectionMode(QAbstractItemView.ExtendedSelection)
68        self.main_window = parent
69        self.gossip_db_loaded.connect(self.on_gossip_db)
70        self.update_rows.connect(self.do_update_rows)
71        self.update_single_row.connect(self.do_update_single_row)
72        self.network = self.parent.network
73        self.wallet = self.parent.wallet
74        self.setSortingEnabled(True)
75
76    @property
77    # property because lnworker might be initialized at runtime
78    def lnworker(self):
79        return self.wallet.lnworker
80
81    def format_fields(self, chan: AbstractChannel) -> Dict['ChannelsList.Columns', str]:
82        labels = {}
83        for subject in (REMOTE, LOCAL):
84            if isinstance(chan, Channel):
85                can_send = chan.available_to_spend(subject) / 1000
86                label = self.parent.format_amount(can_send)
87                other = subject.inverted()
88                bal_other = chan.balance(other)//1000
89                bal_minus_htlcs_other = chan.balance_minus_outgoing_htlcs(other)//1000
90                if bal_other != bal_minus_htlcs_other:
91                    label += ' (+' + self.parent.format_amount(bal_other - bal_minus_htlcs_other) + ')'
92            else:
93                assert isinstance(chan, ChannelBackup)
94                label = ''
95            labels[subject] = label
96        status = chan.get_state_for_GUI()
97        closed = chan.is_closed()
98        node_alias = self.lnworker.get_node_alias(chan.node_id) or chan.node_id.hex()
99        capacity_str = self.parent.format_amount(chan.get_capacity(), whitespaces=True)
100        return {
101            self.Columns.SHORT_CHANID: chan.short_id_for_GUI(),
102            self.Columns.NODE_ALIAS: node_alias,
103            self.Columns.FEATURES: '',
104            self.Columns.CAPACITY: capacity_str,
105            self.Columns.LOCAL_BALANCE: '' if closed else labels[LOCAL],
106            self.Columns.REMOTE_BALANCE: '' if closed else labels[REMOTE],
107            self.Columns.CHANNEL_STATUS: status,
108        }
109
110    def on_channel_closed(self, txid):
111        self.main_window.show_error('Channel closed' + '\n' + txid)
112
113    def on_request_sent(self, b):
114        self.main_window.show_message(_('Request sent'))
115
116    def on_failure(self, exc_info):
117        type_, e, tb = exc_info
118        traceback.print_tb(tb)
119        self.main_window.show_error('Failed to close channel:\n{}'.format(repr(e)))
120
121    def close_channel(self, channel_id):
122        self.is_force_close = False
123        msg = _('Close channel?')
124        force_cb = QCheckBox('Request force close from remote peer')
125        tooltip = _(messages.MSG_REQUEST_FORCE_CLOSE)
126        tooltip = messages.to_rtf(tooltip)
127        def on_checked(b):
128            self.is_force_close = bool(b)
129        force_cb.stateChanged.connect(on_checked)
130        force_cb.setToolTip(tooltip)
131        if not self.parent.question(msg, checkbox=force_cb):
132            return
133        if self.is_force_close:
134            coro = self.lnworker.request_force_close(channel_id)
135            on_success = self.on_request_sent
136        else:
137            coro = self.lnworker.close_channel(channel_id)
138            on_success = self.on_channel_closed
139        def task():
140            return self.network.run_from_another_thread(coro)
141        WaitingDialog(self, 'please wait..', task, on_success, self.on_failure)
142
143    def force_close(self, channel_id):
144        self.save_backup = True
145        backup_cb = QCheckBox('Create a backup now', checked=True)
146        def on_checked(b):
147            self.save_backup = bool(b)
148        backup_cb.stateChanged.connect(on_checked)
149        chan = self.lnworker.channels[channel_id]
150        to_self_delay = chan.config[REMOTE].to_self_delay
151        msg = '<b>' + _('Force-close channel?') + '</b><br/>'\
152            + '<p>' + _('If you force-close this channel, the funds you have in it will not be available for {} blocks.').format(to_self_delay) + ' '\
153            + _('After that delay, funds will be swept to an address derived from your wallet seed.') + '</p>'\
154            + '<u>' + _('Please create a backup of your wallet file!') + '</u> '\
155            + '<p>' + _('Funds in this channel will not be recoverable from seed until they are swept back into your wallet, and might be lost if you lose your wallet file.') + ' '\
156            + _('To prevent that, you should save a backup of your wallet on another device.') + '</p>'
157        if not self.parent.question(msg, title=_('Force-close channel'), rich_text=True, checkbox=backup_cb):
158            return
159        if self.save_backup:
160            if not self.parent.backup_wallet():
161                return
162        def task():
163            coro = self.lnworker.force_close_channel(channel_id)
164            return self.network.run_from_another_thread(coro)
165        WaitingDialog(self, 'please wait..', task, self.on_channel_closed, self.on_failure)
166
167    def remove_channel(self, channel_id):
168        if self.main_window.question(_('Are you sure you want to delete this channel? This will purge associated transactions from your wallet history.')):
169            self.lnworker.remove_channel(channel_id)
170
171    def remove_channel_backup(self, channel_id):
172        if self.main_window.question(_('Remove channel backup?')):
173            self.lnworker.remove_channel_backup(channel_id)
174
175    def export_channel_backup(self, channel_id):
176        msg = ' '.join([
177            _("Channel backups can be imported in another instance of the same wallet, by scanning this QR code."),
178            _("Please note that channel backups cannot be used to restore your channels."),
179            _("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."),
180        ])
181        data = self.lnworker.export_channel_backup(channel_id)
182        self.main_window.show_qrcode(data, 'channel backup', help_text=msg,
183                                     show_copy_text_btn=True)
184
185    def request_force_close(self, channel_id):
186        def task():
187            coro = self.lnworker.request_force_close(channel_id)
188            return self.network.run_from_another_thread(coro)
189        WaitingDialog(self, 'please wait..', task, self.on_request_sent, self.on_failure)
190
191    def freeze_channel_for_sending(self, chan, b):
192        if self.lnworker.channel_db or self.lnworker.is_trampoline_peer(chan.node_id):
193            chan.set_frozen_for_sending(b)
194        else:
195            msg = messages.MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP
196            self.main_window.show_warning(msg, title=_('Channel is frozen for sending'))
197
198    def create_menu(self, position):
199        menu = QMenu()
200        menu.setSeparatorsCollapsible(True)  # consecutive separators are merged together
201        selected = self.selected_in_column(self.Columns.NODE_ALIAS)
202        if not selected:
203            menu.addAction(_("Import channel backup"), lambda: self.parent.do_process_from_text_channel_backup())
204            menu.exec_(self.viewport().mapToGlobal(position))
205            return
206        multi_select = len(selected) > 1
207        if multi_select:
208            return
209        idx = self.indexAt(position)
210        if not idx.isValid():
211            return
212        item = self.model().itemFromIndex(idx)
213        if not item:
214            return
215        channel_id = idx.sibling(idx.row(), self.Columns.NODE_ALIAS).data(ROLE_CHANNEL_ID)
216        chan = self.lnworker.channel_backups.get(channel_id)
217        if chan:
218            funding_tx = self.parent.wallet.db.get_transaction(chan.funding_outpoint.txid)
219            menu.addAction(_("View funding transaction"), lambda: self.parent.show_transaction(funding_tx))
220            if chan.get_state() == ChannelState.FUNDED:
221                menu.addAction(_("Request force-close"), lambda: self.request_force_close(channel_id))
222            if chan.can_be_deleted():
223                menu.addAction(_("Delete"), lambda: self.remove_channel_backup(channel_id))
224            menu.exec_(self.viewport().mapToGlobal(position))
225            return
226        chan = self.lnworker.channels[channel_id]
227        menu.addAction(_("Details..."), lambda: self.parent.show_channel(channel_id))
228        cc = self.add_copy_menu(menu, idx)
229        cc.addAction(_("Node ID"), lambda: self.place_text_on_clipboard(
230            chan.node_id.hex(), title=_("Node ID")))
231        cc.addAction(_("Long Channel ID"), lambda: self.place_text_on_clipboard(
232            channel_id.hex(), title=_("Long Channel ID")))
233        if not chan.is_closed():
234            if not chan.is_frozen_for_sending():
235                menu.addAction(_("Freeze (for sending)"), lambda: self.freeze_channel_for_sending(chan, True))  #
236            else:
237                menu.addAction(_("Unfreeze (for sending)"), lambda: self.freeze_channel_for_sending(chan, False))
238            if not chan.is_frozen_for_receiving():
239                menu.addAction(_("Freeze (for receiving)"), lambda: chan.set_frozen_for_receiving(True))
240            else:
241                menu.addAction(_("Unfreeze (for receiving)"), lambda: chan.set_frozen_for_receiving(False))
242
243        funding_tx = self.parent.wallet.db.get_transaction(chan.funding_outpoint.txid)
244        if funding_tx:
245            menu.addAction(_("View funding transaction"), lambda: self.parent.show_transaction(funding_tx))
246        if not chan.is_closed():
247            menu.addSeparator()
248            if chan.peer_state == PeerState.GOOD:
249                menu.addAction(_("Close channel"), lambda: self.close_channel(channel_id))
250            menu.addAction(_("Force-close channel"), lambda: self.force_close(channel_id))
251        else:
252            item = chan.get_closing_height()
253            if item:
254                txid, height, timestamp = item
255                closing_tx = self.lnworker.lnwatcher.db.get_transaction(txid)
256                if closing_tx:
257                    menu.addAction(_("View closing transaction"), lambda: self.parent.show_transaction(closing_tx))
258        menu.addSeparator()
259        menu.addAction(_("Export backup"), lambda: self.export_channel_backup(channel_id))
260        if chan.can_be_deleted():
261            menu.addSeparator()
262            menu.addAction(_("Delete"), lambda: self.remove_channel(channel_id))
263        menu.exec_(self.viewport().mapToGlobal(position))
264
265    @QtCore.pyqtSlot(Abstract_Wallet, AbstractChannel)
266    def do_update_single_row(self, wallet: Abstract_Wallet, chan: AbstractChannel):
267        if wallet != self.parent.wallet:
268            return
269        for row in range(self.model().rowCount()):
270            item = self.model().item(row, self.Columns.NODE_ALIAS)
271            if item.data(ROLE_CHANNEL_ID) != chan.channel_id:
272                continue
273            for column, v in self.format_fields(chan).items():
274                self.model().item(row, column).setData(v, QtCore.Qt.DisplayRole)
275            items = [self.model().item(row, column) for column in self.Columns]
276            self._update_chan_frozen_bg(chan=chan, items=items)
277        if wallet.lnworker:
278            self.update_can_send(wallet.lnworker)
279
280    @QtCore.pyqtSlot()
281    def on_gossip_db(self):
282        self.do_update_rows(self.parent.wallet)
283
284    @QtCore.pyqtSlot(Abstract_Wallet)
285    def do_update_rows(self, wallet):
286        if wallet != self.parent.wallet:
287            return
288        channels = list(wallet.lnworker.channels.values()) if wallet.lnworker else []
289        backups = list(wallet.lnworker.channel_backups.values()) if wallet.lnworker else []
290        if wallet.lnworker:
291            self.update_can_send(wallet.lnworker)
292        self.model().clear()
293        self.update_headers(self.headers)
294        for chan in channels + backups:
295            field_map = self.format_fields(chan)
296            items = [QtGui.QStandardItem(field_map[col]) for col in sorted(field_map)]
297            self.set_editability(items)
298            if self._default_item_bg_brush is None:
299                self._default_item_bg_brush = items[self.Columns.NODE_ALIAS].background()
300            items[self.Columns.NODE_ALIAS].setData(chan.channel_id, ROLE_CHANNEL_ID)
301            items[self.Columns.NODE_ALIAS].setFont(QFont(MONOSPACE_FONT))
302            items[self.Columns.LOCAL_BALANCE].setFont(QFont(MONOSPACE_FONT))
303            items[self.Columns.REMOTE_BALANCE].setFont(QFont(MONOSPACE_FONT))
304            items[self.Columns.FEATURES].setData(ChannelFeatureIcons.from_channel(chan), self.ROLE_CUSTOM_PAINT)
305            items[self.Columns.CAPACITY].setFont(QFont(MONOSPACE_FONT))
306            self._update_chan_frozen_bg(chan=chan, items=items)
307            self.model().insertRow(0, items)
308
309        self.sortByColumn(self.Columns.SHORT_CHANID, Qt.DescendingOrder)
310
311    def _update_chan_frozen_bg(self, *, chan: AbstractChannel, items: Sequence[QStandardItem]):
312        assert self._default_item_bg_brush is not None
313        # frozen for sending
314        item = items[self.Columns.LOCAL_BALANCE]
315        if chan.is_frozen_for_sending():
316            item.setBackground(ColorScheme.BLUE.as_color(True))
317            item.setToolTip(_("This channel is frozen for sending. It will not be used for outgoing payments."))
318        else:
319            item.setBackground(self._default_item_bg_brush)
320            item.setToolTip("")
321        # frozen for receiving
322        item = items[self.Columns.REMOTE_BALANCE]
323        if chan.is_frozen_for_receiving():
324            item.setBackground(ColorScheme.BLUE.as_color(True))
325            item.setToolTip(_("This channel is frozen for receiving. It will not be included in invoices."))
326        else:
327            item.setBackground(self._default_item_bg_brush)
328            item.setToolTip("")
329
330    def update_can_send(self, lnworker: LNWallet):
331        msg = _('Can send') + ' ' + self.parent.format_amount(lnworker.num_sats_can_send())\
332              + ' ' + self.parent.base_unit() + '; '\
333              + _('can receive') + ' ' + self.parent.format_amount(lnworker.num_sats_can_receive())\
334              + ' ' + self.parent.base_unit()
335        self.can_send_label.setText(msg)
336        self.update_swap_button(lnworker)
337
338    def update_swap_button(self, lnworker: LNWallet):
339        if lnworker.num_sats_can_send() or lnworker.num_sats_can_receive():
340            self.swap_button.setEnabled(True)
341        else:
342            self.swap_button.setEnabled(False)
343
344    def get_toolbar(self):
345        h = QHBoxLayout()
346        self.can_send_label = QLabel('')
347        h.addWidget(self.can_send_label)
348        h.addStretch()
349        self.swap_button = EnterButton(_('Swap'), self.swap_dialog)
350        self.swap_button.setToolTip("Have at least one channel to do swaps.")
351        self.swap_button.setDisabled(True)
352        self.new_channel_button = EnterButton(_('Open Channel'), self.new_channel_with_warning)
353        self.new_channel_button.setEnabled(self.parent.wallet.has_lightning())
354        h.addWidget(self.new_channel_button)
355        h.addWidget(self.swap_button)
356        return h
357
358    def new_channel_with_warning(self):
359        lnworker = self.parent.wallet.lnworker
360        if not lnworker.channels and not lnworker.channel_backups:
361            warning = _(messages.MSG_LIGHTNING_WARNING)
362            answer = self.parent.question(
363                _('Do you want to create your first channel?') + '\n\n' + warning)
364            if answer:
365                self.new_channel_dialog()
366        else:
367            self.new_channel_dialog()
368
369    def statistics_dialog(self):
370        channel_db = self.parent.network.channel_db
371        capacity = self.parent.format_amount(channel_db.capacity()) + ' '+ self.parent.base_unit()
372        d = WindowModalDialog(self.parent, _('Lightning Network Statistics'))
373        d.setMinimumWidth(400)
374        vbox = QVBoxLayout(d)
375        h = QGridLayout()
376        h.addWidget(QLabel(_('Nodes') + ':'), 0, 0)
377        h.addWidget(QLabel('{}'.format(channel_db.num_nodes)), 0, 1)
378        h.addWidget(QLabel(_('Channels') + ':'), 1, 0)
379        h.addWidget(QLabel('{}'.format(channel_db.num_channels)), 1, 1)
380        h.addWidget(QLabel(_('Capacity') + ':'), 2, 0)
381        h.addWidget(QLabel(capacity), 2, 1)
382        vbox.addLayout(h)
383        vbox.addLayout(Buttons(OkButton(d)))
384        d.exec_()
385
386    def new_channel_dialog(self):
387        lnworker = self.parent.wallet.lnworker
388        d = WindowModalDialog(self.parent, _('Open Channel'))
389        vbox = QVBoxLayout(d)
390        if self.parent.network.channel_db:
391            vbox.addWidget(QLabel(_('Enter Remote Node ID or connection string or invoice')))
392            remote_nodeid = QLineEdit()
393            remote_nodeid.setMinimumWidth(700)
394            suggest_button = QPushButton(d, text=_('Suggest Peer'))
395            def on_suggest():
396                self.parent.wallet.network.start_gossip()
397                nodeid = bh2u(lnworker.suggest_peer() or b'')
398                if not nodeid:
399                    remote_nodeid.setText("")
400                    remote_nodeid.setPlaceholderText(
401                        "Please wait until the graph is synchronized to 30%, and then try again.")
402                else:
403                    remote_nodeid.setText(nodeid)
404                remote_nodeid.repaint()  # macOS hack for #6269
405            suggest_button.clicked.connect(on_suggest)
406        else:
407            from electrum.lnworker import hardcoded_trampoline_nodes
408            vbox.addWidget(QLabel(_('Choose a trampoline node to open a channel with')))
409            trampolines = hardcoded_trampoline_nodes()
410            trampoline_names = list(trampolines.keys())
411            trampoline_combo = QComboBox()
412            trampoline_combo.addItems(trampoline_names)
413            trampoline_combo.setCurrentIndex(1)
414
415        amount_e = BTCAmountEdit(self.parent.get_decimal_point)
416        # max button
417        def spend_max():
418            amount_e.setFrozen(max_button.isChecked())
419            if not max_button.isChecked():
420                return
421            dummy_nodeid = ecc.GENERATOR.get_public_key_bytes(compressed=True)
422            make_tx = self.parent.mktx_for_open_channel(funding_sat='!', node_id=dummy_nodeid)
423            try:
424                tx = make_tx(None)
425            except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
426                max_button.setChecked(False)
427                amount_e.setFrozen(False)
428                self.main_window.show_error(str(e))
429                return
430            amount = tx.output_value()
431            amount = min(amount, LN_MAX_FUNDING_SAT)
432            amount_e.setAmount(amount)
433        max_button = EnterButton(_("Max"), spend_max)
434        max_button.setFixedWidth(100)
435        max_button.setCheckable(True)
436
437        clear_button = QPushButton(d, text=_('Clear'))
438        def on_clear():
439            amount_e.setText('')
440            amount_e.setFrozen(False)
441            amount_e.repaint()  # macOS hack for #6269
442            if self.parent.network.channel_db:
443                remote_nodeid.setText('')
444                remote_nodeid.repaint()  # macOS hack for #6269
445            max_button.setChecked(False)
446            max_button.repaint()  # macOS hack for #6269
447        clear_button.clicked.connect(on_clear)
448        clear_button.setFixedWidth(100)
449        h = QGridLayout()
450        if self.parent.network.channel_db:
451            h.addWidget(QLabel(_('Remote Node ID')), 0, 0)
452            h.addWidget(remote_nodeid, 0, 1, 1, 4)
453            h.addWidget(suggest_button, 0, 5)
454        else:
455            h.addWidget(QLabel(_('Trampoline')), 0, 0)
456            h.addWidget(trampoline_combo, 0, 1, 1, 4)
457
458        h.addWidget(QLabel('Amount'), 2, 0)
459        h.addWidget(amount_e, 2, 1)
460        h.addWidget(max_button, 2, 2)
461        h.addWidget(clear_button, 2, 3)
462        vbox.addLayout(h)
463        vbox.addStretch()
464        ok_button = OkButton(d)
465        ok_button.setDefault(True)
466        vbox.addLayout(Buttons(CancelButton(d), ok_button))
467        if not d.exec_():
468            return
469        if max_button.isChecked() and amount_e.get_amount() < LN_MAX_FUNDING_SAT:
470            # if 'max' enabled and amount is strictly less than max allowed,
471            # that means we have fewer coins than max allowed, and hence we can
472            # spend all coins
473            funding_sat = '!'
474        else:
475            funding_sat = amount_e.get_amount()
476        if self.parent.network.channel_db:
477            connect_str = str(remote_nodeid.text()).strip()
478        else:
479            name = trampoline_names[trampoline_combo.currentIndex()]
480            connect_str = str(trampolines[name])
481        if not connect_str or not funding_sat:
482            return
483        self.parent.open_channel(connect_str, funding_sat, 0)
484
485    def swap_dialog(self):
486        from .swap_dialog import SwapDialog
487        d = SwapDialog(self.parent)
488        d.run()
489
490
491class ChannelFeature(ABC):
492    def __init__(self):
493        self.rect = QRect()
494
495    @abstractmethod
496    def tooltip(self) -> str:
497        pass
498
499    @abstractmethod
500    def icon(self) -> QIcon:
501        pass
502
503
504class ChanFeatChannel(ChannelFeature):
505    def tooltip(self) -> str:
506        return _("This is a channel")
507    def icon(self) -> QIcon:
508        return read_QIcon("lightning")
509
510
511class ChanFeatBackup(ChannelFeature):
512    def tooltip(self) -> str:
513        return _("This is a static channel backup")
514    def icon(self) -> QIcon:
515        return read_QIcon("lightning_disconnected")
516
517
518class ChanFeatTrampoline(ChannelFeature):
519    def tooltip(self) -> str:
520        return _("The channel peer can route Trampoline payments.")
521    def icon(self) -> QIcon:
522        return read_QIcon("kangaroo")
523
524
525class ChanFeatNoOnchainBackup(ChannelFeature):
526    def tooltip(self) -> str:
527        return _("This channel cannot be recovered from your seed. You must back it up manually.")
528    def icon(self) -> QIcon:
529        return read_QIcon("nocloud")
530
531
532class ChannelFeatureIcons:
533    ICON_SIZE = QSize(16, 16)
534
535    def __init__(self, features: Sequence['ChannelFeature']):
536        self.features = features
537
538    @classmethod
539    def from_channel(cls, chan: AbstractChannel) -> 'ChannelFeatureIcons':
540        feats = []
541        if chan.is_backup():
542            feats.append(ChanFeatBackup())
543            if chan.is_imported:
544                feats.append(ChanFeatNoOnchainBackup())
545        else:
546            feats.append(ChanFeatChannel())
547            if chan.lnworker.is_trampoline_peer(chan.node_id):
548                feats.append(ChanFeatTrampoline())
549            if not chan.has_onchain_backup():
550                feats.append(ChanFeatNoOnchainBackup())
551        return ChannelFeatureIcons(feats)
552
553    def paint(self, painter: QPainter, rect: QRect) -> None:
554        painter.save()
555        cur_x = rect.x()
556        for feat in self.features:
557            icon_rect = QRect(cur_x, rect.y(), self.ICON_SIZE.width(), self.ICON_SIZE.height())
558            feat.rect = icon_rect
559            if rect.contains(icon_rect):  # stay inside parent
560                painter.drawPixmap(icon_rect, feat.icon().pixmap(self.ICON_SIZE))
561            cur_x += self.ICON_SIZE.width() + 1
562        painter.restore()
563
564    def sizeHint(self, default_size: QSize) -> QSize:
565        if not self.features:
566            return default_size
567        width = len(self.features) * (self.ICON_SIZE.width() + 1)
568        return QSize(width, default_size.height())
569
570    def show_tooltip(self, evt: QHelpEvent) -> bool:
571        assert isinstance(evt, QHelpEvent)
572        for feat in self.features:
573            if feat.rect.contains(evt.pos()):
574                QToolTip.showText(evt.globalPos(), feat.tooltip())
575                break
576        else:
577            QToolTip.hideText()
578            evt.ignore()
579        return True
580