electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

transaction_dialog.py (43064B)


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