From bc3946d2f4e00abdfc8bcd2980ea4a467a2e9756 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Wed, 14 Dec 2022 17:16:15 +0100 Subject: [PATCH] Qt: new onchain tx creation flow: - transaction_dialog is read-only - ConfirmTxDialog and RBF dialogs inherit from TxEditor - TxEditors are configurable --- electrum/gui/qt/confirm_tx_dialog.py | 593 ++++++++++++++++++++------ electrum/gui/qt/fee_slider.py | 22 +- electrum/gui/qt/main_window.py | 15 +- electrum/gui/qt/rbf_dialog.py | 152 +++---- electrum/gui/qt/send_tab.py | 50 +-- electrum/gui/qt/settings_dialog.py | 8 - electrum/gui/qt/transaction_dialog.py | 269 +----------- electrum/wallet.py | 2 +- 8 files changed, 583 insertions(+), 528 deletions(-) diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py index d06723742..ab240050c 100644 --- a/electrum/gui/qt/confirm_tx_dialog.py +++ b/electrum/gui/qt/confirm_tx_dialog.py @@ -24,31 +24,45 @@ # SOFTWARE. from decimal import Decimal +from functools import partial from typing import TYPE_CHECKING, Optional, Union from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QVBoxLayout, QLabel, QGridLayout, QPushButton, QLineEdit +from PyQt5.QtGui import QIcon + +from PyQt5.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QLineEdit, QToolButton, QMenu from electrum.i18n import _ from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates +from electrum.util import quantize_feerate from electrum.plugin import run_hook from electrum.transaction import Transaction, PartialTransaction from electrum.wallet import InternalAddressCorruption +from electrum.simple_config import SimpleConfig from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton, - BlockingWaitingDialog, PasswordLineEdit, WWLabel) + BlockingWaitingDialog, PasswordLineEdit, WWLabel, read_QIcon) from .fee_slider import FeeSlider, FeeComboBox if TYPE_CHECKING: from .main_window import ElectrumWindow +from .transaction_dialog import TxSizeLabel, TxFiatLabel, TxInOutWidget +from .fee_slider import FeeSlider, FeeComboBox +from .amountedit import FeerateEdit, BTCAmountEdit +from .locktimeedit import LockTimeEdit + +class TxEditor(WindowModalDialog): -class TxEditor: + def __init__(self, *, title='', + window: 'ElectrumWindow', + make_tx, + output_value: Union[int, str] = None, + allow_preview=True): - def __init__(self, *, window: 'ElectrumWindow', make_tx, - output_value: Union[int, str] = None, is_sweep: bool): + WindowModalDialog.__init__(self, window, title=title) self.main_window = window self.make_tx = make_tx self.output_value = output_value @@ -58,9 +72,40 @@ class TxEditor: self.not_enough_funds = False self.no_dynfee_estimates = False self.needs_update = False - self.password_required = self.wallet.has_keystore_encryption() and not is_sweep + # preview is disabled for lightning channel funding + self.allow_preview = allow_preview + self.is_preview = False + + self.locktime_e = LockTimeEdit(self) + self.locktime_label = QLabel(_("LockTime") + ": ") + self.io_widget = TxInOutWidget(self.main_window, self.wallet) + self.create_fee_controls() + + vbox = QVBoxLayout() + self.setLayout(vbox) + + top = self.create_top_bar(self.help_text) + grid = self.create_grid() + + vbox.addLayout(top) + vbox.addLayout(grid) + self.message_label = WWLabel('\n') + vbox.addWidget(self.message_label) + vbox.addWidget(self.io_widget) + buttons = self.create_buttons_bar() + vbox.addStretch(1) + vbox.addLayout(buttons) + + self.set_io_visible(self.config.get('show_tx_io', False)) + self.set_fee_edit_visible(self.config.get('show_fee_details', False)) + self.set_locktime_visible(self.config.get('show_locktime', False)) + self.set_preview_visible(self.config.get('show_preview_button', False)) + self.update_fee_target() + self.resize(self.layout().sizeHint()) + self.main_window.gui_object.timer.timeout.connect(self.timer_actions) + def timer_actions(self): if self.needs_update: self.update_tx() @@ -70,7 +115,7 @@ class TxEditor: def stop_editor_updates(self): self.main_window.gui_object.timer.timeout.disconnect(self.timer_actions) - def fee_slider_callback(self, dyn, pos, fee_rate): + def set_fee_config(self, dyn, pos, fee_rate): if dyn: if self.config.use_mempool_fees(): self.config.set_key('depth_level', pos, False) @@ -78,10 +123,399 @@ class TxEditor: self.config.set_key('fee_level', pos, False) else: self.config.set_key('fee_per_kb', fee_rate, False) + + def update_tx(self, *, fallback_to_zero_fee: bool = False): + raise NotImplementedError() + + def update_fee_target(self): + text = self.fee_slider.get_dynfee_target() + self.fee_target.setText(text) + self.fee_target.setVisible(bool(text)) # hide in static mode + + def update_feerate_label(self): + self.feerate_label.setText(self.feerate_e.text() + ' ' + self.feerate_e.base_unit()) + + def create_fee_controls(self): + + self.fee_label = QLabel('') + self.fee_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + + self.size_label = TxSizeLabel() + self.size_label.setAlignment(Qt.AlignCenter) + self.size_label.setAmount(0) + self.size_label.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet()) + + self.feerate_label = QLabel('') + self.feerate_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + + self.fiat_fee_label = TxFiatLabel() + self.fiat_fee_label.setAlignment(Qt.AlignCenter) + self.fiat_fee_label.setAmount(0) + self.fiat_fee_label.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet()) + + self.feerate_e = FeerateEdit(lambda: 0) + self.feerate_e.setAmount(self.config.fee_per_byte()) + self.feerate_e.textEdited.connect(partial(self.on_fee_or_feerate, self.feerate_e, False)) + self.feerate_e.editingFinished.connect(partial(self.on_fee_or_feerate, self.feerate_e, True)) + self.update_feerate_label() + + self.fee_e = BTCAmountEdit(self.main_window.get_decimal_point) + self.fee_e.textEdited.connect(partial(self.on_fee_or_feerate, self.fee_e, False)) + self.fee_e.editingFinished.connect(partial(self.on_fee_or_feerate, self.fee_e, True)) + + self.feerate_e.setFixedWidth(150) + self.fee_e.setFixedWidth(150) + + self.fee_e.textChanged.connect(self.entry_changed) + self.feerate_e.textChanged.connect(self.entry_changed) + + self.fee_target = QLabel('') + self.fee_slider = FeeSlider(self, self.config, self.fee_slider_callback) + self.fee_combo = FeeComboBox(self.fee_slider) + + def feerounding_onclick(): + text = (self.feerounding_text + '\n\n' + + _('To somewhat protect your privacy, Electrum tries to create change with similar precision to other outputs.') + ' ' + + _('At most 100 satoshis might be lost due to this rounding.') + ' ' + + _("You can disable this setting in '{}'.").format(_('Preferences')) + '\n' + + _('Also, dust is not kept as change, but added to the fee.') + '\n' + + _('Also, when batching RBF transactions, BIP 125 imposes a lower bound on the fee.')) + self.show_message(title=_('Fee rounding'), msg=text) + + self.feerounding_icon = QToolButton() + self.feerounding_icon.setIcon(QIcon()) + self.feerounding_icon.setAutoRaise(True) + self.feerounding_icon.clicked.connect(feerounding_onclick) + + self.fee_hbox = fee_hbox = QHBoxLayout() + fee_hbox.addWidget(self.feerate_e) + fee_hbox.addWidget(self.feerate_label) + fee_hbox.addWidget(self.size_label) + fee_hbox.addWidget(self.fee_e) + fee_hbox.addWidget(self.fee_label) + fee_hbox.addWidget(self.fiat_fee_label) + fee_hbox.addWidget(self.feerounding_icon) + fee_hbox.addStretch() + + self.fee_target_hbox = fee_target_hbox = QHBoxLayout() + fee_target_hbox.addWidget(self.fee_target) + fee_target_hbox.addWidget(self.fee_slider) + fee_target_hbox.addWidget(self.fee_combo) + fee_target_hbox.addStretch() + + # set feerate_label to same size as feerate_e + self.feerate_label.setFixedSize(self.feerate_e.sizeHint()) + self.fee_label.setFixedSize(self.fee_e.sizeHint()) + self.fee_slider.setFixedWidth(200) + self.fee_target.setFixedSize(self.feerate_e.sizeHint()) + + def _trigger_update(self): + # set tx to None so that the ok button is disabled while we compute the new tx + self.tx = None + self.update() self.needs_update = True + def fee_slider_callback(self, dyn, pos, fee_rate): + self.set_fee_config(dyn, pos, fee_rate) + self.fee_slider.activate() + if fee_rate: + fee_rate = Decimal(fee_rate) + self.feerate_e.setAmount(quantize_feerate(fee_rate / 1000)) + else: + self.feerate_e.setAmount(None) + self.fee_e.setModified(False) + self.update_fee_target() + self.update_feerate_label() + self._trigger_update() + + def on_fee_or_feerate(self, edit_changed, editing_finished): + edit_other = self.feerate_e if edit_changed == self.fee_e else self.fee_e + if editing_finished: + if edit_changed.get_amount() is None: + # This is so that when the user blanks the fee and moves on, + # we go back to auto-calculate mode and put a fee back. + edit_changed.setModified(False) + else: + # edit_changed was edited just now, so make sure we will + # freeze the correct fee setting (this) + edit_other.setModified(False) + self.fee_slider.deactivate() + self._trigger_update() + + def is_send_fee_frozen(self): + return self.fee_e.isVisible() and self.fee_e.isModified() \ + and (self.fee_e.text() or self.fee_e.hasFocus()) + + def is_send_feerate_frozen(self): + return self.feerate_e.isVisible() and self.feerate_e.isModified() \ + and (self.feerate_e.text() or self.feerate_e.hasFocus()) + + def set_feerounding_text(self, num_satoshis_added): + self.feerounding_text = (_('Additional {} satoshis are going to be added.') + .format(num_satoshis_added)) + def get_fee_estimator(self): - return None + if self.is_send_fee_frozen() and self.fee_e.get_amount() is not None: + fee_estimator = self.fee_e.get_amount() + elif self.is_send_feerate_frozen() and self.feerate_e.get_amount() is not None: + amount = self.feerate_e.get_amount() # sat/byte feerate + amount = 0 if amount is None else amount * 1000 # sat/kilobyte feerate + fee_estimator = partial( + SimpleConfig.estimate_fee_for_feerate, amount) + else: + fee_estimator = None + return fee_estimator + + def entry_changed(self): + # blue color denotes auto-filled values + text = "" + fee_color = ColorScheme.DEFAULT + feerate_color = ColorScheme.DEFAULT + if self.not_enough_funds: + fee_color = ColorScheme.RED + feerate_color = ColorScheme.RED + elif self.fee_e.isModified(): + feerate_color = ColorScheme.BLUE + elif self.feerate_e.isModified(): + fee_color = ColorScheme.BLUE + else: + fee_color = ColorScheme.BLUE + feerate_color = ColorScheme.BLUE + self.fee_e.setStyleSheet(fee_color.as_stylesheet()) + self.feerate_e.setStyleSheet(feerate_color.as_stylesheet()) + # + self.needs_update = True + + def update_fee_fields(self): + freeze_fee = self.is_send_fee_frozen() + freeze_feerate = self.is_send_feerate_frozen() + tx = self.tx + if self.no_dynfee_estimates and tx: + size = tx.estimated_size() + self.size_label.setAmount(size) + #self.size_e.setAmount(size) + if self.not_enough_funds or self.no_dynfee_estimates: + if not freeze_fee: + self.fee_e.setAmount(None) + if not freeze_feerate: + self.feerate_e.setAmount(None) + self.feerounding_icon.setIcon(QIcon()) + return + + assert tx is not None + size = tx.estimated_size() + fee = tx.get_fee() + + #self.size_e.setAmount(size) + self.size_label.setAmount(size) + fiat_fee = self.main_window.format_fiat_and_units(fee) + self.fiat_fee_label.setAmount(fiat_fee) + + # Displayed fee/fee_rate values are set according to user input. + # Due to rounding or dropping dust in CoinChooser, + # actual fees often differ somewhat. + if freeze_feerate or self.fee_slider.is_active(): + displayed_feerate = self.feerate_e.get_amount() + if displayed_feerate is not None: + displayed_feerate = quantize_feerate(displayed_feerate) + elif self.fee_slider.is_active(): + # fallback to actual fee + displayed_feerate = quantize_feerate(fee / size) if fee is not None else None + self.feerate_e.setAmount(displayed_feerate) + displayed_fee = round(displayed_feerate * size) if displayed_feerate is not None else None + self.fee_e.setAmount(displayed_fee) + else: + if freeze_fee: + displayed_fee = self.fee_e.get_amount() + else: + # fallback to actual fee if nothing is frozen + displayed_fee = fee + self.fee_e.setAmount(displayed_fee) + displayed_fee = displayed_fee if displayed_fee else 0 + displayed_feerate = quantize_feerate(displayed_fee / size) if displayed_fee is not None else None + self.feerate_e.setAmount(displayed_feerate) + + # set fee rounding icon to empty if there is no rounding + feerounding = (fee - displayed_fee) if (fee and displayed_fee is not None) else 0 + self.set_feerounding_text(int(feerounding)) + self.feerounding_icon.setToolTip(self.feerounding_text) + self.feerounding_icon.setIcon(read_QIcon('info.png') if abs(feerounding) >= 1 else QIcon()) + + + def create_buttons_bar(self): + self.preview_button = QPushButton(_('Preview')) + self.preview_button.clicked.connect(self.on_preview) + self.ok_button = QPushButton(_('OK')) + self.ok_button.clicked.connect(self.on_send) + self.ok_button.setDefault(True) + buttons = Buttons(CancelButton(self), self.preview_button, self.ok_button) + return buttons + + def create_top_bar(self, text): + self.pref_menu = QMenu() + self.m1 = self.pref_menu.addAction('Show inputs/outputs', self.toggle_io_visibility) + self.m1.setCheckable(True) + self.m2 = self.pref_menu.addAction('Edit fees', self.toggle_fee_details) + self.m2.setCheckable(True) + self.m3 = self.pref_menu.addAction('Edit Locktime', self.toggle_locktime) + self.m3.setCheckable(True) + self.m4 = self.pref_menu.addAction('Show Preview Button', self.toggle_preview_button) + self.m4.setCheckable(True) + self.m4.setEnabled(self.allow_preview) + self.pref_button = QToolButton() + self.pref_button.setIcon(read_QIcon("preferences.png")) + self.pref_button.setMenu(self.pref_menu) + self.pref_button.setPopupMode(QToolButton.InstantPopup) + hbox = QHBoxLayout() + hbox.addWidget(QLabel(text)) + hbox.addStretch() + hbox.addWidget(self.pref_button) + return hbox + + def toggle_io_visibility(self): + b = not self.config.get('show_tx_io', False) + self.config.set_key('show_tx_io', b) + self.set_io_visible(b) + #self.resize(self.layout().sizeHint()) + self.setFixedSize(self.layout().sizeHint()) + + def toggle_fee_details(self): + b = not self.config.get('show_fee_details', False) + self.config.set_key('show_fee_details', b) + self.set_fee_edit_visible(b) + self.setFixedSize(self.layout().sizeHint()) + + def toggle_locktime(self): + b = not self.config.get('show_locktime', False) + self.config.set_key('show_locktime', b) + self.set_locktime_visible(b) + self.setFixedSize(self.layout().sizeHint()) + + def toggle_preview_button(self): + b = not self.config.get('show_preview_button', False) + self.config.set_key('show_preview_button', b) + self.set_preview_visible(b) + + def set_preview_visible(self, b): + b = b and self.allow_preview + self.preview_button.setVisible(b) + self.m4.setChecked(b) + + def set_io_visible(self, b): + self.io_widget.setVisible(b) + self.m1.setChecked(b) + + def set_fee_edit_visible(self, b): + detailed = [self.feerounding_icon, self.feerate_e, self.fee_e] + basic = [self.fee_label, self.feerate_label] + # first hide, then show + for w in (basic if b else detailed): + w.hide() + for w in (detailed if b else basic): + w.show() + self.m2.setChecked(b) + + def set_locktime_visible(self, b): + for w in [ + self.locktime_e, + self.locktime_label]: + w.setVisible(b) + self.m3.setChecked(b) + + def run(self): + cancelled = not self.exec_() + self.stop_editor_updates() + self.deleteLater() # see #3956 + return self.tx if not cancelled else None + + def on_send(self): + self.accept() + + def on_preview(self): + self.is_preview = True + self.accept() + + def toggle_send_button(self, enable: bool, *, message: str = None): + if message is None: + self.message_label.setStyleSheet(None) + self.message_label.setText(' ') + else: + self.message_label.setStyleSheet(ColorScheme.RED.as_stylesheet()) + self.message_label.setText(message) + + self.setFixedSize(self.layout().sizeHint()) + self.preview_button.setEnabled(enable) + self.ok_button.setEnabled(enable) + + def update(self): + tx = self.tx + self._update_amount_label() + if self.not_enough_funds: + text = self.main_window.send_tab.get_text_not_enough_funds_mentioning_frozen() + self.toggle_send_button(False, message=text) + return + if not tx: + self.toggle_send_button(False) + return + self.update_fee_fields() + if self.locktime_e.get_locktime() is None: + self.locktime_e.set_locktime(self.tx.locktime) + self.io_widget.update(tx) + fee = tx.get_fee() + assert fee is not None + self.fee_label.setText(self.main_window.config.format_amount_and_units(fee)) + + fee_rate = fee // tx.estimated_size() + #self.feerate_label.setText(self.main_window.format_amount(fee_rate)) + + # extra fee + x_fee = run_hook('get_tx_extra_fee', self.wallet, tx) + if x_fee: + x_fee_address, x_fee_amount = x_fee + self.extra_fee_label.setVisible(True) + self.extra_fee_value.setVisible(True) + self.extra_fee_value.setText(self.main_window.format_amount_and_units(x_fee_amount)) + amount = tx.output_value() if self.output_value == '!' else self.output_value + tx_size = tx.estimated_size() + fee_warning_tuple = self.wallet.get_tx_fee_warning( + invoice_amt=amount, tx_size=tx_size, fee=fee) + if fee_warning_tuple: + allow_send, long_warning, short_warning = fee_warning_tuple + self.toggle_send_button(allow_send, message=long_warning) + else: + self.toggle_send_button(True) + + def _update_amount_label(self): + pass + +class ConfirmTxDialog(TxEditor): + help_text = ''#_('Set the mining fee of your transaction') + + def __init__(self, *, window: 'ElectrumWindow', make_tx, output_value: Union[int, str], allow_preview=True): + + TxEditor.__init__( + self, + window=window, + make_tx=make_tx, + output_value=output_value, + title=_("New Transaction"), # todo: adapt title for channel funding tx, swaps + allow_preview=allow_preview) + + BlockingWaitingDialog(window, _("Preparing transaction..."), self.update_tx) + self.update() + + def _update_amount_label(self): + tx = self.tx + if self.output_value == '!': + if tx: + amount = tx.output_value() + amount_str = self.main_window.format_amount_and_units(amount) + else: + amount_str = "max" + else: + amount = self.output_value + amount_str = self.main_window.format_amount_and_units(amount) + self.amount_label.setText(amount_str) def update_tx(self, *, fallback_to_zero_fee: bool = False): fee_estimator = self.get_fee_estimator() @@ -116,6 +550,7 @@ class TxEditor: self.tx.set_rbf(True) def have_enough_funds_assuming_zero_fees(self) -> bool: + # called in send_tab.py try: tx = self.make_tx(0) except NotEnoughFunds: @@ -123,147 +558,39 @@ class TxEditor: else: return True - - - -class ConfirmTxDialog(TxEditor, WindowModalDialog): - # set fee and return password (after pw check) - - def __init__(self, *, window: 'ElectrumWindow', make_tx, output_value: Union[int, str], is_sweep: bool): - - TxEditor.__init__(self, window=window, make_tx=make_tx, output_value=output_value, is_sweep=is_sweep) - WindowModalDialog.__init__(self, window, _("Confirm Transaction")) - vbox = QVBoxLayout() - self.setLayout(vbox) + def create_grid(self): grid = QGridLayout() - vbox.addLayout(grid) - msg = (_('The amount to be received by the recipient.') + ' ' + _('Fees are paid by the sender.')) self.amount_label = QLabel('') self.amount_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + grid.addWidget(HelpLabel(_("Amount to be sent") + ": ", msg), 0, 0) grid.addWidget(self.amount_label, 0, 1) msg = _('Bitcoin transactions are in general not free. A transaction fee is paid by the sender of the funds.') + '\n\n'\ + _('The amount of fee can be decided freely by the sender. However, transactions with low fees take more time to be processed.') + '\n\n'\ + _('A suggested fee is automatically added to this field. You may override it. The suggested fee increases with the size of the transaction.') - self.fee_label = QLabel('') - self.fee_label.setTextInteractionFlags(Qt.TextSelectableByMouse) - grid.addWidget(HelpLabel(_("Mining fee") + ": ", msg), 1, 0) - grid.addWidget(self.fee_label, 1, 1) + grid.addWidget(HelpLabel(_("Mining Fee") + ": ", msg), 1, 0) + grid.addLayout(self.fee_hbox, 1, 1, 1, 3) + + grid.addWidget(HelpLabel(_("Fee target") + ": ", self.fee_combo.help_msg), 3, 0) + grid.addLayout(self.fee_target_hbox, 3, 1, 1, 3) + + grid.setColumnStretch(4, 1) + + # extra fee self.extra_fee_label = QLabel(_("Additional fees") + ": ") self.extra_fee_label.setVisible(False) self.extra_fee_value = QLabel('') self.extra_fee_value.setTextInteractionFlags(Qt.TextSelectableByMouse) self.extra_fee_value.setVisible(False) - grid.addWidget(self.extra_fee_label, 2, 0) - grid.addWidget(self.extra_fee_value, 2, 1) - - self.fee_slider = FeeSlider(self, self.config, self.fee_slider_callback) - self.fee_combo = FeeComboBox(self.fee_slider) - grid.addWidget(HelpLabel(_("Fee rate") + ": ", self.fee_combo.help_msg), 5, 0) - grid.addWidget(self.fee_slider, 5, 1) - grid.addWidget(self.fee_combo, 5, 2) - - self.message_label = WWLabel(self.default_message()) - grid.addWidget(self.message_label, 6, 0, 1, -1) - self.pw_label = QLabel(_('Password')) - self.pw_label.setVisible(self.password_required) - self.pw = PasswordLineEdit() - self.pw.setVisible(self.password_required) - grid.addWidget(self.pw_label, 8, 0) - grid.addWidget(self.pw, 8, 1, 1, -1) - self.preview_button = QPushButton(_('Advanced')) - self.preview_button.clicked.connect(self.on_preview) - grid.addWidget(self.preview_button, 0, 2) - self.send_button = QPushButton(_('Send')) - self.send_button.clicked.connect(self.on_send) - self.send_button.setDefault(True) - vbox.addLayout(Buttons(CancelButton(self), self.send_button)) - BlockingWaitingDialog(window, _("Preparing transaction..."), self.update_tx) - self.update() - self.is_send = False + grid.addWidget(self.extra_fee_label, 5, 0) + grid.addWidget(self.extra_fee_value, 5, 1) - def default_message(self): - return _('Enter your password to proceed') if self.password_required else _('Click Send to proceed') + # locktime editor + grid.addWidget(self.locktime_label, 6, 0) + grid.addWidget(self.locktime_e, 6, 1, 1, 2) - def on_preview(self): - self.accept() - - def run(self): - cancelled = not self.exec_() - password = self.pw.text() or None - self.stop_editor_updates() - self.deleteLater() # see #3956 - return cancelled, self.is_send, password, self.tx - - def on_send(self): - password = self.pw.text() or None - if self.password_required: - if password is None: - self.main_window.show_error(_("Password required"), parent=self) - return - try: - self.wallet.check_password(password) - except Exception as e: - self.main_window.show_error(str(e), parent=self) - return - self.is_send = True - self.accept() - - def toggle_send_button(self, enable: bool, *, message: str = None): - if message is None: - self.message_label.setStyleSheet(None) - self.message_label.setText(self.default_message()) - else: - self.message_label.setStyleSheet(ColorScheme.RED.as_stylesheet()) - self.message_label.setText(message) - self.pw.setEnabled(enable) - self.send_button.setEnabled(enable) - - def _update_amount_label(self): - tx = self.tx - if self.output_value == '!': - if tx: - amount = tx.output_value() - amount_str = self.main_window.format_amount_and_units(amount) - else: - amount_str = "max" - else: - amount = self.output_value - amount_str = self.main_window.format_amount_and_units(amount) - self.amount_label.setText(amount_str) - - def update(self): - tx = self.tx - self._update_amount_label() - - if self.not_enough_funds: - text = self.main_window.send_tab.get_text_not_enough_funds_mentioning_frozen() - self.toggle_send_button(False, message=text) - return - - if not tx: - return - - fee = tx.get_fee() - assert fee is not None - self.fee_label.setText(self.main_window.format_amount_and_units(fee)) - x_fee = run_hook('get_tx_extra_fee', self.wallet, tx) - if x_fee: - x_fee_address, x_fee_amount = x_fee - self.extra_fee_label.setVisible(True) - self.extra_fee_value.setVisible(True) - self.extra_fee_value.setText(self.main_window.format_amount_and_units(x_fee_amount)) - - amount = tx.output_value() if self.output_value == '!' else self.output_value - tx_size = tx.estimated_size() - fee_warning_tuple = self.wallet.get_tx_fee_warning( - invoice_amt=amount, tx_size=tx_size, fee=fee) - if fee_warning_tuple: - allow_send, long_warning, short_warning = fee_warning_tuple - self.toggle_send_button(allow_send, message=long_warning) - else: - self.toggle_send_button(True) + return grid diff --git a/electrum/gui/qt/fee_slider.py b/electrum/gui/qt/fee_slider.py index 293a3c6c4..b73c4b49b 100644 --- a/electrum/gui/qt/fee_slider.py +++ b/electrum/gui/qt/fee_slider.py @@ -40,13 +40,18 @@ class FeeSlider(QSlider): self.update() self.valueChanged.connect(self.moved) self._active = True + self.setFocusPolicy(Qt.NoFocus) + + def get_fee_rate(self, pos): + if self.dyn: + fee_rate = self.config.depth_to_fee(pos) if self.config.use_mempool_fees() else self.config.eta_to_fee(pos) + else: + fee_rate = self.config.static_fee(pos) + return fee_rate def moved(self, pos): with self.lock: - if self.dyn: - fee_rate = self.config.depth_to_fee(pos) if self.config.use_mempool_fees() else self.config.eta_to_fee(pos) - else: - fee_rate = self.config.static_fee(pos) + fee_rate = self.get_fee_rate(pos) tooltip = self.get_tooltip(pos, fee_rate) QToolTip.showText(QCursor.pos(), tooltip, self) self.setToolTip(tooltip) @@ -60,6 +65,15 @@ class FeeSlider(QSlider): else: return _('Fixed rate') + ': ' + target + '\n' + _('Estimate') + ': ' + estimate + def get_dynfee_target(self): + if not self.dyn: + return '' + pos = self.value() + fee_rate = self.get_fee_rate(pos) + mempool = self.config.use_mempool_fees() + target, estimate = self.config.get_fee_text(pos, True, mempool, fee_rate) + return target + def update(self): with self.lock: self.dyn = self.config.is_dynfee() diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 418776bd2..dd5fee6b1 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -1258,17 +1258,16 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): msg = messages.MGS_CONFLICTING_BACKUP_INSTANCE if not self.question(msg): return - # use ConfirmTxDialog # we need to know the fee before we broadcast, because the txid is required make_tx = self.mktx_for_open_channel(funding_sat=funding_sat, node_id=node_id) - d = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=funding_sat, is_sweep=False) - # disable preview button because the user must not broadcast tx before establishment_flow - d.preview_button.setEnabled(False) - cancelled, is_send, password, funding_tx = d.run() - if not is_send: - return - if cancelled: + d = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=funding_sat, allow_preview=False) + funding_tx = d.run() + if not funding_tx: return + self._open_channel(connect_str, funding_sat, push_amt, funding_tx) + + @protected + def _open_channel(self, connect_str, funding_sat, push_amt, funding_tx, password): # read funding_sat from tx; converts '!' to int value funding_sat = funding_tx.output_value_for_address(ln_dummy_address()) def task(): diff --git a/electrum/gui/qt/rbf_dialog.py b/electrum/gui/qt/rbf_dialog.py index f803d2910..e99a477e4 100644 --- a/electrum/gui/qt/rbf_dialog.py +++ b/electrum/gui/qt/rbf_dialog.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING +from PyQt5.QtCore import Qt from PyQt5.QtWidgets import (QCheckBox, QLabel, QVBoxLayout, QGridLayout, QWidget, QPushButton, QHBoxLayout, QComboBox) @@ -20,7 +21,9 @@ if TYPE_CHECKING: from .main_window import ElectrumWindow -class _BaseRBFDialog(WindowModalDialog): +from .confirm_tx_dialog import ConfirmTxDialog, TxEditor, TxSizeLabel, HelpLabel + +class _BaseRBFDialog(TxEditor): def __init__( self, @@ -30,125 +33,110 @@ class _BaseRBFDialog(WindowModalDialog): txid: str, title: str): - WindowModalDialog.__init__(self, main_window, title=title) - self.window = main_window self.wallet = main_window.wallet - self.tx = tx - self.new_tx = None + self.old_tx = tx assert txid - self.txid = txid + self.old_txid = txid self.message = '' - fee = tx.get_fee() - assert fee is not None - tx_size = tx.estimated_size() - self.old_fee_rate = old_fee_rate = fee / tx_size # sat/vbyte - vbox = QVBoxLayout(self) - vbox.addWidget(WWLabel(self.help_text)) - vbox.addStretch(1) - - self.ok_button = OkButton(self) - self.message_label = QLabel('') - self.feerate_e = FeerateEdit(lambda: 0) - self.feerate_e.setAmount(max(old_fee_rate * 1.5, old_fee_rate + 1)) - self.feerate_e.textChanged.connect(self.update) - - def on_slider(dyn, pos, fee_rate): - fee_slider.activate() - if fee_rate is not None: - self.feerate_e.setAmount(fee_rate / 1000) - - fee_slider = FeeSlider(self.window, self.window.config, on_slider) - fee_combo = FeeComboBox(fee_slider) - fee_slider.deactivate() - self.feerate_e.textEdited.connect(fee_slider.deactivate) - - grid = QGridLayout() + self.old_fee = self.old_tx.get_fee() + self.old_tx_size = tx.estimated_size() + self.old_fee_rate = old_fee_rate = self.old_fee / self.old_tx_size # sat/vbyte - self.method_label = QLabel(_('Method') + ':') - self.method_combo = QComboBox() - self.method_combo.addItems([_('Preserve payment'), _('Decrease payment')]) - self.method_combo.currentIndexChanged.connect(self.update) - grid.addWidget(self.method_label, 0, 0) - grid.addWidget(self.method_combo, 0, 1) + TxEditor.__init__( + self, + window=main_window, + title=title, + make_tx=self.rbf_func) - grid.addWidget(QLabel(_('Current fee') + ':'), 1, 0) - grid.addWidget(QLabel(self.window.format_amount_and_units(fee)), 1, 1) - grid.addWidget(QLabel(_('Current fee rate') + ':'), 2, 0) - grid.addWidget(QLabel(self.window.format_fee_rate(1000 * old_fee_rate)), 2, 1) - - grid.addWidget(QLabel(_('New fee rate') + ':'), 3, 0) - grid.addWidget(self.feerate_e, 3, 1) - grid.addWidget(fee_slider, 3, 2) - grid.addWidget(fee_combo, 3, 3) - grid.addWidget(self.message_label, 5, 0, 1, 3) - - vbox.addLayout(grid) - vbox.addStretch(1) - btns_hbox = QHBoxLayout() - btns_hbox.addStretch(1) - btns_hbox.addWidget(CancelButton(self)) - btns_hbox.addWidget(self.ok_button) - vbox.addLayout(btns_hbox) - - new_fee_rate = old_fee_rate + max(1, old_fee_rate // 20) + new_fee_rate = self.old_fee_rate + max(1, self.old_fee_rate // 20) self.feerate_e.setAmount(new_fee_rate) self._update_tx(new_fee_rate) self._update_message() - # give focus to fee slider - fee_slider.activate() - fee_slider.setFocus() + self.fee_slider.activate() # are we paying max? invoices = self.wallet.get_relevant_invoices_for_tx(txid) if len(invoices) == 1 and len(invoices[0].outputs) == 1: if invoices[0].outputs[0].value == '!': self.set_decrease_payment() + def create_grid(self): + self.method_label = QLabel(_('Method') + ':') + self.method_combo = QComboBox() + self.method_combo.addItems([_('Preserve payment'), _('Decrease payment')]) + self.method_combo.currentIndexChanged.connect(self.update) + old_size_label = TxSizeLabel() + old_size_label.setAlignment(Qt.AlignCenter) + old_size_label.setAmount(self.old_tx_size) + old_size_label.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet()) + current_fee_hbox = QHBoxLayout() + current_fee_hbox.addWidget(QLabel(self.main_window.format_fee_rate(1000 * self.old_fee_rate))) + current_fee_hbox.addWidget(old_size_label) + current_fee_hbox.addWidget(QLabel(self.main_window.format_amount_and_units(self.old_fee))) + current_fee_hbox.addStretch() + grid = QGridLayout() + grid.addWidget(self.method_label, 0, 0) + grid.addWidget(self.method_combo, 0, 1) + grid.addWidget(QLabel(_('Current fee') + ':'), 1, 0) + grid.addLayout(current_fee_hbox, 1, 1, 1, 3) + grid.addWidget(QLabel(_('New fee') + ':'), 2, 0) + grid.addLayout(self.fee_hbox, 2, 1, 1, 3) + grid.addWidget(HelpLabel(_("Fee target") + ": ", self.fee_combo.help_msg), 4, 0) + grid.addLayout(self.fee_target_hbox, 4, 1, 1, 3) + grid.setColumnStretch(4, 1) + # locktime + grid.addWidget(self.locktime_label, 5, 0) + grid.addWidget(self.locktime_e, 5, 1, 1, 2) + return grid + def is_decrease_payment(self): return self.method_combo.currentIndex() == 1 def set_decrease_payment(self): self.method_combo.setCurrentIndex(1) - def rbf_func(self, fee_rate) -> PartialTransaction: - raise NotImplementedError() # implemented by subclasses - def run(self) -> None: if not self.exec_(): return - self.new_tx.set_rbf(True) - tx_label = self.wallet.get_label_for_txid(self.txid) - self.window.show_transaction(self.new_tx, tx_desc=tx_label) - # TODO maybe save tx_label as label for new tx?? - - def update(self): + if self.is_preview: + self.main_window.show_transaction(self.tx) + return + def sign_done(success): + if success: + self.main_window.broadcast_or_show(self.tx) + self.main_window.sign_tx( + self.tx, + callback=sign_done, + external_keypairs={}) + + def update_tx(self): fee_rate = self.feerate_e.get_amount() self._update_tx(fee_rate) self._update_message() def _update_tx(self, fee_rate): if fee_rate is None: - self.new_tx = None + self.tx = None self.message = '' elif fee_rate <= self.old_fee_rate: - self.new_tx = None + self.tx = None self.message = _("The new fee rate needs to be higher than the old fee rate.") else: try: - self.new_tx = self.rbf_func(fee_rate) + self.tx = self.make_tx(fee_rate) except CannotBumpFee as e: - self.new_tx = None + self.tx = None self.message = str(e) - if not self.new_tx: + if not self.tx: return - delta = self.new_tx.get_fee() - self.tx.get_fee() + delta = self.tx.get_fee() - self.old_tx.get_fee() if not self.is_decrease_payment(): - self.message = _("You will pay {} more.").format(self.window.format_amount_and_units(delta)) + self.message = _("You will pay {} more.").format(self.main_window.format_amount_and_units(delta)) else: - self.message = _("The recipient will receive {} less.").format(self.window.format_amount_and_units(delta)) + self.message = _("The recipient will receive {} less.").format(self.main_window.format_amount_and_units(delta)) def _update_message(self): - enabled = bool(self.new_tx) + enabled = bool(self.tx) self.ok_button.setEnabled(enabled) if enabled: style = ColorScheme.BLUE.as_stylesheet() @@ -177,10 +165,10 @@ class BumpFeeDialog(_BaseRBFDialog): def rbf_func(self, fee_rate): return self.wallet.bump_fee( - tx=self.tx, - txid=self.txid, + tx=self.old_tx, + txid=self.old_txid, new_fee_rate=fee_rate, - coins=self.window.get_coins(), + coins=self.main_window.get_coins(), decrease_payment=self.is_decrease_payment()) @@ -206,4 +194,4 @@ class DSCancelDialog(_BaseRBFDialog): self.method_combo.setVisible(False) def rbf_func(self, fee_rate): - return self.wallet.dscancel(tx=self.tx, new_fee_rate=fee_rate) + return self.wallet.dscancel(tx=self.old_tx, new_fee_rate=fee_rate) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 6294d1d50..2bb64ef51 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -28,7 +28,6 @@ from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLErr from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit from .util import WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit from .confirm_tx_dialog import ConfirmTxDialog -from .transaction_dialog import PreviewTxDialog if TYPE_CHECKING: from .main_window import ElectrumWindow @@ -234,7 +233,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): output_value = '!' else: output_value = sum(output_values) - conf_dlg = ConfirmTxDialog(window=self.window, make_tx=make_tx, output_value=output_value, is_sweep=is_sweep) + conf_dlg = ConfirmTxDialog(window=self.window, make_tx=make_tx, output_value=output_value) if conf_dlg.not_enough_funds: # Check if we had enough funds excluding fees, # if so, still provide opportunity to set lower fees. @@ -243,37 +242,26 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.show_message(text) return - # shortcut to advanced preview (after "enough funds" check!) - if self.config.get('advanced_preview'): - preview_dlg = PreviewTxDialog( - window=self.window, - make_tx=make_tx, - external_keypairs=external_keypairs, - output_value=output_value) - preview_dlg.show() + tx = conf_dlg.run() + if tx is None: + # user cancelled return - - cancelled, is_send, password, tx = conf_dlg.run() - if cancelled: + is_preview = conf_dlg.is_preview + if is_preview: + self.window.show_transaction(tx) return - if is_send: - self.save_pending_invoice() - def sign_done(success): - if success: - self.window.broadcast_or_show(tx) - self.window.sign_tx_with_password( - tx, - callback=sign_done, - password=password, - external_keypairs=external_keypairs, - ) - else: - preview_dlg = PreviewTxDialog( - window=self.window, - make_tx=make_tx, - external_keypairs=external_keypairs, - output_value=output_value) - preview_dlg.show() + + self.save_pending_invoice() + def sign_done(success): + if success: + self.window.broadcast_or_show(tx) + else: + raise + + self.window.sign_tx( + tx, + callback=sign_done, + external_keypairs=external_keypairs) def get_text_not_enough_funds_mentioning_frozen(self) -> str: text = _("Not enough funds") diff --git a/electrum/gui/qt/settings_dialog.py b/electrum/gui/qt/settings_dialog.py index 5155a1414..e247e3bcb 100644 --- a/electrum/gui/qt/settings_dialog.py +++ b/electrum/gui/qt/settings_dialog.py @@ -276,13 +276,6 @@ class SettingsDialog(QDialog, QtEventListener): filelogging_cb.stateChanged.connect(on_set_filelogging) filelogging_cb.setToolTip(_('Debug logs can be persisted to disk. These are useful for troubleshooting.')) - preview_cb = QCheckBox(_('Advanced preview')) - preview_cb.setChecked(bool(self.config.get('advanced_preview', False))) - preview_cb.setToolTip(_("Open advanced transaction preview dialog when 'Pay' is clicked.")) - def on_preview(x): - self.config.set_key('advanced_preview', x == Qt.Checked) - preview_cb.stateChanged.connect(on_preview) - usechange_cb = QCheckBox(_('Use change addresses')) usechange_cb.setChecked(self.wallet.use_change) if not self.config.is_modifiable('use_change'): usechange_cb.setEnabled(False) @@ -494,7 +487,6 @@ class SettingsDialog(QDialog, QtEventListener): tx_widgets = [] tx_widgets.append((usechange_cb, None)) tx_widgets.append((batch_rbf_cb, None)) - tx_widgets.append((preview_cb, None)) tx_widgets.append((unconf_cb, None)) tx_widgets.append((multiple_cb, None)) tx_widgets.append((outrounding_cb, None)) diff --git a/electrum/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py index 4abde8c4f..30e8ac6b9 100644 --- a/electrum/gui/qt/transaction_dialog.py +++ b/electrum/gui/qt/transaction_dialog.py @@ -43,6 +43,7 @@ from qrcode import exceptions from electrum.simple_config import SimpleConfig from electrum.util import quantize_feerate from electrum import bitcoin + from electrum.bitcoin import base_encode, NLOCKTIME_BLOCKHEIGHT_MAX from electrum.i18n import _ from electrum.plugin import run_hook @@ -59,10 +60,6 @@ from .util import (MessageBoxMixin, read_QIcon, Buttons, icon_path, BlockingWaitingDialog, getSaveFileName, ColorSchemeItem, get_iconname_qrcode) -from .fee_slider import FeeSlider, FeeComboBox -from .confirm_tx_dialog import TxEditor -from .amountedit import FeerateEdit, BTCAmountEdit -from .locktimeedit import LockTimeEdit if TYPE_CHECKING: from .main_window import ElectrumWindow @@ -353,6 +350,7 @@ class TxInOutWidget(QWidget): menu.exec_(global_pos) + def show_transaction(tx: Transaction, *, parent: 'ElectrumWindow', desc=None, prompt_if_unsaved=False): try: d = TxDialog(tx, parent=parent, desc=desc, prompt_if_unsaved=prompt_if_unsaved) @@ -428,9 +426,6 @@ class BaseTxDialog(QDialog, MessageBoxMixin): self.export_actions_button.setMenu(export_actions_menu) self.export_actions_button.setPopupMode(QToolButton.InstantPopup) - self.finalize_button = QPushButton(_('Finalize')) - self.finalize_button.clicked.connect(self.on_finalize) - partial_tx_actions_menu = QMenu() ptx_merge_sigs_action = QAction(_("Merge signatures from"), self) ptx_merge_sigs_action.triggered.connect(self.merge_sigs) @@ -447,11 +442,11 @@ class BaseTxDialog(QDialog, MessageBoxMixin): # Action buttons self.buttons = [self.partial_tx_actions_button, self.sign_button, self.broadcast_button, self.cancel_button] # Transaction sharing buttons - self.sharing_buttons = [self.finalize_button, self.export_actions_button, self.save_button] + self.sharing_buttons = [self.export_actions_button, self.save_button] run_hook('transaction_dialog', self) - if not self.finalized: - self.create_fee_controls() - vbox.addWidget(self.feecontrol_fields) + #if not self.finalized: + # self.create_fee_controls() + # vbox.addWidget(self.feecontrol_fields) self.hbox = hbox = QHBoxLayout() hbox.addLayout(Buttons(*self.sharing_buttons)) hbox.addStretch(1) @@ -464,8 +459,6 @@ class BaseTxDialog(QDialog, MessageBoxMixin): def set_buttons_visibility(self): for b in [self.export_actions_button, self.save_button, self.sign_button, self.broadcast_button, self.partial_tx_actions_button]: b.setVisible(self.finalized) - for b in [self.finalize_button]: - b.setVisible(not self.finalized) def set_tx(self, tx: 'Transaction'): # Take a copy; it might get updated in the main window by @@ -659,9 +652,6 @@ class BaseTxDialog(QDialog, MessageBoxMixin): self.update() def update(self): - if not self.finalized: - self.update_fee_fields() - self.finalize_button.setEnabled(self.can_finalize()) if self.tx is None: return self.io_widget.update(self.tx) @@ -723,8 +713,7 @@ class BaseTxDialog(QDialog, MessageBoxMixin): else: locktime_final_str = f"LockTime: {self.tx.locktime} ({datetime.datetime.fromtimestamp(self.tx.locktime)})" self.locktime_final_label.setText(locktime_final_str) - if self.locktime_e.get_locktime() is None: - self.locktime_e.set_locktime(self.tx.locktime) + self.rbf_label.setText(_('Replace by fee') + f": {not self.tx.is_final()}") if tx_mined_status.header_hash: @@ -768,10 +757,7 @@ class BaseTxDialog(QDialog, MessageBoxMixin): fee_rate = Decimal(fee) / size # sat/byte fee_str += ' ( %s ) ' % self.main_window.format_fee_rate(fee_rate * 1000) if isinstance(self.tx, PartialTransaction): - if isinstance(self, PreviewTxDialog): - invoice_amt = self.tx.output_value() if self.output_value == '!' else self.output_value - else: - invoice_amt = amount + invoice_amt = amount fee_warning_tuple = self.wallet.get_tx_fee_warning( invoice_amt=invoice_amt, tx_size=size, fee=fee) if fee_warning_tuple: @@ -865,19 +851,6 @@ class BaseTxDialog(QDialog, MessageBoxMixin): self.locktime_final_label = TxDetailLabel() vbox_right.addWidget(self.locktime_final_label) - locktime_setter_hbox = QHBoxLayout() - locktime_setter_hbox.setContentsMargins(0, 0, 0, 0) - locktime_setter_hbox.setSpacing(0) - locktime_setter_label = TxDetailLabel() - locktime_setter_label.setText("LockTime: ") - self.locktime_e = LockTimeEdit(self) - locktime_setter_hbox.addWidget(locktime_setter_label) - locktime_setter_hbox.addWidget(self.locktime_e) - locktime_setter_hbox.addStretch(1) - self.locktime_setter_widget = QWidget() - self.locktime_setter_widget.setLayout(locktime_setter_hbox) - vbox_right.addWidget(self.locktime_setter_widget) - self.block_height_label = TxDetailLabel() vbox_right.addWidget(self.block_height_label) vbox_right.addStretch(1) @@ -892,7 +865,6 @@ class BaseTxDialog(QDialog, MessageBoxMixin): # set visibility after parenting can be determined by Qt self.rbf_label.setVisible(self.finalized) self.locktime_final_label.setVisible(self.finalized) - self.locktime_setter_widget.setVisible(not self.finalized) def set_title(self): self.setWindowTitle(_("Create transaction") if not self.finalized else _("Transaction")) @@ -947,228 +919,3 @@ class TxDialog(BaseTxDialog): self.update() -class PreviewTxDialog(BaseTxDialog, TxEditor): - - def __init__( - self, - *, - make_tx, - external_keypairs, - window: 'ElectrumWindow', - output_value: Union[int, str], - ): - TxEditor.__init__( - self, - window=window, - make_tx=make_tx, - is_sweep=bool(external_keypairs), - output_value=output_value, - ) - BaseTxDialog.__init__(self, parent=window, desc='', prompt_if_unsaved=False, - finalized=False, external_keypairs=external_keypairs) - BlockingWaitingDialog(window, _("Preparing transaction..."), - lambda: self.update_tx(fallback_to_zero_fee=True)) - self.update() - - def create_fee_controls(self): - - self.size_e = TxSizeLabel() - self.size_e.setAlignment(Qt.AlignCenter) - self.size_e.setAmount(0) - self.size_e.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet()) - - self.fiat_fee_label = TxFiatLabel() - self.fiat_fee_label.setAlignment(Qt.AlignCenter) - self.fiat_fee_label.setAmount(0) - self.fiat_fee_label.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet()) - - self.feerate_e = FeerateEdit(lambda: 0) - self.feerate_e.setAmount(self.config.fee_per_byte()) - self.feerate_e.textEdited.connect(partial(self.on_fee_or_feerate, self.feerate_e, False)) - self.feerate_e.editingFinished.connect(partial(self.on_fee_or_feerate, self.feerate_e, True)) - - self.fee_e = BTCAmountEdit(self.main_window.get_decimal_point) - self.fee_e.textEdited.connect(partial(self.on_fee_or_feerate, self.fee_e, False)) - self.fee_e.editingFinished.connect(partial(self.on_fee_or_feerate, self.fee_e, True)) - - self.fee_e.textChanged.connect(self.entry_changed) - self.feerate_e.textChanged.connect(self.entry_changed) - - self.fee_slider = FeeSlider(self, self.config, self.fee_slider_callback) - self.fee_combo = FeeComboBox(self.fee_slider) - self.fee_slider.setFixedWidth(self.fee_e.width()) - - def feerounding_onclick(): - text = (self.feerounding_text + '\n\n' + - _('To somewhat protect your privacy, Electrum tries to create change with similar precision to other outputs.') + ' ' + - _('At most 100 satoshis might be lost due to this rounding.') + ' ' + - _("You can disable this setting in '{}'.").format(_('Preferences')) + '\n' + - _('Also, dust is not kept as change, but added to the fee.') + '\n' + - _('Also, when batching RBF transactions, BIP 125 imposes a lower bound on the fee.')) - self.show_message(title=_('Fee rounding'), msg=text) - - self.feerounding_icon = QToolButton() - self.feerounding_icon.setIcon(read_QIcon('info.png')) - self.feerounding_icon.setAutoRaise(True) - self.feerounding_icon.clicked.connect(feerounding_onclick) - self.feerounding_icon.setVisible(False) - - self.feecontrol_fields = QWidget() - hbox = QHBoxLayout(self.feecontrol_fields) - hbox.setContentsMargins(0, 0, 0, 0) - grid = QGridLayout() - grid.addWidget(QLabel(_("Target fee:")), 0, 0) - grid.addWidget(self.feerate_e, 0, 1) - grid.addWidget(self.size_e, 0, 2) - grid.addWidget(self.fee_e, 0, 3) - grid.addWidget(self.feerounding_icon, 0, 4) - grid.addWidget(self.fiat_fee_label, 0, 5) - grid.addWidget(self.fee_slider, 1, 1) - grid.addWidget(self.fee_combo, 1, 2) - hbox.addLayout(grid) - hbox.addStretch(1) - - def fee_slider_callback(self, dyn, pos, fee_rate): - super().fee_slider_callback(dyn, pos, fee_rate) - self.fee_slider.activate() - if fee_rate: - fee_rate = Decimal(fee_rate) - self.feerate_e.setAmount(quantize_feerate(fee_rate / 1000)) - else: - self.feerate_e.setAmount(None) - self.fee_e.setModified(False) - - def on_fee_or_feerate(self, edit_changed, editing_finished): - edit_other = self.feerate_e if edit_changed == self.fee_e else self.fee_e - if editing_finished: - if edit_changed.get_amount() is None: - # This is so that when the user blanks the fee and moves on, - # we go back to auto-calculate mode and put a fee back. - edit_changed.setModified(False) - else: - # edit_changed was edited just now, so make sure we will - # freeze the correct fee setting (this) - edit_other.setModified(False) - self.fee_slider.deactivate() - self.update() - - def is_send_fee_frozen(self): - return self.fee_e.isVisible() and self.fee_e.isModified() \ - and (self.fee_e.text() or self.fee_e.hasFocus()) - - def is_send_feerate_frozen(self): - return self.feerate_e.isVisible() and self.feerate_e.isModified() \ - and (self.feerate_e.text() or self.feerate_e.hasFocus()) - - def set_feerounding_text(self, num_satoshis_added): - self.feerounding_text = (_('Additional {} satoshis are going to be added.') - .format(num_satoshis_added)) - - def get_fee_estimator(self): - if self.is_send_fee_frozen() and self.fee_e.get_amount() is not None: - fee_estimator = self.fee_e.get_amount() - elif self.is_send_feerate_frozen() and self.feerate_e.get_amount() is not None: - amount = self.feerate_e.get_amount() # sat/byte feerate - amount = 0 if amount is None else amount * 1000 # sat/kilobyte feerate - fee_estimator = partial( - SimpleConfig.estimate_fee_for_feerate, amount) - else: - fee_estimator = None - return fee_estimator - - def entry_changed(self): - # blue color denotes auto-filled values - text = "" - fee_color = ColorScheme.DEFAULT - feerate_color = ColorScheme.DEFAULT - if self.not_enough_funds: - fee_color = ColorScheme.RED - feerate_color = ColorScheme.RED - elif self.fee_e.isModified(): - feerate_color = ColorScheme.BLUE - elif self.feerate_e.isModified(): - fee_color = ColorScheme.BLUE - else: - fee_color = ColorScheme.BLUE - feerate_color = ColorScheme.BLUE - self.fee_e.setStyleSheet(fee_color.as_stylesheet()) - self.feerate_e.setStyleSheet(feerate_color.as_stylesheet()) - # - self.needs_update = True - - def update_fee_fields(self): - freeze_fee = self.is_send_fee_frozen() - freeze_feerate = self.is_send_feerate_frozen() - tx = self.tx - if self.no_dynfee_estimates and tx: - size = tx.estimated_size() - self.size_e.setAmount(size) - if self.not_enough_funds or self.no_dynfee_estimates: - if not freeze_fee: - self.fee_e.setAmount(None) - if not freeze_feerate: - self.feerate_e.setAmount(None) - self.feerounding_icon.setVisible(False) - return - - assert tx is not None - size = tx.estimated_size() - fee = tx.get_fee() - - self.size_e.setAmount(size) - fiat_fee = self.main_window.format_fiat_and_units(fee) - self.fiat_fee_label.setAmount(fiat_fee) - - # Displayed fee/fee_rate values are set according to user input. - # Due to rounding or dropping dust in CoinChooser, - # actual fees often differ somewhat. - if freeze_feerate or self.fee_slider.is_active(): - displayed_feerate = self.feerate_e.get_amount() - if displayed_feerate is not None: - displayed_feerate = quantize_feerate(displayed_feerate) - elif self.fee_slider.is_active(): - # fallback to actual fee - displayed_feerate = quantize_feerate(fee / size) if fee is not None else None - self.feerate_e.setAmount(displayed_feerate) - displayed_fee = round(displayed_feerate * size) if displayed_feerate is not None else None - self.fee_e.setAmount(displayed_fee) - else: - if freeze_fee: - displayed_fee = self.fee_e.get_amount() - else: - # fallback to actual fee if nothing is frozen - displayed_fee = fee - self.fee_e.setAmount(displayed_fee) - displayed_fee = displayed_fee if displayed_fee else 0 - displayed_feerate = quantize_feerate(displayed_fee / size) if displayed_fee is not None else None - self.feerate_e.setAmount(displayed_feerate) - - # show/hide fee rounding icon - feerounding = (fee - displayed_fee) if (fee and displayed_fee is not None) else 0 - self.set_feerounding_text(int(feerounding)) - self.feerounding_icon.setToolTip(self.feerounding_text) - self.feerounding_icon.setVisible(abs(feerounding) >= 1) - - def can_finalize(self): - return (self.tx is not None - and not self.not_enough_funds) - - def on_finalize(self): - if not self.can_finalize(): - return - assert self.tx - self.finalized = True - self.stop_editor_updates() - self.tx.set_rbf(True) - locktime = self.locktime_e.get_locktime() - if locktime is not None: - self.tx.locktime = locktime - for widget in [self.fee_slider, self.fee_combo, self.feecontrol_fields, - self.locktime_setter_widget, self.locktime_e]: - widget.setEnabled(False) - widget.setVisible(False) - for widget in [self.rbf_label, self.locktime_final_label]: - widget.setVisible(True) - self.set_title() - self.set_buttons_visibility() - self.update() diff --git a/electrum/wallet.py b/electrum/wallet.py index f6ef08580..5ea84a1fc 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -2770,7 +2770,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): fee: int) -> Optional[Tuple[bool, str, str]]: feerate = Decimal(fee) / tx_size # sat/byte - fee_ratio = Decimal(fee) / invoice_amt if invoice_amt else 1 + fee_ratio = Decimal(fee) / invoice_amt if invoice_amt else 0 long_warning = None short_warning = None allow_send = True