Browse Source

Merge pull request #8197 from spesmilo/new_tx_flow

Qt: new onchain tx creation flow:
master
ThomasV 3 years ago committed by GitHub
parent
commit
bf16919a74
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 625
      electrum/gui/qt/confirm_tx_dialog.py
  2. 17
      electrum/gui/qt/fee_slider.py
  3. 9
      electrum/gui/qt/locktimeedit.py
  4. 15
      electrum/gui/qt/main_window.py
  5. 173
      electrum/gui/qt/rbf_dialog.py
  6. 38
      electrum/gui/qt/send_tab.py
  7. 8
      electrum/gui/qt/settings_dialog.py
  8. 267
      electrum/gui/qt/transaction_dialog.py
  9. 2
      electrum/wallet.py

625
electrum/gui/qt/confirm_tx_dialog.py

@ -24,53 +24,106 @@
# SOFTWARE. # SOFTWARE.
from decimal import Decimal from decimal import Decimal
from functools import partial
from typing import TYPE_CHECKING, Optional, Union from typing import TYPE_CHECKING, Optional, Union
from PyQt5.QtCore import Qt 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.i18n import _
from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates
from electrum.util import quantize_feerate
from electrum.plugin import run_hook from electrum.plugin import run_hook
from electrum.transaction import Transaction, PartialTransaction from electrum.transaction import Transaction, PartialTransaction
from electrum.wallet import InternalAddressCorruption from electrum.wallet import InternalAddressCorruption
from electrum.simple_config import SimpleConfig
from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton, from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton,
BlockingWaitingDialog, PasswordLineEdit, WWLabel) BlockingWaitingDialog, PasswordLineEdit, WWLabel, read_QIcon)
from .fee_slider import FeeSlider, FeeComboBox from .fee_slider import FeeSlider, FeeComboBox
if TYPE_CHECKING: if TYPE_CHECKING:
from .main_window import ElectrumWindow 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, WindowModalDialog.__init__(self, window, title=title)
output_value: Union[int, str] = None, is_sweep: bool):
self.main_window = window self.main_window = window
self.make_tx = make_tx self.make_tx = make_tx
self.output_value = output_value self.output_value = output_value
self.tx = None # type: Optional[PartialTransaction] self.tx = None # type: Optional[PartialTransaction]
self.message = '' # set by side effect
self.error = '' # set by side effect
self.config = window.config self.config = window.config
self.wallet = window.wallet self.wallet = window.wallet
self.not_enough_funds = False self.not_enough_funds = False
self.no_dynfee_estimates = False self.no_dynfee_estimates = False
self.needs_update = 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_e.valueEdited.connect(self.trigger_update)
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_tx_fee_details', False))
self.set_locktime_visible(self.config.get('show_tx_locktime', False))
self.set_preview_visible(self.config.get('show_tx_preview_button', False))
self.update_fee_target()
self.resize(self.layout().sizeHint())
self.main_window.gui_object.timer.timeout.connect(self.timer_actions) self.main_window.gui_object.timer.timeout.connect(self.timer_actions)
def timer_actions(self): def timer_actions(self):
if self.needs_update: if self.needs_update:
self.update_tx()
self.update() self.update()
self.needs_update = False self.needs_update = False
def update(self):
self.update_tx()
self.set_locktime()
self._update_widgets()
def stop_editor_updates(self): def stop_editor_updates(self):
self.main_window.gui_object.timer.timeout.disconnect(self.timer_actions) 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 dyn:
if self.config.use_mempool_fees(): if self.config.use_mempool_fees():
self.config.set_key('depth_level', pos, False) self.config.set_key('depth_level', pos, False)
@ -78,10 +131,423 @@ class TxEditor:
self.config.set_key('fee_level', pos, False) self.config.set_key('fee_level', pos, False)
else: else:
self.config.set_key('fee_per_kb', fee_rate, False) self.config.set_key('fee_per_kb', fee_rate, False)
def update_tx(self, *, fallback_to_zero_fee: bool = False):
# expected to set self.tx, self.message and self.error
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)
self.fee_combo.setFocusPolicy(Qt.NoFocus)
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.setStyleSheet("background-color: rgba(255, 255, 255, 0); ")
self.feerounding_icon.setAutoRaise(True)
self.feerounding_icon.clicked.connect(feerounding_onclick)
self.set_feerounding_visibility(False)
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.message = ''
self.error = ''
self._update_widgets()
self.needs_update = True 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()
# do not call trigger_update on editing_finished,
# because that event is emitted when we press OK
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 set_feerounding_visibility(self, b:bool):
# we do not use setVisible because it affects the layout
self.feerounding_icon.setIcon(read_QIcon('info.png') if b else QIcon())
self.feerounding_icon.setEnabled(b)
def get_fee_estimator(self): 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.set_feerounding_visibility(False)
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.set_feerounding_visibility(abs(feerounding) >= 1)
# feerate_label needs to be updated from feerate_e
self.update_feerate_label()
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)
self.pref_button.setFocusPolicy(Qt.NoFocus)
hbox = QHBoxLayout()
hbox.addWidget(QLabel(text))
hbox.addStretch()
hbox.addWidget(self.pref_button)
return hbox
def resize_to_fit_content(self):
# fixme: calling resize once is not enough...
size = self.layout().sizeHint()
self.resize(size)
self.resize(size)
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_to_fit_content()
def toggle_fee_details(self):
b = not self.config.get('show_tx_fee_details', False)
self.config.set_key('show_tx_fee_details', b)
self.set_fee_edit_visible(b)
self.resize_to_fit_content()
def toggle_locktime(self):
b = not self.config.get('show_tx_locktime', False)
self.config.set_key('show_tx_locktime', b)
self.set_locktime_visible(b)
self.resize_to_fit_content()
def toggle_preview_button(self):
b = not self.config.get('show_tx_preview_button', False)
self.config.set_key('show_tx_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 _update_widgets(self):
self._update_amount_label()
if self.not_enough_funds:
self.error = self.main_window.send_tab.get_text_not_enough_funds_mentioning_frozen()
if not self.tx:
self.set_feerounding_visibility(False)
else:
self.check_tx_fee_warning()
self.update_fee_fields()
if self.locktime_e.get_locktime() is None:
self.locktime_e.set_locktime(self.tx.locktime)
self.io_widget.update(self.tx)
self.fee_label.setText(self.main_window.config.format_amount_and_units(self.tx.get_fee()))
self._update_extra_fees()
self._update_send_button()
self._update_message()
def check_tx_fee_warning(self):
# side effects: self.error, self.message
fee = self.tx.get_fee()
assert fee is not None
amount = self.tx.output_value() if self.output_value == '!' else self.output_value
tx_size = self.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
if not allow_send:
self.error = long_warning
else:
# note: this may overrride existing message
self.message = long_warning
def set_locktime(self):
if not self.tx:
return
locktime = self.locktime_e.get_locktime()
if locktime is not None:
self.tx.locktime = locktime
def _update_amount_label(self):
pass
def _update_extra_fees(self):
pass
def _update_message(self):
style = ColorScheme.RED if self.error else ColorScheme.BLUE
self.message_label.setStyleSheet(style.as_stylesheet())
self.message_label.setText(self.error or self.message)
def _update_send_button(self):
enabled = bool(self.tx) and not self.error
self.preview_button.setEnabled(enabled)
self.ok_button.setEnabled(enabled)
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)
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): def update_tx(self, *, fallback_to_zero_fee: bool = False):
fee_estimator = self.get_fee_estimator() fee_estimator = self.get_fee_estimator()
@ -89,6 +555,8 @@ class TxEditor:
self.tx = self.make_tx(fee_estimator) self.tx = self.make_tx(fee_estimator)
self.not_enough_funds = False self.not_enough_funds = False
self.no_dynfee_estimates = False self.no_dynfee_estimates = False
error = ''
message = ''
except NotEnoughFunds: except NotEnoughFunds:
self.not_enough_funds = True self.not_enough_funds = True
self.tx = None self.tx = None
@ -116,6 +584,7 @@ class TxEditor:
self.tx.set_rbf(True) self.tx.set_rbf(True)
def have_enough_funds_assuming_zero_fees(self) -> bool: def have_enough_funds_assuming_zero_fees(self) -> bool:
# called in send_tab.py
try: try:
tx = self.make_tx(0) tx = self.make_tx(0)
except NotEnoughFunds: except NotEnoughFunds:
@ -123,147 +592,47 @@ class TxEditor:
else: else:
return True return True
def create_grid(self):
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)
grid = QGridLayout() grid = QGridLayout()
vbox.addLayout(grid)
msg = (_('The amount to be received by the recipient.') + ' ' msg = (_('The amount to be received by the recipient.') + ' '
+ _('Fees are paid by the sender.')) + _('Fees are paid by the sender.'))
self.amount_label = QLabel('') self.amount_label = QLabel('')
self.amount_label.setTextInteractionFlags(Qt.TextSelectableByMouse) self.amount_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
grid.addWidget(HelpLabel(_("Amount to be sent") + ": ", msg), 0, 0) grid.addWidget(HelpLabel(_("Amount to be sent") + ": ", msg), 0, 0)
grid.addWidget(self.amount_label, 0, 1) 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'\ 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'\ + _('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.') + _('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 = QLabel(_("Additional fees") + ": ")
self.extra_fee_label.setVisible(False) self.extra_fee_label.setVisible(False)
self.extra_fee_value = QLabel('') self.extra_fee_value = QLabel('')
self.extra_fee_value.setTextInteractionFlags(Qt.TextSelectableByMouse) self.extra_fee_value.setTextInteractionFlags(Qt.TextSelectableByMouse)
self.extra_fee_value.setVisible(False) self.extra_fee_value.setVisible(False)
grid.addWidget(self.extra_fee_label, 2, 0) grid.addWidget(self.extra_fee_label, 5, 0)
grid.addWidget(self.extra_fee_value, 2, 1) grid.addWidget(self.extra_fee_value, 5, 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
def default_message(self):
return _('Enter your password to proceed') if self.password_required else _('Click Send to proceed')
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): # locktime editor
tx = self.tx grid.addWidget(self.locktime_label, 6, 0)
self._update_amount_label() grid.addWidget(self.locktime_e, 6, 1, 1, 2)
if self.not_enough_funds: return grid
text = self.main_window.send_tab.get_text_not_enough_funds_mentioning_frozen()
self.toggle_send_button(False, message=text)
return
if not tx: def _update_extra_fees(self):
return x_fee = run_hook('get_tx_extra_fee', self.wallet, self.tx)
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: if x_fee:
x_fee_address, x_fee_amount = x_fee x_fee_address, x_fee_amount = x_fee
self.extra_fee_label.setVisible(True) self.extra_fee_label.setVisible(True)
self.extra_fee_value.setVisible(True) self.extra_fee_value.setVisible(True)
self.extra_fee_value.setText(self.main_window.format_amount_and_units(x_fee_amount)) 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)

