electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

commit dd6cb2caf7e1eff6a8af07a2e0363da371aa1b13
parent f8c84fbb1e5d316a734e386ccc55190957a607ee
Author: ThomasV <thomasv@electrum.org>
Date:   Mon,  4 Nov 2019 08:40:06 +0100

GUI: Separate output selection and transaction finalization.
 - Output selection belongs in the Send tab.
 - Tx finalization is performed in a confirmation dialog
   (ConfirmTxDialog or PreviewTxDialog)
 - the fee slider is shown in the confirmation dialog
 - coin control works by selecting items in the coins tab
 - user can save invoices and pay them later
 - ConfirmTxDialog is used when opening channels and sweeping keys

Diffstat:
MRELEASE-NOTES | 1+
Aelectrum/gui/qt/confirm_tx_dialog.py | 261+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Melectrum/gui/qt/invoice_list.py | 6++++++
Melectrum/gui/qt/main_window.py | 530++++++++++++-------------------------------------------------------------------
Melectrum/gui/qt/settings_dialog.py | 17++++++++---------
Melectrum/gui/qt/transaction_dialog.py | 307++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Melectrum/gui/qt/utxo_list.py | 35++++++++++++++++++++++++++++++-----
7 files changed, 655 insertions(+), 502 deletions(-)

