ttransaction_dialog.py - electrum - Electrum Bitcoin wallet
HTML git clone https://git.parazyd.org/electrum
DIR Log
DIR Files
DIR Refs
DIR Submodules
---
ttransaction_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()