17
electrum/gui/qt/fee_slider.py

@ -41,12 +41,16 @@ class FeeSlider(QSlider):
self.valueChanged.connect(self.moved) self.valueChanged.connect(self.moved)
self._active = True self._active = True
def moved(self, pos): def get_fee_rate(self, pos):
with self.lock:
if self.dyn: if self.dyn:
fee_rate = self.config.depth_to_fee(pos) if self.config.use_mempool_fees() else self.config.eta_to_fee(pos) fee_rate = self.config.depth_to_fee(pos) if self.config.use_mempool_fees() else self.config.eta_to_fee(pos)
else: else:
fee_rate = self.config.static_fee(pos) fee_rate = self.config.static_fee(pos)
return fee_rate
def moved(self, pos):
with self.lock:
fee_rate = self.get_fee_rate(pos)
tooltip = self.get_tooltip(pos, fee_rate) tooltip = self.get_tooltip(pos, fee_rate)
QToolTip.showText(QCursor.pos(), tooltip, self) QToolTip.showText(QCursor.pos(), tooltip, self)
self.setToolTip(tooltip) self.setToolTip(tooltip)
@ -60,6 +64,15 @@ class FeeSlider(QSlider):
else: else:
return _('Fixed rate') + ': ' + target + '\n' + _('Estimate') + ': ' + estimate 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): def update(self):
with self.lock: with self.lock:
self.dyn = self.config.is_dynfee() self.dyn = self.config.is_dynfee()

