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