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