9
electrum/gui/qt/locktimeedit.py

@ -6,7 +6,7 @@ import time
from datetime import datetime from datetime import datetime
from typing import Optional, Any from typing import Optional, Any
from PyQt5.QtCore import Qt, QDateTime from PyQt5.QtCore import Qt, QDateTime, pyqtSignal
from PyQt5.QtGui import QPalette, QPainter from PyQt5.QtGui import QPalette, QPainter
from PyQt5.QtWidgets import (QWidget, QLineEdit, QStyle, QStyleOptionFrame, QComboBox, from PyQt5.QtWidgets import (QWidget, QLineEdit, QStyle, QStyleOptionFrame, QComboBox,
QHBoxLayout, QDateTimeEdit) QHBoxLayout, QDateTimeEdit)
@ -19,6 +19,8 @@ from .util import char_width_in_lineedit, ColorScheme
class LockTimeEdit(QWidget): class LockTimeEdit(QWidget):
valueEdited = pyqtSignal()
def __init__(self, parent=None): def __init__(self, parent=None):
QWidget.__init__(self, parent) QWidget.__init__(self, parent)
@ -63,6 +65,11 @@ class LockTimeEdit(QWidget):
hbox.addWidget(w) hbox.addWidget(w)
hbox.addStretch(1) hbox.addStretch(1)
self.locktime_height_e.textEdited.connect(self.valueEdited.emit)
self.locktime_raw_e.textEdited.connect(self.valueEdited.emit)
self.locktime_date_e.dateTimeChanged.connect(self.valueEdited.emit)
self.combo.currentIndexChanged.connect(self.valueEdited.emit)
def get_locktime(self) -> Optional[int]: def get_locktime(self) -> Optional[int]:
return self.editor.get_locktime() return self.editor.get_locktime()

