1#!/usr/local/bin/python3.8
2#
3# Electrum - lightweight Bitcoin client
4# Copyright (C) 2012 thomasv@gitorious
5#
6# Permission is hereby granted, free of charge, to any person
7# obtaining a copy of this software and associated documentation files
8# (the "Software"), to deal in the Software without restriction,
9# including without limitation the rights to use, copy, modify, merge,
10# publish, distribute, sublicense, and/or sell copies of the Software,
11# and to permit persons to whom the Software is furnished to do so,
12# subject to the following conditions:
13#
14# The above copyright notice and this permission notice shall be
15# included in all copies or substantial portions of the Software.
16#
17# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
21# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
22# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
23# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24# SOFTWARE.
25
26import sys
27import copy
28import datetime
29import traceback
30import time
31from typing import TYPE_CHECKING, Callable, Optional, List, Union
32from functools import partial
33from decimal import Decimal
34
35from PyQt5.QtCore import QSize, Qt
36from PyQt5.QtGui import QTextCharFormat, QBrush, QFont, QPixmap
37from PyQt5.QtWidgets import (QDialog, QLabel, QPushButton, QHBoxLayout, QVBoxLayout, QWidget, QGridLayout,
38                             QTextEdit, QFrame, QAction, QToolButton, QMenu, QCheckBox)
39import qrcode
40from qrcode import exceptions
41
42from electrum.simple_config import SimpleConfig
43from electrum.util import quantize_feerate
44from electrum.bitcoin import base_encode, NLOCKTIME_BLOCKHEIGHT_MAX
45from electrum.i18n import _
46from electrum.plugin import run_hook
47from electrum import simple_config
48from electrum.transaction import SerializationError, Transaction, PartialTransaction, PartialTxInput
49from electrum.logging import get_logger
50
51from .util import (MessageBoxMixin, read_QIcon, Buttons, icon_path,
52                   MONOSPACE_FONT, ColorScheme, ButtonsLineEdit, text_dialog,
53                   char_width_in_lineedit, TRANSACTION_FILE_EXTENSION_FILTER_SEPARATE,
54                   TRANSACTION_FILE_EXTENSION_FILTER_ONLY_COMPLETE_TX,
55                   TRANSACTION_FILE_EXTENSION_FILTER_ONLY_PARTIAL_TX,
56                   BlockingWaitingDialog, getSaveFileName, ColorSchemeItem)
57
58from .fee_slider import FeeSlider, FeeComboBox
59from .confirm_tx_dialog import TxEditor
60from .amountedit import FeerateEdit, BTCAmountEdit
61from .locktimeedit import LockTimeEdit
62
63if TYPE_CHECKING:
64    from .main_window import ElectrumWindow
65
66
67class TxSizeLabel(QLabel):
68    def setAmount(self, byte_size):
69        self.setText(('x   %s bytes   =' % byte_size) if byte_size else '')
70
71class TxFiatLabel(QLabel):
72    def setAmount(self, fiat_fee):
73        self.setText(('≈  %s' % fiat_fee) if fiat_fee else '')
74
75class QTextEditWithDefaultSize(QTextEdit):
76    def sizeHint(self):
77        return QSize(0, 100)
78
79
80
81_logger = get_logger(__name__)
82dialogs = []  # Otherwise python randomly garbage collects the dialogs...
83
84
85def show_transaction(tx: Transaction, *, parent: 'ElectrumWindow', desc=None, prompt_if_unsaved=False):
86    try:
87        d = TxDialog(tx, parent=parent, desc=desc, prompt_if_unsaved=prompt_if_unsaved)
88    except SerializationError as e:
89        _logger.exception('unable to deserialize the transaction')
90        parent.show_critical(_("Electrum was unable to deserialize the transaction:") + "\n" + str(e))
91    else:
92        d.show()
93
94
95
96class BaseTxDialog(QDialog, MessageBoxMixin):
97
98    def __init__(self, *, parent: 'ElectrumWindow', desc, prompt_if_unsaved, finalized: bool, external_keypairs=None):
99        '''Transactions in the wallet will show their description.
100        Pass desc to give a description for txs not yet in the wallet.
101        '''
102        # We want to be a top-level window
103        QDialog.__init__(self, parent=None)
104        self.tx = None  # type: Optional[Transaction]
105        self.external_keypairs = external_keypairs
106        self.finalized = finalized
107        self.main_window = parent
108        self.config = parent.config
109        self.wallet = parent.wallet
110        self.prompt_if_unsaved = prompt_if_unsaved
111        self.saved = False
112        self.desc = desc
113        self.setMinimumWidth(640)
114        self.resize(1200,600)
115        self.set_title()
116
117        self.psbt_only_widgets = []  # type: List[QWidget]
118
119        vbox = QVBoxLayout()
120        self.setLayout(vbox)
121
122        vbox.addWidget(QLabel(_("Transaction ID:")))
123        self.tx_hash_e  = ButtonsLineEdit()
124        qr_show = lambda: parent.show_qrcode(str(self.tx_hash_e.text()), 'Transaction ID', parent=self)
125        qr_icon = "qrcode_white.png" if ColorScheme.dark_scheme else "qrcode.png"
126        self.tx_hash_e.addButton(qr_icon, qr_show, _("Show as QR code"))
127        self.tx_hash_e.setReadOnly(True)
128        vbox.addWidget(self.tx_hash_e)
129
130        self.add_tx_stats(vbox)
131
132        vbox.addSpacing(10)
133
134        self.inputs_header = QLabel()
135        vbox.addWidget(self.inputs_header)
136        self.inputs_textedit = QTextEditWithDefaultSize()
137        vbox.addWidget(self.inputs_textedit)
138
139        self.txo_color_recv = TxOutputColoring(
140            legend=_("Receiving Address"), color=ColorScheme.GREEN, tooltip=_("Wallet receive address"))
141        self.txo_color_change = TxOutputColoring(
142            legend=_("Change Address"), color=ColorScheme.YELLOW, tooltip=_("Wallet change address"))
143        self.txo_color_2fa = TxOutputColoring(
144            legend=_("TrustedCoin (2FA) batch fee"), color=ColorScheme.BLUE, tooltip=_("TrustedCoin (2FA) fee for the next batch of transactions"))
145
146        outheader_hbox = QHBoxLayout()
147        outheader_hbox.setContentsMargins(0, 0, 0, 0)
148        vbox.addLayout(outheader_hbox)
149        self.outputs_header = QLabel()
150        outheader_hbox.addWidget(self.outputs_header)
151        outheader_hbox.addStretch(2)
152        outheader_hbox.addWidget(self.txo_color_recv.legend_label)
153        outheader_hbox.addWidget(self.txo_color_change.legend_label)
154        outheader_hbox.addWidget(self.txo_color_2fa.legend_label)
155
156        self.outputs_textedit = QTextEditWithDefaultSize()
157        vbox.addWidget(self.outputs_textedit)
158
159        self.sign_button = b = QPushButton(_("Sign"))
160        b.clicked.connect(self.sign)
161
162        self.broadcast_button = b = QPushButton(_("Broadcast"))
163        b.clicked.connect(self.do_broadcast)
164
165        self.save_button = b = QPushButton(_("Save"))
166        b.clicked.connect(self.save)
167
168        self.cancel_button = b = QPushButton(_("Close"))
169        b.clicked.connect(self.close)
170        b.setDefault(True)
171
172        self.export_actions_menu = export_actions_menu = QMenu()
173        self.add_export_actions_to_menu(export_actions_menu)
174        export_actions_menu.addSeparator()
175        export_submenu = export_actions_menu.addMenu(_("For CoinJoin; strip privates"))
176        self.add_export_actions_to_menu(export_submenu, gettx=self._gettx_for_coinjoin)
177        self.psbt_only_widgets.append(export_submenu)
178        export_submenu = export_actions_menu.addMenu(_("For hardware device; include xpubs"))
179        self.add_export_actions_to_menu(export_submenu, gettx=self._gettx_for_hardware_device)
180        self.psbt_only_widgets.append(export_submenu)
181
182        self.export_actions_button = QToolButton()
183        self.export_actions_button.setText(_("Export"))
184        self.export_actions_button.setMenu(export_actions_menu)
185        self.export_actions_button.setPopupMode(QToolButton.InstantPopup)
186
187        self.finalize_button = QPushButton(_('Finalize'))
188        self.finalize_button.clicked.connect(self.on_finalize)
189
190        partial_tx_actions_menu = QMenu()
191        ptx_merge_sigs_action = QAction(_("Merge signatures from"), self)
192        ptx_merge_sigs_action.triggered.connect(self.merge_sigs)
193        partial_tx_actions_menu.addAction(ptx_merge_sigs_action)
194        self._ptx_join_txs_action = QAction(_("Join inputs/outputs"), self)
195        self._ptx_join_txs_action.triggered.connect(self.join_tx_with_another)
196        partial_tx_actions_menu.addAction(self._ptx_join_txs_action)
197        self.partial_tx_actions_button = QToolButton()
198        self.partial_tx_actions_button.setText(_("Combine"))
199        self.partial_tx_actions_button.setMenu(partial_tx_actions_menu)
200        self.partial_tx_actions_button.setPopupMode(QToolButton.InstantPopup)
201        self.psbt_only_widgets.append(self.partial_tx_actions_button)
202
203        # Action buttons
204        self.buttons = [self.partial_tx_actions_button, self.sign_button, self.broadcast_button, self.cancel_button]
205        # Transaction sharing buttons
206        self.sharing_buttons = [self.finalize_button, self.export_actions_button, self.save_button]
207        run_hook('transaction_dialog', self)
208        if not self.finalized:
209            self.create_fee_controls()
210            vbox.addWidget(self.feecontrol_fields)
211        self.hbox = hbox = QHBoxLayout()
212        hbox.addLayout(Buttons(*self.sharing_buttons))
213        hbox.addStretch(1)
214        hbox.addLayout(Buttons(*self.buttons))
215        vbox.addLayout(hbox)
216        self.set_buttons_visibility()
217
218        dialogs.append(self)
219
220    def set_buttons_visibility(self):
221        for b in [self.export_actions_button, self.save_button, self.sign_button, self.broadcast_button, self.partial_tx_actions_button]:
222            b.setVisible(self.finalized)
223        for b in [self.finalize_button]:
224            b.setVisible(not self.finalized)
225
226    def set_tx(self, tx: 'Transaction'):
227        # Take a copy; it might get updated in the main window by
228        # e.g. the FX plugin.  If this happens during or after a long
229        # sign operation the signatures are lost.
230        self.tx = tx = copy.deepcopy(tx)
231        try:
232            self.tx.deserialize()
233        except BaseException as e:
234            raise SerializationError(e)
235        # If the wallet can populate the inputs with more info, do it now.
236        # As a result, e.g. we might learn an imported address tx is segwit,
237        # or that a beyond-gap-limit address is is_mine.
238        # note: this might fetch prev txs over the network.
239        BlockingWaitingDialog(
240            self,
241            _("Adding info to tx, from wallet and network..."),
242            lambda: tx.add_info_from_wallet(self.wallet),
243        )
244
245    def do_broadcast(self):
246        self.main_window.push_top_level_window(self)
247        self.main_window.save_pending_invoice()
248        try:
249            self.main_window.broadcast_transaction(self.tx)
250        finally:
251            self.main_window.pop_top_level_window(self)
252        self.saved = True
253        self.update()
254
255    def closeEvent(self, event):
256        if (self.prompt_if_unsaved and not self.saved
257                and not self.question(_('This transaction is not saved. Close anyway?'), title=_("Warning"))):
258            event.ignore()
259        else:
260            event.accept()
261            try:
262                dialogs.remove(self)
263            except ValueError:
264                pass  # was not in list already
265
266    def reject(self):
267        # Override escape-key to close normally (and invoke closeEvent)
268        self.close()
269
270    def add_export_actions_to_menu(self, menu: QMenu, *, gettx: Callable[[], Transaction] = None) -> None:
271        if gettx is None:
272            gettx = lambda: None
273
274        action = QAction(_("Copy to clipboard"), self)
275        action.triggered.connect(lambda: self.copy_to_clipboard(tx=gettx()))
276        menu.addAction(action)
277
278        qr_icon = "qrcode_white.png" if ColorScheme.dark_scheme else "qrcode.png"
279        action = QAction(read_QIcon(qr_icon), _("Show as QR code"), self)
280        action.triggered.connect(lambda: self.show_qr(tx=gettx()))
281        menu.addAction(action)
282
283        action = QAction(_("Export to file"), self)
284        action.triggered.connect(lambda: self.export_to_file(tx=gettx()))
285        menu.addAction(action)
286
287    def _gettx_for_coinjoin(self) -> PartialTransaction:
288        if not isinstance(self.tx, PartialTransaction):
289            raise Exception("Can only export partial transactions for coinjoins.")
290        tx = copy.deepcopy(self.tx)
291        tx.prepare_for_export_for_coinjoin()
292        return tx
293
294    def _gettx_for_hardware_device(self) -> PartialTransaction:
295        if not isinstance(self.tx, PartialTransaction):
296            raise Exception("Can only export partial transactions for hardware device.")
297        tx = copy.deepcopy(self.tx)
298        tx.add_info_from_wallet(self.wallet, include_xpubs=True)
299        # log warning if PSBT_*_BIP32_DERIVATION fields cannot be filled with full path due to missing info
300        from electrum.keystore import Xpub
301        def is_ks_missing_info(ks):
302            return (isinstance(ks, Xpub) and (ks.get_root_fingerprint() is None
303                                              or ks.get_derivation_prefix() is None))
304        if any([is_ks_missing_info(ks) for ks in self.wallet.get_keystores()]):
305            _logger.warning('PSBT was requested to be filled with full bip32 paths but '
306                            'some keystores lacked either the derivation prefix or the root fingerprint')
307        return tx
308
309    def copy_to_clipboard(self, *, tx: Transaction = None):
310        if tx is None:
311            tx = self.tx
312        self.main_window.do_copy(str(tx), title=_("Transaction"))
313
314    def show_qr(self, *, tx: Transaction = None):
315        if tx is None:
316            tx = self.tx
317        qr_data = tx.to_qr_data()
318        try:
319            self.main_window.show_qrcode(qr_data, 'Transaction', parent=self)
320        except qrcode.exceptions.DataOverflowError:
321            self.show_error(_('Failed to display QR code.') + '\n' +
322                            _('Transaction is too large in size.'))
323        except Exception as e:
324            self.show_error(_('Failed to display QR code.') + '\n' + repr(e))
325
326    def sign(self):
327        def sign_done(success):
328            if self.tx.is_complete():
329                self.prompt_if_unsaved = True
330                self.saved = False
331            self.update()
332            self.main_window.pop_top_level_window(self)
333
334        self.sign_button.setDisabled(True)
335        self.main_window.push_top_level_window(self)
336        self.main_window.sign_tx(self.tx, callback=sign_done, external_keypairs=self.external_keypairs)
337
338    def save(self):
339        self.main_window.push_top_level_window(self)
340        if self.main_window.save_transaction_into_wallet(self.tx):
341            self.save_button.setDisabled(True)
342            self.saved = True
343        self.main_window.pop_top_level_window(self)
344
345    def export_to_file(self, *, tx: Transaction = None):
346        if tx is None:
347            tx = self.tx
348        if isinstance(tx, PartialTransaction):
349            tx.finalize_psbt()
350        txid = tx.txid()
351        suffix = txid[0:8] if txid is not None else time.strftime('%Y%m%d-%H%M')
352        if tx.is_complete():
353            extension = 'txn'
354            default_filter = TRANSACTION_FILE_EXTENSION_FILTER_ONLY_COMPLETE_TX
355        else:
356            extension = 'psbt'
357            default_filter = TRANSACTION_FILE_EXTENSION_FILTER_ONLY_PARTIAL_TX
358        name = f'{self.wallet.basename()}-{suffix}.{extension}'
359        fileName = getSaveFileName(
360            parent=self,
361            title=_("Select where to save your transaction"),
362            filename=name,
363            filter=TRANSACTION_FILE_EXTENSION_FILTER_SEPARATE,
364            default_extension=extension,
365            default_filter=default_filter,
366            config=self.config,
367        )
368        if not fileName:
369            return
370        if tx.is_complete():  # network tx hex
371            with open(fileName, "w+") as f:
372                network_tx_hex = tx.serialize_to_network()
373                f.write(network_tx_hex + '\n')
374        else:  # if partial: PSBT bytes
375            assert isinstance(tx, PartialTransaction)
376            with open(fileName, "wb+") as f:
377                f.write(tx.serialize_as_bytes())
378
379        self.show_message(_("Transaction exported successfully"))
380        self.saved = True
381
382    def merge_sigs(self):
383        if not isinstance(self.tx, PartialTransaction):
384            return
385        text = text_dialog(
386            parent=self,
387            title=_('Input raw transaction'),
388            header_layout=_("Transaction to merge signatures from") + ":",
389            ok_label=_("Load transaction"),
390            config=self.config,
391        )
392        if not text:
393            return
394        tx = self.main_window.tx_from_text(text)
395        if not tx:
396            return
397        try:
398            self.tx.combine_with_other_psbt(tx)
399        except Exception as e:
400            self.show_error(_("Error combining partial transactions") + ":\n" + repr(e))
401            return
402        self.update()
403
404    def join_tx_with_another(self):
405        if not isinstance(self.tx, PartialTransaction):
406            return
407        text = text_dialog(
408            parent=self,
409            title=_('Input raw transaction'),
410            header_layout=_("Transaction to join with") + " (" + _("add inputs and outputs") + "):",
411            ok_label=_("Load transaction"),
412            config=self.config,
413        )
414        if not text:
415            return
416        tx = self.main_window.tx_from_text(text)
417        if not tx:
418            return
419        try:
420            self.tx.join_with_other_psbt(tx)
421        except Exception as e:
422            self.show_error(_("Error joining partial transactions") + ":\n" + repr(e))
423            return
424        self.update()
425
426    def update(self):
427        if not self.finalized:
428            self.update_fee_fields()
429            self.finalize_button.setEnabled(self.can_finalize())
430        if self.tx is None:
431            return
432        self.update_io()
433        desc = self.desc
434        base_unit = self.main_window.base_unit()
435        format_amount = self.main_window.format_amount
436        format_fiat_and_units = self.main_window.format_fiat_and_units
437        tx_details = self.wallet.get_tx_info(self.tx)
438        tx_mined_status = tx_details.tx_mined_status
439        exp_n = tx_details.mempool_depth_bytes
440        amount, fee = tx_details.amount, tx_details.fee
441        size = self.tx.estimated_size()
442        txid = self.tx.txid()
443        fx = self.main_window.fx
444        tx_item_fiat = None
445        if (self.finalized  # ensures we don't use historical rates for tx being constructed *now*
446                and txid is not None and fx.is_enabled() and amount is not None):
447            tx_item_fiat = self.wallet.get_tx_item_fiat(
448                tx_hash=txid, amount_sat=abs(amount), fx=fx, tx_fee=fee)
449        lnworker_history = self.wallet.lnworker.get_onchain_history() if self.wallet.lnworker else {}
450        if txid in lnworker_history:
451            item = lnworker_history[txid]
452            ln_amount = item['amount_msat'] / 1000
453            if amount is None:
454                tx_mined_status = self.wallet.lnworker.lnwatcher.get_tx_height(txid)
455        else:
456            ln_amount = None
457        self.broadcast_button.setEnabled(tx_details.can_broadcast)
458        can_sign = not self.tx.is_complete() and \
459            (self.wallet.can_sign(self.tx) or bool(self.external_keypairs))
460        self.sign_button.setEnabled(can_sign)
461        if self.finalized and tx_details.txid:
462            self.tx_hash_e.setText(tx_details.txid)
463        else:
464            # note: when not finalized, RBF and locktime changes do not trigger
465            #       a make_tx, so the txid is unreliable, hence:
466            self.tx_hash_e.setText(_('Unknown'))
467        if not desc:
468            self.tx_desc.hide()
469        else:
470            self.tx_desc.setText(_("Description") + ': ' + desc)
471            self.tx_desc.show()
472        self.status_label.setText(_('Status:') + ' ' + tx_details.status)
473
474        if tx_mined_status.timestamp:
475            time_str = datetime.datetime.fromtimestamp(tx_mined_status.timestamp).isoformat(' ')[:-3]
476            self.date_label.setText(_("Date: {}").format(time_str))
477            self.date_label.show()
478        elif exp_n is not None:
479            text = '%.2f MB'%(exp_n/1000000)
480            self.date_label.setText(_('Position in mempool: {} from tip').format(text))
481            self.date_label.show()
482        else:
483            self.date_label.hide()
484        if self.tx.locktime <= NLOCKTIME_BLOCKHEIGHT_MAX:
485            locktime_final_str = f"LockTime: {self.tx.locktime} (height)"
486        else:
487            locktime_final_str = f"LockTime: {self.tx.locktime} ({datetime.datetime.fromtimestamp(self.tx.locktime)})"
488        self.locktime_final_label.setText(locktime_final_str)
489        if self.locktime_e.get_locktime() is None:
490            self.locktime_e.set_locktime(self.tx.locktime)
491        self.rbf_label.setText(_('Replace by fee') + f": {not self.tx.is_final()}")
492
493        if tx_mined_status.header_hash:
494            self.block_hash_label.setText(_("Included in block: {}")
495                                          .format(tx_mined_status.header_hash))
496            self.block_height_label.setText(_("At block height: {}")
497                                            .format(tx_mined_status.height))
498        else:
499            self.block_hash_label.hide()
500            self.block_height_label.hide()
501        if amount is None and ln_amount is None:
502            amount_str = _("Transaction unrelated to your wallet")
503        elif amount is None:
504            amount_str = ''
505        else:
506            if amount > 0:
507                amount_str = _("Amount received:") + ' %s'% format_amount(amount) + ' ' + base_unit
508            else:
509                amount_str = _("Amount sent:") + ' %s' % format_amount(-amount) + ' ' + base_unit
510            if fx.is_enabled():
511                if tx_item_fiat:
512                    amount_str += ' (%s)' % tx_item_fiat['fiat_value'].to_ui_string()
513                else:
514                    amount_str += ' (%s)' % format_fiat_and_units(abs(amount))
515        if amount_str:
516            self.amount_label.setText(amount_str)
517        else:
518            self.amount_label.hide()
519        size_str = _("Size:") + ' %d bytes'% size
520        if fee is None:
521            fee_str = _("Fee") + ': ' + _("unknown")
522        else:
523            fee_str = _("Fee") + f': {format_amount(fee)} {base_unit}'
524            if fx.is_enabled():
525                if tx_item_fiat:
526                    fiat_fee_str = tx_item_fiat['fiat_fee'].to_ui_string()
527                else:
528                    fiat_fee_str = format_fiat_and_units(fee)
529                fee_str += f' ({fiat_fee_str})'
530        if fee is not None:
531            fee_rate = Decimal(fee) / size  # sat/byte
532            fee_str += '  ( %s ) ' % self.main_window.format_fee_rate(fee_rate * 1000)
533            if isinstance(self.tx, PartialTransaction):
534                if isinstance(self, PreviewTxDialog):
535                    invoice_amt = self.tx.output_value() if self.output_value == '!' else self.output_value
536                else:
537                    invoice_amt = amount
538                fee_warning_tuple = self.wallet.get_tx_fee_warning(
539                    invoice_amt=invoice_amt, tx_size=size, fee=fee)
540                if fee_warning_tuple:
541                    allow_send, long_warning, short_warning = fee_warning_tuple
542                    fee_str += " - <font color={color}>{header}: {body}</font>".format(
543                        header=_('Warning'),
544                        body=short_warning,
545                        color=ColorScheme.RED.as_color().name(),
546                    )
547        if isinstance(self.tx, PartialTransaction):
548            risk_of_burning_coins = (can_sign and fee is not None
549                                     and self.wallet.get_warning_for_risk_of_burning_coins_as_fees(self.tx))
550            self.fee_warning_icon.setToolTip(str(risk_of_burning_coins))
551            self.fee_warning_icon.setVisible(bool(risk_of_burning_coins))
552        self.fee_label.setText(fee_str)
553        self.size_label.setText(size_str)
554        if ln_amount is None or ln_amount == 0:
555            ln_amount_str = ''
556        elif ln_amount > 0:
557            ln_amount_str = _('Amount received in channels') + ': ' + format_amount(ln_amount) + ' ' + base_unit
558        else:
559            assert ln_amount < 0, f"{ln_amount!r}"
560            ln_amount_str = _('Amount withdrawn from channels') + ': ' + format_amount(-ln_amount) + ' ' + base_unit
561        if ln_amount_str:
562            self.ln_amount_label.setText(ln_amount_str)
563        else:
564            self.ln_amount_label.hide()
565        show_psbt_only_widgets = self.finalized and isinstance(self.tx, PartialTransaction)
566        for widget in self.psbt_only_widgets:
567            if isinstance(widget, QMenu):
568                widget.menuAction().setVisible(show_psbt_only_widgets)
569            else:
570                widget.setVisible(show_psbt_only_widgets)
571        if tx_details.is_lightning_funding_tx:
572            self._ptx_join_txs_action.setEnabled(False)  # would change txid
573
574        self.save_button.setEnabled(tx_details.can_save_as_local)
575        if tx_details.can_save_as_local:
576            self.save_button.setToolTip(_("Save transaction offline"))
577        else:
578            self.save_button.setToolTip(_("Transaction already saved or not yet signed."))
579
580        run_hook('transaction_dialog_update', self)
581
582    def update_io(self):
583        inputs_header_text = _("Inputs") + ' (%d)'%len(self.tx.inputs())
584        if not self.finalized:
585            selected_coins = self.main_window.get_manually_selected_coins()
586            if selected_coins is not None:
587                inputs_header_text += f"  -  " + _("Coin selection active ({} UTXOs selected)").format(len(selected_coins))
588        self.inputs_header.setText(inputs_header_text)
589
590        ext = QTextCharFormat()
591        tf_used_recv, tf_used_change, tf_used_2fa = False, False, False
592        def text_format(addr):
593            nonlocal tf_used_recv, tf_used_change, tf_used_2fa
594            if self.wallet.is_mine(addr):
595                if self.wallet.is_change(addr):
596                    tf_used_change = True
597                    return self.txo_color_change.text_char_format
598                else:
599                    tf_used_recv = True
600                    return self.txo_color_recv.text_char_format
601            elif self.wallet.is_billing_address(addr):
602                tf_used_2fa = True
603                return self.txo_color_2fa.text_char_format
604            return ext
605
606        def format_amount(amt):
607            return self.main_window.format_amount(amt, whitespaces=True)
608
609        i_text = self.inputs_textedit
610        i_text.clear()
611        i_text.setFont(QFont(MONOSPACE_FONT))
612        i_text.setReadOnly(True)
613        cursor = i_text.textCursor()
614        for txin in self.tx.inputs():
615            if txin.is_coinbase_input():
616                cursor.insertText('coinbase')
617            else:
618                prevout_hash = txin.prevout.txid.hex()
619                prevout_n = txin.prevout.out_idx
620                cursor.insertText(prevout_hash + ":%-4d " % prevout_n, ext)
621                addr = self.wallet.get_txin_address(txin)
622                if addr is None:
623                    addr = ''
624                cursor.insertText(addr, text_format(addr))
625                txin_value = self.wallet.get_txin_value(txin)
626                if txin_value is not None:
627                    cursor.insertText(format_amount(txin_value), ext)
628            cursor.insertBlock()
629
630        self.outputs_header.setText(_("Outputs") + ' (%d)'%len(self.tx.outputs()))
631        o_text = self.outputs_textedit
632        o_text.clear()
633        o_text.setFont(QFont(MONOSPACE_FONT))
634        o_text.setReadOnly(True)
635        cursor = o_text.textCursor()
636        for o in self.tx.outputs():
637            addr, v = o.get_ui_address_str(), o.value
638            cursor.insertText(addr, text_format(addr))
639            if v is not None:
640                cursor.insertText('\t', ext)
641                cursor.insertText(format_amount(v), ext)
642            cursor.insertBlock()
643
644        self.txo_color_recv.legend_label.setVisible(tf_used_recv)
645        self.txo_color_change.legend_label.setVisible(tf_used_change)
646        self.txo_color_2fa.legend_label.setVisible(tf_used_2fa)
647
648    def add_tx_stats(self, vbox):
649        hbox_stats = QHBoxLayout()
650
651        # left column
652        vbox_left = QVBoxLayout()
653        self.tx_desc = TxDetailLabel(word_wrap=True)
654        vbox_left.addWidget(self.tx_desc)
655        self.status_label = TxDetailLabel()
656        vbox_left.addWidget(self.status_label)
657        self.date_label = TxDetailLabel()
658        vbox_left.addWidget(self.date_label)
659        self.amount_label = TxDetailLabel()
660        vbox_left.addWidget(self.amount_label)
661        self.ln_amount_label = TxDetailLabel()
662        vbox_left.addWidget(self.ln_amount_label)
663
664        fee_hbox = QHBoxLayout()
665        self.fee_label = TxDetailLabel()
666        fee_hbox.addWidget(self.fee_label)
667        self.fee_warning_icon = QLabel()
668        pixmap = QPixmap(icon_path("warning"))
669        pixmap_size = round(2 * char_width_in_lineedit())
670        pixmap = pixmap.scaled(pixmap_size, pixmap_size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
671        self.fee_warning_icon.setPixmap(pixmap)
672        self.fee_warning_icon.setVisible(False)
673        fee_hbox.addWidget(self.fee_warning_icon)
674        fee_hbox.addStretch(1)
675        vbox_left.addLayout(fee_hbox)
676
677        vbox_left.addStretch(1)
678        hbox_stats.addLayout(vbox_left, 50)
679
680        # vertical line separator
681        line_separator = QFrame()
682        line_separator.setFrameShape(QFrame.VLine)
683        line_separator.setFrameShadow(QFrame.Sunken)
684        line_separator.setLineWidth(1)
685        hbox_stats.addWidget(line_separator)
686
687        # right column
688        vbox_right = QVBoxLayout()
689        self.size_label = TxDetailLabel()
690        vbox_right.addWidget(self.size_label)
691        self.rbf_label = TxDetailLabel()
692        vbox_right.addWidget(self.rbf_label)
693        self.rbf_cb = QCheckBox(_('Replace by fee'))
694        self.rbf_cb.setChecked(bool(self.config.get('use_rbf', True)))
695        vbox_right.addWidget(self.rbf_cb)
696
697        self.locktime_final_label = TxDetailLabel()
698        vbox_right.addWidget(self.locktime_final_label)
699
700        locktime_setter_hbox = QHBoxLayout()
701        locktime_setter_hbox.setContentsMargins(0, 0, 0, 0)
702        locktime_setter_hbox.setSpacing(0)
703        locktime_setter_label = TxDetailLabel()
704        locktime_setter_label.setText("LockTime: ")
705        self.locktime_e = LockTimeEdit(self)
706        locktime_setter_hbox.addWidget(locktime_setter_label)
707        locktime_setter_hbox.addWidget(self.locktime_e)
708        locktime_setter_hbox.addStretch(1)
709        self.locktime_setter_widget = QWidget()
710        self.locktime_setter_widget.setLayout(locktime_setter_hbox)
711        vbox_right.addWidget(self.locktime_setter_widget)
712
713        self.block_height_label = TxDetailLabel()
714        vbox_right.addWidget(self.block_height_label)
715        vbox_right.addStretch(1)
716        hbox_stats.addLayout(vbox_right, 50)
717
718        vbox.addLayout(hbox_stats)
719
720        # below columns
721        self.block_hash_label = TxDetailLabel(word_wrap=True)
722        vbox.addWidget(self.block_hash_label)
723
724        # set visibility after parenting can be determined by Qt
725        self.rbf_label.setVisible(self.finalized)
726        self.rbf_cb.setVisible(not self.finalized)
727        self.locktime_final_label.setVisible(self.finalized)
728        self.locktime_setter_widget.setVisible(not self.finalized)
729
730    def set_title(self):
731        self.setWindowTitle(_("Create transaction") if not self.finalized else _("Transaction"))
732
733    def can_finalize(self) -> bool:
734        return False
735
736    def on_finalize(self):
737        pass  # overridden in subclass
738
739    def update_fee_fields(self):
740        pass  # overridden in subclass
741
742
743class TxDetailLabel(QLabel):
744    def __init__(self, *, word_wrap=None):
745        super().__init__()
746        self.setTextInteractionFlags(Qt.TextSelectableByMouse)
747        if word_wrap is not None:
748            self.setWordWrap(word_wrap)
749
750
751class TxOutputColoring:
752    # used for both inputs and outputs
753
754    def __init__(
755            self,
756            *,
757            legend: str,
758            color: ColorSchemeItem,
759            tooltip: str,
760    ):
761        self.color = color.as_color(background=True)
762        self.legend_label = QLabel("<font color={color}>{box_char}</font> = {label}".format(
763            color=self.color.name(),
764            box_char="█",
765            label=legend,
766        ))
767        font = self.legend_label.font()
768        font.setPointSize(font.pointSize() - 1)
769        self.legend_label.setFont(font)
770        self.legend_label.setVisible(False)
771        self.text_char_format = QTextCharFormat()
772        self.text_char_format.setBackground(QBrush(self.color))
773        self.text_char_format.setToolTip(tooltip)
774
775
776class TxDialog(BaseTxDialog):
777    def __init__(self, tx: Transaction, *, parent: 'ElectrumWindow', desc, prompt_if_unsaved):
778        BaseTxDialog.__init__(self, parent=parent, desc=desc, prompt_if_unsaved=prompt_if_unsaved, finalized=True)
779        self.set_tx(tx)
780        self.update()
781
782
783class PreviewTxDialog(BaseTxDialog, TxEditor):
784
785    def __init__(
786            self,
787            *,
788            make_tx,
789            external_keypairs,
790            window: 'ElectrumWindow',
791            output_value: Union[int, str],
792    ):
793        TxEditor.__init__(
794            self,
795            window=window,
796            make_tx=make_tx,
797            is_sweep=bool(external_keypairs),
798            output_value=output_value,
799        )
800        BaseTxDialog.__init__(self, parent=window, desc='', prompt_if_unsaved=False,
801                              finalized=False, external_keypairs=external_keypairs)
802        BlockingWaitingDialog(window, _("Preparing transaction..."),
803                              lambda: self.update_tx(fallback_to_zero_fee=True))
804        self.update()
805
806    def create_fee_controls(self):
807
808        self.size_e = TxSizeLabel()
809        self.size_e.setAlignment(Qt.AlignCenter)
810        self.size_e.setAmount(0)
811        self.size_e.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
812
813        self.fiat_fee_label = TxFiatLabel()
814        self.fiat_fee_label.setAlignment(Qt.AlignCenter)
815        self.fiat_fee_label.setAmount(0)
816        self.fiat_fee_label.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
817
818        self.feerate_e = FeerateEdit(lambda: 0)
819        self.feerate_e.setAmount(self.config.fee_per_byte())
820        self.feerate_e.textEdited.connect(partial(self.on_fee_or_feerate, self.feerate_e, False))
821        self.feerate_e.editingFinished.connect(partial(self.on_fee_or_feerate, self.feerate_e, True))
822
823        self.fee_e = BTCAmountEdit(self.main_window.get_decimal_point)
824        self.fee_e.textEdited.connect(partial(self.on_fee_or_feerate, self.fee_e, False))
825        self.fee_e.editingFinished.connect(partial(self.on_fee_or_feerate, self.fee_e, True))
826
827        self.fee_e.textChanged.connect(self.entry_changed)
828        self.feerate_e.textChanged.connect(self.entry_changed)
829
830        self.fee_slider = FeeSlider(self, self.config, self.fee_slider_callback)
831        self.fee_combo = FeeComboBox(self.fee_slider)
832        self.fee_slider.setFixedWidth(self.fee_e.width())
833
834        def feerounding_onclick():
835            text = (self.feerounding_text + '\n\n' +
836                    _('To somewhat protect your privacy, Electrum tries to create change with similar precision to other outputs.') + ' ' +
837                    _('At most 100 satoshis might be lost due to this rounding.') + ' ' +
838                    _("You can disable this setting in '{}'.").format(_('Preferences')) + '\n' +
839                    _('Also, dust is not kept as change, but added to the fee.')  + '\n' +
840                    _('Also, when batching RBF transactions, BIP 125 imposes a lower bound on the fee.'))
841            self.show_message(title=_('Fee rounding'), msg=text)
842
843        self.feerounding_icon = QToolButton()
844        self.feerounding_icon.setIcon(read_QIcon('info.png'))
845        self.feerounding_icon.setAutoRaise(True)
846        self.feerounding_icon.clicked.connect(feerounding_onclick)
847        self.feerounding_icon.setVisible(False)
848
849        self.feecontrol_fields = QWidget()
850        hbox = QHBoxLayout(self.feecontrol_fields)
851        hbox.setContentsMargins(0, 0, 0, 0)
852        grid = QGridLayout()
853        grid.addWidget(QLabel(_("Target fee:")), 0, 0)
854        grid.addWidget(self.feerate_e, 0, 1)
855        grid.addWidget(self.size_e, 0, 2)
856        grid.addWidget(self.fee_e, 0, 3)
857        grid.addWidget(self.feerounding_icon, 0, 4)
858        grid.addWidget(self.fiat_fee_label, 0, 5)
859        grid.addWidget(self.fee_slider, 1, 1)
860        grid.addWidget(self.fee_combo, 1, 2)
861        hbox.addLayout(grid)
862        hbox.addStretch(1)
863
864    def fee_slider_callback(self, dyn, pos, fee_rate):
865        super().fee_slider_callback(dyn, pos, fee_rate)
866        self.fee_slider.activate()
867        if fee_rate:
868            fee_rate = Decimal(fee_rate)
869            self.feerate_e.setAmount(quantize_feerate(fee_rate / 1000))
870        else:
871            self.feerate_e.setAmount(None)
872        self.fee_e.setModified(False)
873
874    def on_fee_or_feerate(self, edit_changed, editing_finished):
875        edit_other = self.feerate_e if edit_changed == self.fee_e else self.fee_e
876        if editing_finished:
877            if edit_changed.get_amount() is None:
878                # This is so that when the user blanks the fee and moves on,
879                # we go back to auto-calculate mode and put a fee back.
880                edit_changed.setModified(False)
881        else:
882            # edit_changed was edited just now, so make sure we will
883            # freeze the correct fee setting (this)
884            edit_other.setModified(False)
885        self.fee_slider.deactivate()
886        self.update()
887
888    def is_send_fee_frozen(self):
889        return self.fee_e.isVisible() and self.fee_e.isModified() \
890               and (self.fee_e.text() or self.fee_e.hasFocus())
891
892    def is_send_feerate_frozen(self):
893        return self.feerate_e.isVisible() and self.feerate_e.isModified() \
894               and (self.feerate_e.text() or self.feerate_e.hasFocus())
895
896    def set_feerounding_text(self, num_satoshis_added):
897        self.feerounding_text = (_('Additional {} satoshis are going to be added.')
898                                 .format(num_satoshis_added))
899
900    def get_fee_estimator(self):
901        if self.is_send_fee_frozen() and self.fee_e.get_amount() is not None:
902            fee_estimator = self.fee_e.get_amount()
903        elif self.is_send_feerate_frozen() and self.feerate_e.get_amount() is not None:
904            amount = self.feerate_e.get_amount()  # sat/byte feerate
905            amount = 0 if amount is None else amount * 1000  # sat/kilobyte feerate
906            fee_estimator = partial(
907                SimpleConfig.estimate_fee_for_feerate, amount)
908        else:
909            fee_estimator = None
910        return fee_estimator
911
912    def entry_changed(self):
913        # blue color denotes auto-filled values
914        text = ""
915        fee_color = ColorScheme.DEFAULT
916        feerate_color = ColorScheme.DEFAULT
917        if self.not_enough_funds:
918            fee_color = ColorScheme.RED
919            feerate_color = ColorScheme.RED
920        elif self.fee_e.isModified():
921            feerate_color = ColorScheme.BLUE
922        elif self.feerate_e.isModified():
923            fee_color = ColorScheme.BLUE
924        else:
925            fee_color = ColorScheme.BLUE
926            feerate_color = ColorScheme.BLUE
927        self.fee_e.setStyleSheet(fee_color.as_stylesheet())
928        self.feerate_e.setStyleSheet(feerate_color.as_stylesheet())
929        #
930        self.needs_update = True
931
932    def update_fee_fields(self):
933        freeze_fee = self.is_send_fee_frozen()
934        freeze_feerate = self.is_send_feerate_frozen()
935        tx = self.tx
936        if self.no_dynfee_estimates and tx:
937            size = tx.estimated_size()
938            self.size_e.setAmount(size)
939        if self.not_enough_funds or self.no_dynfee_estimates:
940            if not freeze_fee:
941                self.fee_e.setAmount(None)
942            if not freeze_feerate:
943                self.feerate_e.setAmount(None)
944            self.feerounding_icon.setVisible(False)
945            return
946
947        assert tx is not None
948        size = tx.estimated_size()
949        fee = tx.get_fee()
950
951        self.size_e.setAmount(size)
952        fiat_fee = self.main_window.format_fiat_and_units(fee)
953        self.fiat_fee_label.setAmount(fiat_fee)
954
955        # Displayed fee/fee_rate values are set according to user input.
956        # Due to rounding or dropping dust in CoinChooser,
957        # actual fees often differ somewhat.
958        if freeze_feerate or self.fee_slider.is_active():
959            displayed_feerate = self.feerate_e.get_amount()
960            if displayed_feerate is not None:
961                displayed_feerate = quantize_feerate(displayed_feerate)
962            elif self.fee_slider.is_active():
963                # fallback to actual fee
964                displayed_feerate = quantize_feerate(fee / size) if fee is not None else None
965                self.feerate_e.setAmount(displayed_feerate)
966            displayed_fee = round(displayed_feerate * size) if displayed_feerate is not None else None
967            self.fee_e.setAmount(displayed_fee)
968        else:
969            if freeze_fee:
970                displayed_fee = self.fee_e.get_amount()
971            else:
972                # fallback to actual fee if nothing is frozen
973                displayed_fee = fee
974                self.fee_e.setAmount(displayed_fee)
975            displayed_fee = displayed_fee if displayed_fee else 0
976            displayed_feerate = quantize_feerate(displayed_fee / size) if displayed_fee is not None else None
977            self.feerate_e.setAmount(displayed_feerate)
978
979        # show/hide fee rounding icon
980        feerounding = (fee - displayed_fee) if (fee and displayed_fee is not None) else 0
981        self.set_feerounding_text(int(feerounding))
982        self.feerounding_icon.setToolTip(self.feerounding_text)
983        self.feerounding_icon.setVisible(abs(feerounding) >= 1)
984
985    def can_finalize(self):
986        return (self.tx is not None
987                and not self.not_enough_funds)
988
989    def on_finalize(self):
990        if not self.can_finalize():
991            return
992        assert self.tx
993        self.finalized = True
994        self.tx.set_rbf(self.rbf_cb.isChecked())
995        locktime = self.locktime_e.get_locktime()
996        if locktime is not None:
997            self.tx.locktime = locktime
998        for widget in [self.fee_slider, self.fee_combo, self.feecontrol_fields, self.rbf_cb,
999                       self.locktime_setter_widget, self.locktime_e]:
1000            widget.setEnabled(False)
1001            widget.setVisible(False)
1002        for widget in [self.rbf_label, self.locktime_final_label]:
1003            widget.setVisible(True)
1004        self.set_title()
1005        self.set_buttons_visibility()
1006        self.update()
1007