diff --git a/RELEASE-NOTES b/RELEASE-NOTES @@ -1,6 +1,7 @@ # Release 4.0 - (Not released yet; release notes are incomplete) * Lightning Network + * Qt GUI: Separation between output selection and transaction finalization. * Http PayServer can be configured from GUI # Release 3.3.8 - (July 11, 2019) diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (2019) The Electrum Developers +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from typing import TYPE_CHECKING +import copy + +from PyQt5.QtCore import Qt, QSize +from PyQt5.QtGui import QTextCharFormat, QBrush, QFont +from PyQt5.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QWidget, QTextEdit, QLineEdit, QCheckBox + +from electrum.i18n import _ +from electrum.util import quantize_feerate, NotEnoughFunds, NoDynamicFeeEstimates +from electrum.plugin import run_hook +from electrum.transaction import TxOutput +from electrum.simple_config import SimpleConfig, FEERATE_WARNING_HIGH_FEE +from electrum.wallet import InternalAddressCorruption + +from .util import WindowModalDialog, ButtonsLineEdit, ColorScheme, Buttons, CloseButton, FromList, HelpLabel, read_QIcon, char_width_in_lineedit, Buttons, CancelButton, OkButton +from .util import MONOSPACE_FONT + +from .fee_slider import FeeSlider +from .history_list import HistoryList, HistoryModel +from .qrtextedit import ShowQRTextEdit + +if TYPE_CHECKING: + from .main_window import ElectrumWindow + + + +class TxEditor: + + def __init__(self, window, inputs, outputs, external_keypairs): + self.main_window = window + self.outputs = outputs + self.get_coins = inputs + self.tx = None + self.config = window.config + self.wallet = window.wallet + self.external_keypairs = external_keypairs + self.not_enough_funds = False + self.no_dynfee_estimates = False + self.needs_update = False + self.password_required = self.wallet.has_keystore_encryption() and not external_keypairs + self.main_window.gui_object.timer.timeout.connect(self.timer_actions) + + def timer_actions(self): + if self.needs_update: + self.update_tx() + self.update() + self.needs_update = False + + def fee_slider_callback(self, dyn, pos, fee_rate): + if dyn: + if self.config.use_mempool_fees(): + self.config.set_key('depth_level', pos, False) + else: + self.config.set_key('fee_level', pos, False) + else: + self.config.set_key('fee_per_kb', fee_rate, False) + self.needs_update = True + + def get_fee_estimator(self): + return None + + def update_tx(self): + fee_estimator = self.get_fee_estimator() + is_sweep = bool(self.external_keypairs) + coins = self.get_coins() + # deepcopy outputs because '!' is converted to number + outputs = copy.deepcopy(self.outputs) + make_tx = lambda fee_est: self.wallet.make_unsigned_transaction( + coins=coins, + outputs=outputs, + fee=fee_est, + is_sweep=is_sweep) + try: + self.tx = make_tx(fee_estimator) + self.not_enough_funds = False + self.no_dynfee_estimates = False + except NotEnoughFunds: + self.not_enough_funds = True + self.tx = None + return + except NoDynamicFeeEstimates: + self.no_dynfee_estimates = True + self.tx = None + try: + self.tx = make_tx(0) + except BaseException: + return + except InternalAddressCorruption as e: + self.tx = None + self.main_window.show_error(str(e)) + raise + except BaseException as e: + self.tx = None + self.main_window.logger.exception('') + self.show_message(str(e)) + return + use_rbf = bool(self.config.get('use_rbf', True)) + if use_rbf: + self.tx.set_rbf(True) + + + + + + +class ConfirmTxDialog(TxEditor, WindowModalDialog): + # set fee and return password (after pw check) + + def __init__(self, window: 'ElectrumWindow', inputs, outputs, external_keypairs): + + TxEditor.__init__(self, window, inputs, outputs, external_keypairs) + WindowModalDialog.__init__(self, window, _("Confirm Transaction")) + vbox = QVBoxLayout() + self.setLayout(vbox) + grid = QGridLayout() + vbox.addLayout(grid) + self.amount_label = QLabel('') + grid.addWidget(QLabel(_("Amount to be sent") + ": "), 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('') + grid.addWidget(HelpLabel(_("Mining fee") + ": ", msg), 1, 0) + grid.addWidget(self.fee_label, 1, 1) + + self.extra_fee_label = QLabel(_("Additional fees") + ": ") + self.extra_fee_label.setVisible(False) + self.extra_fee_value = QLabel('') + 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) + grid.addWidget(self.fee_slider, 5, 1) + + self.message_label = QLabel(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 = QLineEdit() + self.pw.setEchoMode(2) + self.pw.setVisible(self.password_required) + grid.addWidget(self.pw_label, 8, 0) + grid.addWidget(self.pw, 8, 1, 1, -1) + vbox.addLayout(grid) + 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)) + 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 + 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: + 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 disable(self, reason): + self.message_label.setStyleSheet(ColorScheme.RED.as_stylesheet()) + self.message_label.setText(reason) + self.pw.setEnabled(False) + self.send_button.setEnabled(False) + + def enable(self): + self.message_label.setStyleSheet(None) + self.message_label.setText(self.default_message()) + self.pw.setEnabled(True) + self.send_button.setEnabled(True) + + def update(self): + tx = self.tx + output_values = [x.value for x in self.outputs] + is_max = '!' in output_values + amount = tx.output_value() if is_max else sum(output_values) + self.amount_label.setText(self.main_window.format_amount_and_units(amount)) + + if self.not_enough_funds: + text = _("Not enough funds") + c, u, x = self.wallet.get_frozen_balance() + if c+u+x: + text += " ({} {} {})".format( + self.main_window.format_amount(c + u + x).strip(), self.main_window.base_unit(), _("are frozen") + ) + self.disable(text) + return + + if not tx: + return + + fee = tx.get_fee() + 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)) + + feerate_warning = FEERATE_WARNING_HIGH_FEE + low_fee = fee < self.wallet.relayfee() * tx.estimated_size() / 1000 + high_fee = fee > feerate_warning * tx.estimated_size() / 1000 + if low_fee: + msg = '\n'.join([ + _("This transaction requires a higher fee, or it will not be propagated by your current server"), + _("Try to raise your transaction fee, or use a server with a lower relay fee.") + ]) + self.disable(msg) + elif high_fee: + self.disable(_('Warning') + ': ' + _("The fee for this transaction seems unusually high.")) + else: + self.enable() diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py @@ -27,6 +27,7 @@ from enum import IntEnum from PyQt5.QtCore import Qt, QItemSelectionModel from PyQt5.QtGui import QStandardItemModel, QStandardItem, QFont +from PyQt5.QtWidgets import QAbstractItemView from PyQt5.QtWidgets import QHeaderView, QMenu, QVBoxLayout, QGridLayout, QLabel, QTreeWidget, QTreeWidgetItem from electrum.i18n import _ @@ -70,6 +71,7 @@ class InvoiceList(MyTreeView): editable_columns=[]) self.setSortingEnabled(True) self.setModel(QStandardItemModel(self)) + self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.update() def update_item(self, key, status): @@ -143,6 +145,10 @@ class InvoiceList(MyTreeView): export_meta_gui(self.parent, _('invoices'), self.parent.invoices.export_file) def create_menu(self, position): + items = self.selected_in_column(0) + if len(items) > 1: + print(items) + return idx = self.indexAt(position) item = self.model().itemFromIndex(idx) item_col0 = self.model().itemFromIndex(idx.sibling(idx.row(), self.Columns.DATE)) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py @@ -95,6 +95,8 @@ from .installwizard import WIF_HELP_TEXT from .history_list import HistoryList, HistoryModel from .update_checker import UpdateCheck, UpdateCheckThread from .channels_list import ChannelsList +from .confirm_tx_dialog import ConfirmTxDialog +from .transaction_dialog import PreviewTxDialog if TYPE_CHECKING: from . import ElectrumGui @@ -153,11 +155,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.payto_URI = None self.checking_accounts = False self.qr_window = None - self.not_enough_funds = False self.pluginsdialog = None self.require_fee_update = False self.tl_windows = [] - self.tx_external_keypairs = {} Logger.__init__(self) self.tx_notification_queue = queue.Queue() @@ -174,8 +174,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.completions = QStringListModel() - self.send_tab_is_onchain = False - self.tabs = tabs = QTabWidget(self) self.send_tab = self.create_send_tab() self.receive_tab = self.create_receive_tab() @@ -244,7 +242,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.console.showMessage(self.network.banner) # update fee slider in case we missed the callback - self.fee_slider.update() + #self.fee_slider.update() self.load_wallet(wallet) gui_object.timer.timeout.connect(self.timer_actions) self.fetch_alias() @@ -397,11 +395,11 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.history_model.update_tx_mined_status(tx_hash, tx_mined_status) elif event == 'fee': if self.config.is_dynfee(): - self.fee_slider.update() + #self.fee_slider.update() self.require_fee_update = True elif event == 'fee_histogram': if self.config.is_dynfee(): - self.fee_slider.update() + #self.fee_slider.update() self.require_fee_update = True self.history_model.on_fee_histogram() else: @@ -769,7 +767,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.payto_e.resolve() # update fee if self.require_fee_update: - self.do_update_fee() + #self.do_update_fee() self.require_fee_update = False self.notify_transactions() @@ -946,7 +944,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): if not self.fx or not self.fx.is_enabled(): self.fiat_receive_e.setVisible(False) grid.addWidget(self.fiat_receive_e, 1, 2, Qt.AlignLeft) + self.connect_fields(self, self.receive_amount_e, self.fiat_receive_e, None) + self.connect_fields(self, self.amount_e, self.fiat_send_e, None) self.expires_combo = QComboBox() evl = sorted(pr_expiration_values.items()) @@ -1179,10 +1179,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.receive_address_e.setStyleSheet("") self.receive_address_e.setToolTip("") - def set_feerounding_text(self, num_satoshis_added): - self.feerounding_text = (_('Additional {} satoshis are going to be added.') - .format(num_satoshis_added)) - def create_send_tab(self): # A 4-column grid layout. All the stretch is in the last column. # The exchange rate plugin adds a fiat widget in column 2 @@ -1232,131 +1228,18 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.max_button.setCheckable(True) grid.addWidget(self.max_button, 3, 3) - self.from_label = QLabel(_('From')) - grid.addWidget(self.from_label, 4, 0) - self.from_list = FromList(self, self.from_list_menu) - grid.addWidget(self.from_list, 4, 1, 1, -1) - self.set_pay_from([]) - - 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_e_label = HelpLabel(_('Fee'), msg) - - def fee_cb(dyn, pos, fee_rate): - if dyn: - if self.config.use_mempool_fees(): - self.config.set_key('depth_level', pos, False) - else: - self.config.set_key('fee_level', pos, False) - else: - self.config.set_key('fee_per_kb', fee_rate, False) - - 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.fee_slider.activate() - self.spend_max() if self.max_button.isChecked() else self.update_fee() - - self.fee_slider = FeeSlider(self, self.config, fee_cb) - self.fee_slider.setFixedWidth(self.amount_e.width()) - - def on_fee_or_feerate(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_fee() - - class TxSizeLabel(QLabel): - def setAmount(self, byte_size): - self.setText(('x %s bytes =' % byte_size) if byte_size else '') - - self.size_e = TxSizeLabel() - self.size_e.setAlignment(Qt.AlignCenter) - self.size_e.setAmount(0) - self.size_e.setFixedWidth(self.amount_e.width()) - self.size_e.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(on_fee_or_feerate, self.feerate_e, False)) - self.feerate_e.editingFinished.connect(partial(on_fee_or_feerate, self.feerate_e, True)) - - self.fee_e = BTCAmountEdit(self.get_decimal_point) - self.fee_e.textEdited.connect(partial(on_fee_or_feerate, self.fee_e, False)) - self.fee_e.editingFinished.connect(partial(on_fee_or_feerate, self.fee_e, True)) - - 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 = QPushButton(read_QIcon('info.png'), '') - self.feerounding_icon.setFixedWidth(round(2.2 * char_width_in_lineedit())) - self.feerounding_icon.setFlat(True) - self.feerounding_icon.clicked.connect(feerounding_onclick) - self.feerounding_icon.setVisible(False) - - self.connect_fields(self, self.amount_e, self.fiat_send_e, self.fee_e) - - vbox_feelabel = QVBoxLayout() - vbox_feelabel.addWidget(self.fee_e_label) - vbox_feelabel.addStretch(1) - grid.addLayout(vbox_feelabel, 5, 0) - - self.fee_adv_controls = QWidget() - hbox = QHBoxLayout(self.fee_adv_controls) - hbox.setContentsMargins(0, 0, 0, 0) - hbox.addWidget(self.feerate_e) - hbox.addWidget(self.size_e) - hbox.addWidget(self.fee_e) - hbox.addWidget(self.feerounding_icon, Qt.AlignLeft) - hbox.addStretch(1) - - self.feecontrol_fields = QWidget() - vbox_feecontrol = QVBoxLayout(self.feecontrol_fields) - vbox_feecontrol.setContentsMargins(0, 0, 0, 0) - vbox_feecontrol.addWidget(self.fee_adv_controls) - vbox_feecontrol.addWidget(self.fee_slider) - - grid.addWidget(self.feecontrol_fields, 5, 1, 1, -1) - - if not self.config.get('show_fee', False): - self.fee_adv_controls.setVisible(False) - self.save_button = EnterButton(_("Save"), self.do_save_invoice) - self.preview_button = EnterButton(_("Preview"), self.do_preview) - self.preview_button.setToolTip(_('Display the details of your transaction before signing it.')) - self.send_button = EnterButton(_("Send"), self.do_pay) + self.send_button = EnterButton(_("Pay"), self.do_pay) self.clear_button = EnterButton(_("Clear"), self.do_clear) buttons = QHBoxLayout() buttons.addStretch(1) buttons.addWidget(self.clear_button) buttons.addWidget(self.save_button) - buttons.addWidget(self.preview_button) buttons.addWidget(self.send_button) grid.addLayout(buttons, 6, 1, 1, 4) self.amount_e.shortcut.connect(self.spend_max) - self.payto_e.textChanged.connect(self.update_fee) - self.amount_e.textEdited.connect(self.update_fee) def reset_max(text): self.max_button.setChecked(False) @@ -1365,45 +1248,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.amount_e.textEdited.connect(reset_max) self.fiat_send_e.textEdited.connect(reset_max) - def entry_changed(): - text = "" - - amt_color = ColorScheme.DEFAULT - fee_color = ColorScheme.DEFAULT - feerate_color = ColorScheme.DEFAULT - - if self.not_enough_funds: - amt_color, fee_color = ColorScheme.RED, ColorScheme.RED - feerate_color = ColorScheme.RED - text = _("Not enough funds") - c, u, x = self.wallet.get_frozen_balance() - if c+u+x: - text += " ({} {} {})".format( - self.format_amount(c + u + x).strip(), self.base_unit(), _("are frozen") - ) - - # blue color denotes auto-filled values - elif self.fee_e.isModified(): - feerate_color = ColorScheme.BLUE - elif self.feerate_e.isModified(): - fee_color = ColorScheme.BLUE - elif self.amount_e.isModified(): - fee_color = ColorScheme.BLUE - feerate_color = ColorScheme.BLUE - else: - amt_color = ColorScheme.BLUE - fee_color = ColorScheme.BLUE - feerate_color = ColorScheme.BLUE - - self.statusBar().showMessage(text) - self.amount_e.setStyleSheet(amt_color.as_stylesheet()) - self.fee_e.setStyleSheet(fee_color.as_stylesheet()) - self.feerate_e.setStyleSheet(feerate_color.as_stylesheet()) - - self.amount_e.textChanged.connect(entry_changed) - self.fee_e.textChanged.connect(entry_changed) - self.feerate_e.textChanged.connect(entry_changed) - self.set_onchain(False) self.invoices_label = QLabel(_('Outgoing payments')) @@ -1430,144 +1274,12 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): if run_hook('abort_send', self): return self.max_button.setChecked(True) - self.do_update_fee() - - def update_fee(self): - self.require_fee_update = True - - def get_payto_or_dummy(self) -> bytes: - r = self.payto_e.get_destination_scriptpubkey() - if r: - return r - return bfh(bitcoin.address_to_script(self.wallet.dummy_address())) - - def do_update_fee(self): - '''Recalculate the fee. If the fee was manually input, retain it, but - still build the TX to see if there are enough funds. - ''' - if not self.is_onchain: - return - freeze_fee = self.is_send_fee_frozen() - freeze_feerate = self.is_send_feerate_frozen() - amount = '!' if self.max_button.isChecked() else self.amount_e.get_amount() - if amount is None: - if not freeze_fee: - self.fee_e.setAmount(None) - self.not_enough_funds = False - self.statusBar().showMessage('') - return - - outputs = self.read_outputs() - fee_estimator = self.get_send_fee_estimator() - coins = self.get_coins() - - if not outputs: - scriptpubkey = self.get_payto_or_dummy() - outputs = [PartialTxOutput(scriptpubkey=scriptpubkey, value=amount)] - is_sweep = bool(self.tx_external_keypairs) - make_tx = lambda fee_est: \ - self.wallet.make_unsigned_transaction( - coins=coins, - outputs=outputs, - fee=fee_est, - is_sweep=is_sweep) - try: - tx = make_tx(fee_estimator) - self.not_enough_funds = False - except (NotEnoughFunds, NoDynamicFeeEstimates) as e: - if not freeze_fee: - self.fee_e.setAmount(None) - if not freeze_feerate: - self.feerate_e.setAmount(None) - self.feerounding_icon.setVisible(False) - - if isinstance(e, NotEnoughFunds): - self.not_enough_funds = True - elif isinstance(e, NoDynamicFeeEstimates): - try: - tx = make_tx(0) - size = tx.estimated_size() - self.size_e.setAmount(size) - except BaseException: - pass - return - except BaseException: - self.logger.exception('') - return - - size = tx.estimated_size() - self.size_e.setAmount(size) - - fee = tx.get_fee() - fee = None if self.not_enough_funds else 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) - else: - # 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 else 0 - self.set_feerounding_text(int(feerounding)) - self.feerounding_icon.setToolTip(self.feerounding_text) - self.feerounding_icon.setVisible(abs(feerounding) >= 1) - - if self.max_button.isChecked(): - amount = tx.output_value() - __, x_fee_amount = run_hook('get_tx_extra_fee', self.wallet, tx) or (None, 0) - amount_after_all_fees = amount - x_fee_amount - self.amount_e.setAmount(amount_after_all_fees) - - def from_list_delete(self, item): - i = self.from_list.indexOfTopLevelItem(item) - self.pay_from.pop(i) - self.redraw_from_list() - self.update_fee() - - def from_list_menu(self, position): - item = self.from_list.itemAt(position) - menu = QMenu() - menu.addAction(_("Remove"), lambda: self.from_list_delete(item)) - menu.exec_(self.from_list.viewport().mapToGlobal(position)) - - def set_pay_from(self, coins: Sequence[PartialTxInput]): - self.pay_from = list(coins) - self.redraw_from_list() - - def redraw_from_list(self): - self.from_list.clear() - self.from_label.setHidden(len(self.pay_from) == 0) - self.from_list.setHidden(len(self.pay_from) == 0) - - def format(txin: PartialTxInput): - h = txin.prevout.txid.hex() - out_idx = txin.prevout.out_idx - addr = txin.address - return h[0:10] + '...' + h[-10:] + ":%d"%out_idx + '\t' + addr + '\t' - - for coin in self.pay_from: - item = QTreeWidgetItem([format(coin), self.format_amount(coin.value_sats())]) - item.setFont(0, QFont(MONOSPACE_FONT)) - self.from_list.addTopLevelItem(item) + amount = sum(x.value_sats() for x in self.get_coins()) + self.amount_e.setAmount(amount) + ## substract extra fee + #__, x_fee_amount = run_hook('get_tx_extra_fee', self.wallet, tx) or (None, 0) + #amount_after_all_fees = amount - x_fee_amount + #self.amount_e.setAmount(amount_after_all_fees) def get_contact_payto(self, key): _type, label = self.contacts.get(key) @@ -1605,26 +1317,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def protect(self, func, args, password): return func(*args, password) - 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 get_send_fee_estimator(self): - if self.is_send_fee_frozen(): - fee_estimator = self.fee_e.get_amount() - elif self.is_send_feerate_frozen(): - amount = self.feerate_e.get_amount() # sat/byte feerate - amount = 0 if amount is None else amount * 1000 # sat/kilobyte feerate - fee_estimator = partial( - simple_config.SimpleConfig.estimate_fee_for_feerate, amount) - else: - fee_estimator = None - return fee_estimator - def read_outputs(self) -> List[PartialTxOutput]: if self.payment_request: outputs = self.payment_request.get_outputs() @@ -1734,115 +1426,69 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.do_clear() self.invoice_list.update() - def do_preview(self): - self.do_pay(preview=True) - - def do_pay(self, preview=False): + def do_pay(self): invoice = self.read_invoice() if not invoice: return self.wallet.save_invoice(invoice) self.invoice_list.update() - self.do_pay_invoice(invoice, preview) + self.do_clear() + self.do_pay_invoice(invoice) - def do_pay_invoice(self, invoice, preview=False): + def do_pay_invoice(self, invoice): if invoice['type'] == PR_TYPE_LN: self.pay_lightning_invoice(invoice['invoice']) - return elif invoice['type'] == PR_TYPE_ONCHAIN: - message = invoice['message'] - outputs = invoice['outputs'] # type: List[PartialTxOutput] + outputs = invoice['outputs'] + self.pay_onchain_dialog(self.get_coins, outputs, invoice=invoice) else: raise Exception('unknown invoice type') + def get_coins(self): + coins = self.utxo_list.get_spend_list() + return coins or self.wallet.get_spendable_coins(None) + + def pay_onchain_dialog(self, inputs, outputs, invoice=None, external_keypairs=None): + # trustedcoin requires this if run_hook('abort_send', self): return - - for txout in outputs: - assert isinstance(txout, PartialTxOutput) - fee_estimator = self.get_send_fee_estimator() - coins = self.get_coins() - try: - is_sweep = bool(self.tx_external_keypairs) - tx = self.wallet.make_unsigned_transaction( - coins=coins, - outputs=outputs, - fee=fee_estimator, - is_sweep=is_sweep) - except (NotEnoughFunds, NoDynamicFeeEstimates) as e: - self.show_message(str(e)) + if self.config.get('advanced_preview'): + self.preview_tx_dialog(inputs, outputs, invoice=invoice) return - except InternalAddressCorruption as e: - self.show_error(str(e)) - raise - except BaseException as e: - self.logger.exception('') - self.show_message(str(e)) + d = ConfirmTxDialog(self, inputs, outputs, external_keypairs) + d.update_tx() + if d.not_enough_funds: + self.show_message(_('Not Enough Funds')) return - - amount = tx.output_value() if self.max_button.isChecked() else sum(map(lambda x: x.value, outputs)) - fee = tx.get_fee() - - use_rbf = bool(self.config.get('use_rbf', True)) - if use_rbf: - tx.set_rbf(True) - - if fee < self.wallet.relayfee() * tx.estimated_size() / 1000: - self.show_error('\n'.join([ - _("This transaction requires a higher fee, or it will not be propagated by your current server"), - _("Try to raise your transaction fee, or use a server with a lower relay fee.") - ])) + cancelled, is_send, password, tx = d.run() + if cancelled: return + if is_send: + def sign_done(success): + if success: + self.broadcast_or_show(tx, invoice=invoice) + self.sign_tx_with_password(tx, sign_done, password, external_keypairs) + else: + self.preview_tx_dialog(inputs, outputs, external_keypairs=external_keypairs, invoice=invoice) - if preview: - self.show_transaction(tx, invoice=invoice) - return + def preview_tx_dialog(self, inputs, outputs, external_keypairs=None, invoice=None): + d = PreviewTxDialog(inputs, outputs, external_keypairs, window=self, invoice=invoice) + d.show() + def broadcast_or_show(self, tx, invoice=None): if not self.network: self.show_error(_("You can't broadcast a transaction without a live network connection.")) - return - - # confirmation dialog - msg = [ - _("Amount to be sent") + ": " + self.format_amount_and_units(amount), - _("Mining fee") + ": " + self.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 - msg.append( _("Additional fees") + ": " + self.format_amount_and_units(x_fee_amount) ) - - feerate_warning = simple_config.FEERATE_WARNING_HIGH_FEE - if fee > feerate_warning * tx.estimated_size() / 1000: - msg.append(_('Warning') + ': ' + _("The fee for this transaction seems unusually high.")) - - if self.wallet.has_keystore_encryption(): - msg.append("") - msg.append(_("Enter your password to proceed")) - password = self.password_dialog('\n'.join(msg)) - if not password: - return + self.show_transaction(tx, invoice=invoice) + elif not tx.is_complete(): + self.show_transaction(tx, invoice=invoice) else: - msg.append(_('Proceed?')) - password = None - if not self.question('\n'.join(msg)): - return - - def sign_done(success): - if success: - self.do_clear() - if not tx.is_complete(): - self.show_transaction(tx, invoice=invoice) - else: - self.broadcast_transaction(tx, invoice=invoice) - self.sign_tx_with_password(tx, sign_done, password) + self.broadcast_transaction(tx, invoice=invoice) @protected - def sign_tx(self, tx, callback, password): - self.sign_tx_with_password(tx, callback, password) + def sign_tx(self, tx, callback, external_keypairs, password): + self.sign_tx_with_password(tx, callback, password, external_keypairs=external_keypairs) - def sign_tx_with_password(self, tx: PartialTransaction, callback, password): + def sign_tx_with_password(self, tx: PartialTransaction, callback, password, external_keypairs=None): '''Sign the transaction in a separate thread. When done, calls the callback with a success code of True or False. ''' @@ -1852,9 +1498,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.on_error(exc_info) callback(False) on_success = run_hook('tc_sign_wrapper', self.wallet, tx, on_success, on_failure) or on_success - if self.tx_external_keypairs: + if external_keypairs: # can sign directly - task = partial(tx.sign, self.tx_external_keypairs) + task = partial(tx.sign, external_keypairs) else: task = partial(self.wallet.sign_transaction, tx, password) msg = _('Signing transaction...') @@ -1908,10 +1554,21 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): WaitingDialog(self, _('Broadcasting transaction...'), broadcast_thread, broadcast_done, self.on_error) - @protected - def open_channel(self, *args, **kwargs): + def open_channel(self, connect_str, local_amt, push_amt): + # use ConfirmTxDialog + # we need to know the fee before we broadcast, because the txid is required + # however, the user must be allowed to broadcast early + funding_sat = local_amt + push_amt + inputs = self.get_coins + outputs = [PartialTxOutput.from_address_and_value(self.wallet.dummy_address(), funding_sat)] + d = ConfirmTxDialog(self, inputs, outputs, None) + cancelled, is_send, password, tx = d.run() + if not is_send: + return + if cancelled: + return def task(): - return self.wallet.lnworker.open_channel(*args, **kwargs) + return self.wallet.lnworker.open_channel(connect_str, local_amt, push_amt, password) def on_success(chan): n = chan.constraints.funding_txn_minimum_depth message = '\n'.join([ @@ -2014,13 +1671,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def set_onchain(self, b): self.is_onchain = b - self.preview_button.setEnabled(b) self.max_button.setEnabled(b) - self.show_send_tab_onchain_fees(b) - - def show_send_tab_onchain_fees(self, b: bool): - self.feecontrol_fields.setEnabled(b) - #self.fee_e_label.setVisible(b) def pay_to_URI(self, URI): if not URI: @@ -2056,36 +1707,25 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def do_clear(self): self.max_button.setChecked(False) - self.not_enough_funds = False self.payment_request = None self.payto_URI = None self.payto_e.is_pr = False self.is_onchain = False self.set_onchain(False) - for e in [self.payto_e, self.message_e, self.amount_e, self.fiat_send_e, - self.fee_e, self.feerate_e]: + for e in [self.payto_e, self.message_e, self.amount_e]: e.setText('') e.setFrozen(False) - self.fee_slider.activate() - self.feerate_e.setAmount(self.config.fee_per_byte()) - self.size_e.setAmount(0) - self.feerounding_icon.setVisible(False) - self.set_pay_from([]) - self.tx_external_keypairs = {} self.update_status() run_hook('do_clear', self) - def set_frozen_state_of_addresses(self, addrs, freeze: bool): self.wallet.set_frozen_state_of_addresses(addrs, freeze) self.address_list.update() self.utxo_list.update() - self.update_fee() def set_frozen_state_of_coins(self, utxos: Sequence[PartialTxInput], freeze: bool): self.wallet.set_frozen_state_of_coins(utxos, freeze) self.utxo_list.update() - self.update_fee() def create_list_tab(self, l, toolbar=None): w = QWidget() @@ -2109,8 +1749,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def create_utxo_tab(self): from .utxo_list import UTXOList - self.utxo_list = l = UTXOList(self) - return self.create_list_tab(l) + self.utxo_list = UTXOList(self) + t = self.utxo_list.get_toolbar() + return self.create_list_tab(self.utxo_list, t) def create_contacts_tab(self): from .contact_list import ContactList @@ -2123,18 +1764,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.need_update.set() # history, addresses, coins self.clear_receive_tab() - def get_coins(self): - if self.pay_from: - return self.pay_from - else: - return self.wallet.get_spendable_coins(None) - - def spend_coins(self, coins: Sequence[PartialTxInput]): - self.set_pay_from(coins) - self.set_onchain(len(coins) > 0) - self.show_send_tab() - self.update_fee() - def paytomany(self): self.show_send_tab() self.payto_e.paytomany() @@ -2915,14 +2544,11 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def sweep_key_dialog(self): d = WindowModalDialog(self, title=_('Sweep private keys')) d.setMinimumSize(600, 300) - vbox = QVBoxLayout(d) - hbox_top = QHBoxLayout() hbox_top.addWidget(QLabel(_("Enter private keys:"))) hbox_top.addWidget(InfoButton(WIF_HELP_TEXT), alignment=Qt.AlignRight) vbox.addLayout(hbox_top) - keys_e = ScanQRTextEdit(allow_multi=True) keys_e.setTabChangesFocus(True) vbox.addWidget(keys_e) @@ -2978,14 +2604,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): except Exception as e: # FIXME too broad... self.show_message(repr(e)) return - self.do_clear() - self.tx_external_keypairs = keypairs - self.spend_coins(coins) - self.payto_e.setText(addr) - self.spend_max() - self.payto_e.setFrozen(True) - self.amount_e.setFrozen(True) + scriptpubkey = bfh(bitcoin.address_to_script(addr)) + outputs = [PartialTxOutput(scriptpubkey=scriptpubkey, value='!')] self.warn_if_watching_only() + self.pay_onchain_dialog(lambda: coins, outputs, invoice=None, external_keypairs=keypairs) def _do_import(self, title, header_layout, func): text = text_dialog(self, title, header_layout, _('Import'), allow_multi=True) diff --git a/electrum/gui/qt/settings_dialog.py b/electrum/gui/qt/settings_dialog.py @@ -120,15 +120,6 @@ class SettingsDialog(WindowModalDialog): fee_type_combo.currentIndexChanged.connect(on_fee_type) fee_widgets.append((fee_type_label, fee_type_combo)) - feebox_cb = QCheckBox(_('Edit fees manually')) - feebox_cb.setChecked(bool(self.config.get('show_fee', False))) - feebox_cb.setToolTip(_("Show fee edit box in send tab.")) - def on_feebox(x): - self.config.set_key('show_fee', x == Qt.Checked) - self.window.fee_adv_controls.setVisible(bool(x)) - feebox_cb.stateChanged.connect(on_feebox) - fee_widgets.append((feebox_cb, None)) - use_rbf = bool(self.config.get('use_rbf', True)) use_rbf_cb = QCheckBox(_('Use Replace-By-Fee')) use_rbf_cb.setChecked(use_rbf) @@ -321,6 +312,14 @@ that is always connected to the internet. Configure a port if you want it to be filelogging_cb.setToolTip(_('Debug logs can be persisted to disk. These are useful for troubleshooting.')) gui_widgets.append((filelogging_cb, None)) + 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) + tx_widgets.append((preview_cb, None)) + usechange_cb = QCheckBox(_('Use change addresses')) usechange_cb.setChecked(self.window.wallet.use_change) if not self.config.is_modifiable('use_change'): usechange_cb.setEnabled(False) diff --git a/electrum/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py @@ -29,14 +29,18 @@ import datetime import traceback import time from typing import TYPE_CHECKING, Callable +from functools import partial +from decimal import Decimal from PyQt5.QtCore import QSize, Qt from PyQt5.QtGui import QTextCharFormat, QBrush, QFont, QPixmap -from PyQt5.QtWidgets import (QDialog, QLabel, QPushButton, QHBoxLayout, QVBoxLayout, - QTextEdit, QFrame, QAction, QToolButton, QMenu) +from PyQt5.QtWidgets import (QDialog, QLabel, QPushButton, QHBoxLayout, QVBoxLayout, QWidget, + QTextEdit, QFrame, QAction, QToolButton, QMenu, QCheckBox) import qrcode from qrcode import exceptions +from electrum.simple_config import SimpleConfig +from electrum.util import quantize_feerate from electrum.bitcoin import base_encode from electrum.i18n import _ from electrum.plugin import run_hook @@ -49,9 +53,21 @@ from .util import (MessageBoxMixin, read_QIcon, Buttons, CopyButton, icon_path, MONOSPACE_FONT, ColorScheme, ButtonsLineEdit, text_dialog, char_width_in_lineedit, TRANSACTION_FILE_EXTENSION_FILTER) +from .fee_slider import FeeSlider +from .confirm_tx_dialog import TxEditor +from .amountedit import FeerateEdit, BTCAmountEdit + if TYPE_CHECKING: from .main_window import ElectrumWindow +class TxSizeLabel(QLabel): + def setAmount(self, byte_size): + self.setText(('x %s bytes =' % byte_size) if byte_size else '') + +class QTextEditWithDefaultSize(QTextEdit): + def sizeHint(self): + return QSize(0, 100) + SAVE_BUTTON_ENABLED_TOOLTIP = _("Save transaction offline") SAVE_BUTTON_DISABLED_TOOLTIP = _("Please sign this transaction in order to save it") @@ -72,36 +88,25 @@ def show_transaction(tx: Transaction, *, parent: 'ElectrumWindow', invoice=None, d.show() -class TxDialog(QDialog, MessageBoxMixin): - def __init__(self, tx: Transaction, *, parent: 'ElectrumWindow', invoice, desc, prompt_if_unsaved): +class BaseTxDialog(QDialog, MessageBoxMixin): + + def __init__(self, *, parent: 'ElectrumWindow', invoice, desc, prompt_if_unsaved, finalized): '''Transactions in the wallet will show their description. Pass desc to give a description for txs not yet in the wallet. ''' # We want to be a top-level window QDialog.__init__(self, parent=None) - # Take a copy; it might get updated in the main window by - # e.g. the FX plugin. If this happens during or after a long - # sign operation the signatures are lost. - self.tx = tx = copy.deepcopy(tx) - try: - self.tx.deserialize() - except BaseException as e: - raise SerializationError(e) + self.finalized = finalized self.main_window = parent + self.config = parent.config self.wallet = parent.wallet self.prompt_if_unsaved = prompt_if_unsaved self.saved = False self.desc = desc self.invoice = invoice - - # if the wallet can populate the inputs with more info, do it now. - # as a result, e.g. we might learn an imported address tx is segwit, - # or that a beyond-gap-limit address is is_mine - tx.add_info_from_wallet(self.wallet) - self.setMinimumWidth(950) - self.setWindowTitle(_("Transaction")) + self.set_title() vbox = QVBoxLayout() self.setLayout(vbox) @@ -115,6 +120,7 @@ class TxDialog(QDialog, MessageBoxMixin): vbox.addWidget(self.tx_hash_e) self.add_tx_stats(vbox) + vbox.addSpacing(10) self.inputs_header = QLabel() @@ -125,7 +131,6 @@ class TxDialog(QDialog, MessageBoxMixin): vbox.addWidget(self.outputs_header) self.outputs_textedit = QTextEditWithDefaultSize() vbox.addWidget(self.outputs_textedit) - self.sign_button = b = QPushButton(_("Sign")) b.clicked.connect(self.sign) @@ -133,7 +138,7 @@ class TxDialog(QDialog, MessageBoxMixin): b.clicked.connect(self.do_broadcast) self.save_button = b = QPushButton(_("Save")) - save_button_disabled = not tx.is_complete() + save_button_disabled = False #not tx.is_complete() b.setDisabled(save_button_disabled) if save_button_disabled: b.setToolTip(SAVE_BUTTON_DISABLED_TOOLTIP) @@ -148,15 +153,18 @@ class TxDialog(QDialog, MessageBoxMixin): self.export_actions_menu = export_actions_menu = QMenu() self.add_export_actions_to_menu(export_actions_menu) export_actions_menu.addSeparator() - if isinstance(tx, PartialTransaction): - export_for_coinjoin_submenu = export_actions_menu.addMenu(_("For CoinJoin; strip privates")) - self.add_export_actions_to_menu(export_for_coinjoin_submenu, gettx=self._gettx_for_coinjoin) + #if isinstance(tx, PartialTransaction): + export_for_coinjoin_submenu = export_actions_menu.addMenu(_("For CoinJoin; strip privates")) + self.add_export_actions_to_menu(export_for_coinjoin_submenu, gettx=self._gettx_for_coinjoin) self.export_actions_button = QToolButton() self.export_actions_button.setText(_("Export")) 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) @@ -171,20 +179,41 @@ class TxDialog(QDialog, MessageBoxMixin): # Action buttons self.buttons = [] - if isinstance(tx, PartialTransaction): - self.buttons.append(self.partial_tx_actions_button) + #if isinstance(tx, PartialTransaction): + self.buttons.append(self.partial_tx_actions_button) self.buttons += [self.sign_button, self.broadcast_button, self.cancel_button] # Transaction sharing buttons - self.sharing_buttons = [self.export_actions_button, self.save_button] - + self.sharing_buttons = [self.finalize_button, self.export_actions_button, self.save_button] run_hook('transaction_dialog', self) - - hbox = QHBoxLayout() + 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) hbox.addLayout(Buttons(*self.buttons)) vbox.addLayout(hbox) - self.update() + self.set_buttons_visibility() + + 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): + # Take a copy; it might get updated in the main window by + # e.g. the FX plugin. If this happens during or after a long + # sign operation the signatures are lost. + self.tx = tx = copy.deepcopy(tx) + try: + self.tx.deserialize() + except BaseException as e: + raise SerializationError(e) + # if the wallet can populate the inputs with more info, do it now. + # as a result, e.g. we might learn an imported address tx is segwit, + # or that a beyond-gap-limit address is is_mine + tx.add_info_from_wallet(self.wallet) def do_broadcast(self): self.main_window.push_top_level_window(self) @@ -269,7 +298,7 @@ class TxDialog(QDialog, MessageBoxMixin): self.sign_button.setDisabled(True) self.main_window.push_top_level_window(self) - self.main_window.sign_tx(self.tx, sign_done) + self.main_window.sign_tx(self.tx, sign_done, self.external_keypairs) def save(self): self.main_window.push_top_level_window(self) @@ -341,6 +370,10 @@ class TxDialog(QDialog, MessageBoxMixin): self.update() def update(self): + if not self.finalized: + self.update_fee_fields() + if self.tx is None: + return self.update_io() desc = self.desc base_unit = self.main_window.base_unit() @@ -373,7 +406,8 @@ class TxDialog(QDialog, MessageBoxMixin): else: self.date_label.hide() self.locktime_label.setText(f"LockTime: {self.tx.locktime}") - self.rbf_label.setText(f"RBF: {not self.tx.is_final()}") + self.rbf_label.setText(f"Replace by Fee: {not self.tx.is_final()}") + if tx_mined_status.header_hash: self.block_hash_label.setText(_("Included in block: {}") .format(tx_mined_status.header_hash)) @@ -443,7 +477,7 @@ class TxDialog(QDialog, MessageBoxMixin): addr = self.wallet.get_txin_address(txin) if addr is None: addr = '' - cursor.insertText(addr, text_format(addr)) + #cursor.insertText(addr, text_format(addr)) if isinstance(txin, PartialTxInput) and txin.value_sats() is not None: cursor.insertText(format_amount(txin.value_sats()), ext) cursor.insertBlock() @@ -509,6 +543,11 @@ class TxDialog(QDialog, MessageBoxMixin): vbox_right.addWidget(self.size_label) self.rbf_label = TxDetailLabel() vbox_right.addWidget(self.rbf_label) + self.rbf_cb = QCheckBox(_('Replace by fee')) + vbox_right.addWidget(self.rbf_cb) + self.rbf_label.setVisible(self.finalized) + self.rbf_cb.setVisible(not self.finalized) + self.locktime_label = TxDetailLabel() vbox_right.addWidget(self.locktime_label) self.block_hash_label = TxDetailLabel(word_wrap=True) @@ -520,6 +559,19 @@ class TxDialog(QDialog, MessageBoxMixin): vbox.addLayout(hbox_stats) + def set_title(self): + self.setWindowTitle(_("Create transaction") if not self.finalized else _("Transaction")) + + def on_finalize(self): + self.finalized = True + for widget in [self.fee_slider, self.feecontrol_fields, self.rbf_cb]: + widget.setEnabled(False) + widget.setVisible(False) + for widget in [self.rbf_label]: + widget.setVisible(True) + self.set_title() + self.set_buttons_visibility() + class QTextEditWithDefaultSize(QTextEdit): def sizeHint(self): @@ -532,3 +584,190 @@ class TxDetailLabel(QLabel): self.setTextInteractionFlags(Qt.TextSelectableByMouse) if word_wrap is not None: self.setWordWrap(word_wrap) + + +class TxDialog(BaseTxDialog): + def __init__(self, tx: Transaction, *, parent: 'ElectrumWindow', invoice, desc, prompt_if_unsaved): + BaseTxDialog.__init__(self, parent=parent, invoice=invoice, desc=desc, prompt_if_unsaved=prompt_if_unsaved, finalized=True) + self.set_tx(tx) + self.update() + + + +class PreviewTxDialog(BaseTxDialog, TxEditor): + + def __init__(self, inputs, outputs, external_keypairs, *, window: 'ElectrumWindow', invoice): + TxEditor.__init__(self, window, inputs, outputs, external_keypairs) + BaseTxDialog.__init__(self, parent=window, invoice=invoice, desc='', prompt_if_unsaved=False, finalized=False) + self.update_tx() + 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.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_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 = QPushButton(read_QIcon('info.png'), '') + self.feerounding_icon.setFixedWidth(round(2.2 * char_width_in_lineedit())) + self.feerounding_icon.setFlat(True) + self.feerounding_icon.clicked.connect(feerounding_onclick) + self.feerounding_icon.setVisible(False) + + self.fee_adv_controls = QWidget() + hbox = QHBoxLayout(self.fee_adv_controls) + hbox.setContentsMargins(0, 0, 0, 0) + hbox.addWidget(self.feerate_e) + hbox.addWidget(self.size_e) + hbox.addWidget(self.fee_e) + hbox.addWidget(self.feerounding_icon, Qt.AlignLeft) + hbox.addStretch(1) + + self.feecontrol_fields = QWidget() + vbox_feecontrol = QVBoxLayout(self.feecontrol_fields) + vbox_feecontrol.setContentsMargins(0, 0, 0, 0) + vbox_feecontrol.addWidget(self.fee_adv_controls) + vbox_feecontrol.addWidget(self.fee_slider) + + 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(): + fee_estimator = self.fee_e.get_amount() + elif self.is_send_feerate_frozen(): + 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() + if self.no_dynfee_estimates: + size = self.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 + + tx = self.tx + size = tx.estimated_size() + fee = tx.get_fee() + + self.size_e.setAmount(size) + + # 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) + else: + # 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 else 0 + self.set_feerounding_text(int(feerounding)) + self.feerounding_icon.setToolTip(self.feerounding_text) + self.feerounding_icon.setVisible(abs(feerounding) >= 1) diff --git a/electrum/gui/qt/utxo_list.py b/electrum/gui/qt/utxo_list.py @@ -28,12 +28,12 @@ from enum import IntEnum from PyQt5.QtCore import Qt from PyQt5.QtGui import QStandardItemModel, QStandardItem, QFont -from PyQt5.QtWidgets import QAbstractItemView, QMenu +from PyQt5.QtWidgets import QAbstractItemView, QMenu, QLabel, QHBoxLayout from electrum.i18n import _ from electrum.transaction import PartialTxInput -from .util import MyTreeView, ColorScheme, MONOSPACE_FONT +from .util import MyTreeView, ColorScheme, MONOSPACE_FONT, EnterButton class UTXOList(MyTreeView): @@ -58,6 +58,9 @@ class UTXOList(MyTreeView): super().__init__(parent, self.create_menu, stretch_column=self.Columns.LABEL, editable_columns=[]) + self.cc_label = QLabel('') + self.clear_cc_button = EnterButton(_('Reset'), lambda: self.set_spend_list([])) + self.spend_list = [] self.setModel(QStandardItemModel(self)) self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.setSortingEnabled(True) @@ -72,6 +75,11 @@ class UTXOList(MyTreeView): for idx, utxo in enumerate(utxos): self.insert_utxo(idx, utxo) self.filter() + self.clear_cc_button.setEnabled(bool(self.spend_list)) + coins = [self.utxo_dict[x] for x in self.spend_list] or utxos + amount = sum(x.value_sats() for x in coins) + amount_str = self.parent.format_amount_and_units(amount) + self.cc_label.setText('%d outputs, %s'%(len(coins), amount_str)) def insert_utxo(self, idx, utxo: PartialTxInput): address = utxo.address @@ -88,10 +96,13 @@ class UTXOList(MyTreeView): utxo_item[self.Columns.AMOUNT].setFont(QFont(MONOSPACE_FONT)) utxo_item[self.Columns.OUTPOINT].setFont(QFont(MONOSPACE_FONT)) utxo_item[self.Columns.ADDRESS].setData(name, Qt.UserRole) - if self.wallet.is_frozen_address(address): + if name in self.spend_list: + for i in range(5): + utxo_item[i].setBackground(ColorScheme.GREEN.as_color(True)) + elif self.wallet.is_frozen_address(address): utxo_item[self.Columns.ADDRESS].setBackground(ColorScheme.BLUE.as_color(True)) utxo_item[self.Columns.ADDRESS].setToolTip(_('Address is frozen')) - if self.wallet.is_frozen_coin(utxo): + elif self.wallet.is_frozen_coin(utxo): utxo_item[self.Columns.OUTPOINT].setBackground(ColorScheme.BLUE.as_color(True)) utxo_item[self.Columns.OUTPOINT].setToolTip(f"{name}\n{_('Coin is frozen')}") else: @@ -106,6 +117,20 @@ class UTXOList(MyTreeView): return None return [x.data(Qt.UserRole) for x in items] + def set_spend_list(self, coins): + self.spend_list = [utxo.prevout.to_str() for utxo in coins] + self.update() + + def get_spend_list(self): + return [self.utxo_dict[x] for x in self.spend_list] + + def get_toolbar(self): + h = QHBoxLayout() + h.addWidget(self.cc_label) + h.addStretch() + h.addWidget(self.clear_cc_button) + return h + def create_menu(self, position): selected = self.get_selected_outpoints() if not selected: @@ -113,7 +138,7 @@ class UTXOList(MyTreeView): menu = QMenu() menu.setSeparatorsCollapsible(True) # consecutive separators are merged together coins = [self.utxo_dict[name] for name in selected] - menu.addAction(_("Spend"), lambda: self.parent.spend_coins(coins)) + menu.addAction(_("Spend"), lambda: self.set_spend_list(coins)) assert len(coins) >= 1, len(coins) if len(coins) == 1: utxo = coins[0]