15
electrum/gui/qt/main_window.py

@ -1258,17 +1258,16 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
msg = messages.MGS_CONFLICTING_BACKUP_INSTANCE msg = messages.MGS_CONFLICTING_BACKUP_INSTANCE
if not self.question(msg): if not self.question(msg):
return return
# use ConfirmTxDialog
# we need to know the fee before we broadcast, because the txid is required # 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) 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) d = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=funding_sat, allow_preview=False)
# disable preview button because the user must not broadcast tx before establishment_flow funding_tx = d.run()
d.preview_button.setEnabled(False) if not funding_tx:
cancelled, is_send, password, funding_tx = d.run()
if not is_send:
return
if cancelled:
return 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 # read funding_sat from tx; converts '!' to int value
funding_sat = funding_tx.output_value_for_address(ln_dummy_address()) funding_sat = funding_tx.output_value_for_address(ln_dummy_address())
def task(): def task():

173
electrum/gui/qt/rbf_dialog.py

@ -4,6 +4,7 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (QCheckBox, QLabel, QVBoxLayout, QGridLayout, QWidget, from PyQt5.QtWidgets import (QCheckBox, QLabel, QVBoxLayout, QGridLayout, QWidget,
QPushButton, QHBoxLayout, QComboBox) QPushButton, QHBoxLayout, QComboBox)
@ -20,7 +21,9 @@ if TYPE_CHECKING:
from .main_window import ElectrumWindow from .main_window import ElectrumWindow
class _BaseRBFDialog(WindowModalDialog): from .confirm_tx_dialog import ConfirmTxDialog, TxEditor, TxSizeLabel, HelpLabel
class _BaseRBFDialog(TxEditor):
def __init__( def __init__(
self, self,
@ -30,132 +33,104 @@ class _BaseRBFDialog(WindowModalDialog):
txid: str, txid: str,
title: str): title: str):
WindowModalDialog.__init__(self, main_window, title=title)
self.window = main_window
self.wallet = main_window.wallet self.wallet = main_window.wallet
self.tx = tx self.old_tx = tx
self.new_tx = None
assert txid assert txid
self.txid = txid self.old_txid = txid
self.message = '' self.message = ''
fee = tx.get_fee() self.old_fee = self.old_tx.get_fee()
assert fee is not None self.old_tx_size = tx.estimated_size()
tx_size = tx.estimated_size() self.old_fee_rate = old_fee_rate = self.old_fee / self.old_tx_size # sat/vbyte
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.method_label = QLabel(_('Method') + ':') TxEditor.__init__(
self.method_combo = QComboBox() self,
self.method_combo.addItems([_('Preserve payment'), _('Decrease payment')]) window=main_window,
self.method_combo.currentIndexChanged.connect(self.update) title=title,
grid.addWidget(self.method_label, 0, 0) make_tx=self.rbf_func)
grid.addWidget(self.method_combo, 0, 1)
grid.addWidget(QLabel(_('Current fee') + ':'), 1, 0) new_fee_rate = self.old_fee_rate + max(1, self.old_fee_rate // 20)
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)
self.feerate_e.setAmount(new_fee_rate) self.feerate_e.setAmount(new_fee_rate)
self._update_tx(new_fee_rate) self.update()
self._update_message() self.fee_slider.deactivate()
# give focus to fee slider
fee_slider.activate()
fee_slider.setFocus()
# are we paying max? # are we paying max?
invoices = self.wallet.get_relevant_invoices_for_tx(txid) invoices = self.wallet.get_relevant_invoices_for_tx(txid)
if len(invoices) == 1 and len(invoices[0].outputs) == 1: if len(invoices) == 1 and len(invoices[0].outputs) == 1:
if invoices[0].outputs[0].value == '!': if invoices[0].outputs[0].value == '!':
self.set_decrease_payment() 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.trigger_update)
self.method_combo.setFocusPolicy(Qt.NoFocus)
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): def is_decrease_payment(self):
return self.method_combo.currentIndex() == 1 return self.method_combo.currentIndex() == 1
def set_decrease_payment(self): def set_decrease_payment(self):
self.method_combo.setCurrentIndex(1) self.method_combo.setCurrentIndex(1)
def rbf_func(self, fee_rate) -> PartialTransaction:
raise NotImplementedError() # implemented by subclasses
def run(self) -> None: def run(self) -> None:
if not self.exec_(): if not self.exec_():
return return
self.new_tx.set_rbf(True) if self.is_preview:
tx_label = self.wallet.get_label_for_txid(self.txid) self.main_window.show_transaction(self.tx)
self.window.show_transaction(self.new_tx, tx_desc=tx_label) return
# TODO maybe save tx_label as label for new tx?? def sign_done(success):
if success:
def update(self): 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() 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: if fee_rate is None:
self.new_tx = None self.tx = None
self.message = '' self.error = _('No fee rate')
elif fee_rate <= self.old_fee_rate: 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.") self.error = _("The new fee rate needs to be higher than the old fee rate.")
else: else:
try: try:
self.new_tx = self.rbf_func(fee_rate) self.tx = self.make_tx(fee_rate)
except CannotBumpFee as e: except CannotBumpFee as e:
self.new_tx = None self.tx = None
self.message = str(e) self.error = str(e)
if not self.new_tx: if not self.tx:
return 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(): 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: 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)
self.ok_button.setEnabled(enabled)
if enabled:
style = ColorScheme.BLUE.as_stylesheet()
else:
style = ColorScheme.RED.as_stylesheet()
self.message_label.setStyleSheet(style)
self.message_label.setText(self.message)
class BumpFeeDialog(_BaseRBFDialog): class BumpFeeDialog(_BaseRBFDialog):
@ -177,10 +152,10 @@ class BumpFeeDialog(_BaseRBFDialog):
def rbf_func(self, fee_rate): def rbf_func(self, fee_rate):
return self.wallet.bump_fee( return self.wallet.bump_fee(
tx=self.tx, tx=self.old_tx,
txid=self.txid, txid=self.old_txid,
new_fee_rate=fee_rate, new_fee_rate=fee_rate,
coins=self.window.get_coins(), coins=self.main_window.get_coins(),
decrease_payment=self.is_decrease_payment()) decrease_payment=self.is_decrease_payment())
@ -206,4 +181,4 @@ class DSCancelDialog(_BaseRBFDialog):
self.method_combo.setVisible(False) self.method_combo.setVisible(False)
def rbf_func(self, fee_rate): 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)

