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:
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]