Browse Source

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
ln-negative-red
ThomasV 5 years ago
parent
commit
dd6cb2caf7
  1. 1
      RELEASE-NOTES
  2. 261
      electrum/gui/qt/confirm_tx_dialog.py
  3. 6
      electrum/gui/qt/invoice_list.py
  4. 530
      electrum/gui/qt/main_window.py
  5. 17
      electrum/gui/qt/settings_dialog.py
  6. 307
      electrum/gui/qt/transaction_dialog.py
  7. 35
      electrum/gui/qt/utxo_list.py

1
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)

261
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()

6
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))

530
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)

17
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)

307
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)

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

Loading…
Cancel
Save