38
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 .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit
from .util import WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit from .util import WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit
from .confirm_tx_dialog import ConfirmTxDialog from .confirm_tx_dialog import ConfirmTxDialog
from .transaction_dialog import PreviewTxDialog
if TYPE_CHECKING: if TYPE_CHECKING:
from .main_window import ElectrumWindow from .main_window import ElectrumWindow
@ -234,7 +233,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
output_value = '!' output_value = '!'
else: else:
output_value = sum(output_values) 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: if conf_dlg.not_enough_funds:
# Check if we had enough funds excluding fees, # Check if we had enough funds excluding fees,
# if so, still provide opportunity to set lower fees. # if so, still provide opportunity to set lower fees.
@ -243,37 +242,26 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.show_message(text) self.show_message(text)
return return
# shortcut to advanced preview (after "enough funds" check!) tx = conf_dlg.run()
if self.config.get('advanced_preview'): if tx is None:
preview_dlg = PreviewTxDialog( # user cancelled
window=self.window,
make_tx=make_tx,
external_keypairs=external_keypairs,
output_value=output_value)
preview_dlg.show()
return return
is_preview = conf_dlg.is_preview
cancelled, is_send, password, tx = conf_dlg.run() if is_preview:
if cancelled: self.window.show_transaction(tx)
return return
if is_send:
self.save_pending_invoice() self.save_pending_invoice()
def sign_done(success): def sign_done(success):
if success: if success:
self.window.broadcast_or_show(tx) self.window.broadcast_or_show(tx)
self.window.sign_tx_with_password( else:
raise
self.window.sign_tx(
tx, tx,
callback=sign_done, callback=sign_done,
password=password, external_keypairs=external_keypairs)
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()
def get_text_not_enough_funds_mentioning_frozen(self) -> str: def get_text_not_enough_funds_mentioning_frozen(self) -> str:
text = _("Not enough funds") text = _("Not enough funds")

8
electrum/gui/qt/settings_dialog.py

@ -276,13 +276,6 @@ class SettingsDialog(QDialog, QtEventListener):
filelogging_cb.stateChanged.connect(on_set_filelogging) filelogging_cb.stateChanged.connect(on_set_filelogging)
filelogging_cb.setToolTip(_('Debug logs can be persisted to disk. These are useful for troubleshooting.')) 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 = QCheckBox(_('Use change addresses'))
usechange_cb.setChecked(self.wallet.use_change) usechange_cb.setChecked(self.wallet.use_change)
if not self.config.is_modifiable('use_change'): usechange_cb.setEnabled(False) if not self.config.is_modifiable('use_change'): usechange_cb.setEnabled(False)
@ -494,7 +487,6 @@ class SettingsDialog(QDialog, QtEventListener):
tx_widgets = [] tx_widgets = []
tx_widgets.append((usechange_cb, None)) tx_widgets.append((usechange_cb, None))
tx_widgets.append((batch_rbf_cb, None)) tx_widgets.append((batch_rbf_cb, None))
tx_widgets.append((preview_cb, None))
tx_widgets.append((unconf_cb, None)) tx_widgets.append((unconf_cb, None))
tx_widgets.append((multiple_cb, None)) tx_widgets.append((multiple_cb, None))
tx_widgets.append((outrounding_cb, None)) tx_widgets.append((outrounding_cb, None))

267
electrum/gui/qt/transaction_dialog.py

@ -43,6 +43,7 @@ from qrcode import exceptions
from electrum.simple_config import SimpleConfig from electrum.simple_config import SimpleConfig
from electrum.util import quantize_feerate from electrum.util import quantize_feerate
from electrum import bitcoin from electrum import bitcoin
from electrum.bitcoin import base_encode, NLOCKTIME_BLOCKHEIGHT_MAX from electrum.bitcoin import base_encode, NLOCKTIME_BLOCKHEIGHT_MAX
from electrum.i18n import _ from electrum.i18n import _
from electrum.plugin import run_hook from electrum.plugin import run_hook
@ -59,10 +60,6 @@ from .util import (MessageBoxMixin, read_QIcon, Buttons, icon_path,
BlockingWaitingDialog, getSaveFileName, ColorSchemeItem, BlockingWaitingDialog, getSaveFileName, ColorSchemeItem,
get_iconname_qrcode) 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: if TYPE_CHECKING:
from .main_window import ElectrumWindow from .main_window import ElectrumWindow
@ -353,6 +350,7 @@ class TxInOutWidget(QWidget):
menu.exec_(global_pos) menu.exec_(global_pos)
def show_transaction(tx: Transaction, *, parent: 'ElectrumWindow', desc=None, prompt_if_unsaved=False): def show_transaction(tx: Transaction, *, parent: 'ElectrumWindow', desc=None, prompt_if_unsaved=False):
try: try:
d = TxDialog(tx, parent=parent, desc=desc, prompt_if_unsaved=prompt_if_unsaved) 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.setMenu(export_actions_menu)
self.export_actions_button.setPopupMode(QToolButton.InstantPopup) 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() partial_tx_actions_menu = QMenu()
ptx_merge_sigs_action = QAction(_("Merge signatures from"), self) ptx_merge_sigs_action = QAction(_("Merge signatures from"), self)
ptx_merge_sigs_action.triggered.connect(self.merge_sigs) ptx_merge_sigs_action.triggered.connect(self.merge_sigs)
@ -447,11 +442,11 @@ class BaseTxDialog(QDialog, MessageBoxMixin):
# Action buttons # Action buttons
self.buttons = [self.partial_tx_actions_button, self.sign_button, self.broadcast_button, self.cancel_button] self.buttons = [self.partial_tx_actions_button, self.sign_button, self.broadcast_button, self.cancel_button]
# Transaction sharing buttons # 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) run_hook('transaction_dialog', self)
if not self.finalized: #if not self.finalized:
self.create_fee_controls() # self.create_fee_controls()
vbox.addWidget(self.feecontrol_fields) # vbox.addWidget(self.feecontrol_fields)
self.hbox = hbox = QHBoxLayout() self.hbox = hbox = QHBoxLayout()
hbox.addLayout(Buttons(*self.sharing_buttons)) hbox.addLayout(Buttons(*self.sharing_buttons))
hbox.addStretch(1) hbox.addStretch(1)
@ -464,8 +459,6 @@ class BaseTxDialog(QDialog, MessageBoxMixin):
def set_buttons_visibility(self): 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]: 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) b.setVisible(self.finalized)
for b in [self.finalize_button]:
b.setVisible(not self.finalized)
def set_tx(self, tx: 'Transaction'): def set_tx(self, tx: 'Transaction'):
# Take a copy; it might get updated in the main window by # Take a copy; it might get updated in the main window by
@ -659,9 +652,6 @@ class BaseTxDialog(QDialog, MessageBoxMixin):
self.update() self.update()
def update(self): def update(self):
if not self.finalized:
self.update_fee_fields()
self.finalize_button.setEnabled(self.can_finalize())
if self.tx is None: if self.tx is None:
return return
self.io_widget.update(self.tx) self.io_widget.update(self.tx)
@ -723,8 +713,7 @@ class BaseTxDialog(QDialog, MessageBoxMixin):
else: else:
locktime_final_str = f"LockTime: {self.tx.locktime} ({datetime.datetime.fromtimestamp(self.tx.locktime)})" locktime_final_str = f"LockTime: {self.tx.locktime} ({datetime.datetime.fromtimestamp(self.tx.locktime)})"
self.locktime_final_label.setText(locktime_final_str) 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()}") self.rbf_label.setText(_('Replace by fee') + f": {not self.tx.is_final()}")
if tx_mined_status.header_hash: if tx_mined_status.header_hash:
@ -768,9 +757,6 @@ class BaseTxDialog(QDialog, MessageBoxMixin):
fee_rate = Decimal(fee) / size # sat/byte fee_rate = Decimal(fee) / size # sat/byte
fee_str += ' ( %s ) ' % self.main_window.format_fee_rate(fee_rate * 1000) fee_str += ' ( %s ) ' % self.main_window.format_fee_rate(fee_rate * 1000)
if isinstance(self.tx, PartialTransaction): 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( fee_warning_tuple = self.wallet.get_tx_fee_warning(
invoice_amt=invoice_amt, tx_size=size, fee=fee) invoice_amt=invoice_amt, tx_size=size, fee=fee)
@ -865,19 +851,6 @@ class BaseTxDialog(QDialog, MessageBoxMixin):
self.locktime_final_label = TxDetailLabel() self.locktime_final_label = TxDetailLabel()
vbox_right.addWidget(self.locktime_final_label) 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() self.block_height_label = TxDetailLabel()
vbox_right.addWidget(self.block_height_label) vbox_right.addWidget(self.block_height_label)
vbox_right.addStretch(1) vbox_right.addStretch(1)
@ -892,7 +865,6 @@ class BaseTxDialog(QDialog, MessageBoxMixin):
# set visibility after parenting can be determined by Qt # set visibility after parenting can be determined by Qt
self.rbf_label.setVisible(self.finalized) self.rbf_label.setVisible(self.finalized)
self.locktime_final_label.setVisible(self.finalized) self.locktime_final_label.setVisible(self.finalized)
self.locktime_setter_widget.setVisible(not self.finalized)
def set_title(self): def set_title(self):
self.setWindowTitle(_("Create transaction") if not self.finalized else _("Transaction")) self.setWindowTitle(_("Create transaction") if not self.finalized else _("Transaction"))
@ -947,228 +919,3 @@ class TxDialog(BaseTxDialog):
self.update() 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()

2
electrum/wallet.py

@ -2770,7 +2770,7 @@ class Abstract_Wallet(ABC, Logger, EventListener):
fee: int) -> Optional[Tuple[bool, str, str]]: fee: int) -> Optional[Tuple[bool, str, str]]:
feerate = Decimal(fee) / tx_size # sat/byte 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 long_warning = None
short_warning = None short_warning = None
allow_send = True allow_send = True

Loading…
Cancel
Save