From 37f0069f2a3959cf743d693b4b89f42944bcd9d1 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 12 Dec 2024 13:25:44 +0100 Subject: [PATCH 1/2] qml: calculate max amount when max toggle is enabled --- electrum/gui/qml/components/InvoiceDialog.qml | 63 ++++++++++++++++--- .../gui/qml/components/OpenChannelDialog.qml | 56 ++++++++++++++--- electrum/gui/qml/qechannelopener.py | 37 ++++++++++- electrum/gui/qml/qeinvoice.py | 40 +++++++++++- electrum/gui/qml/qewallet.py | 34 +++++++++- 5 files changed, 209 insertions(+), 21 deletions(-) diff --git a/electrum/gui/qml/components/InvoiceDialog.qml b/electrum/gui/qml/components/InvoiceDialog.qml index 4cd687e6e..4b79497ce 100644 --- a/electrum/gui/qml/components/InvoiceDialog.qml +++ b/electrum/gui/qml/components/InvoiceDialog.qml @@ -142,6 +142,7 @@ ElDialog { Layout.alignment: Qt.AlignHCenter leftPadding: constants.paddingXLarge + rightPadding: constants.paddingXLarge property bool editmode: false @@ -216,10 +217,23 @@ ElDialog { BtcField { id: amountBtc + Layout.preferredWidth: amountFontMetrics.advanceWidth('0') * 14 + leftPadding + rightPadding fiatfield: amountFiat - enabled: !amountMax.checked + readOnly: amountMax.checked + color: readOnly + ? Material.accentColor + : Material.foreground onTextAsSatsChanged: { - invoice.amountOverride = textAsSats + if (!amountMax.checked) + invoice.amountOverride.satsInt = textAsSats.satsInt + } + Connections { + target: invoice.amountOverride + function onSatsIntChanged() { + console.log('amuontOverride satsIntChanged, sats=' + invoice.amountOverride.satsInt) + if (amountMax.checked) // amountOverride updated by max amount estimate + amountBtc.text = Config.formatSats(invoice.amountOverride.satsInt) + } } } @@ -239,24 +253,48 @@ ElDialog { visible: _canMax checked: false onCheckedChanged: { - if (activeFocus) + if (activeFocus) { invoice.amountOverride.isMax = checked + if (checked) { + maxAmountMessage.text = '' + invoice.updateMaxAmount() + } + } } } FiatField { id: amountFiat + Layout.preferredWidth: amountFontMetrics.advanceWidth('0') * 14 + leftPadding + rightPadding btcfield: amountBtc - visible: Daemon.fx.enabled && !amountMax.checked - enabled: !amountMax.checked + visible: Daemon.fx.enabled + readOnly: amountMax.checked + color: readOnly + ? Material.accentColor + : Material.foreground } Label { Layout.columnSpan: 2 - visible: Daemon.fx.enabled && !amountMax.checked + visible: Daemon.fx.enabled text: Daemon.fx.fiatCurrency color: Material.accentColor } + + InfoTextArea { + Layout.topMargin: constants.paddingMedium + Layout.fillWidth: true + Layout.columnSpan: 3 + id: maxAmountMessage + visible: amountMax.checked && text + compact: true + Connections { + target: invoice + function onMaxAmountMessage(message) { + maxAmountMessage.text = message + } + } + } } } @@ -425,7 +463,9 @@ ElDialog { enabled: !invoice.isSaved && invoice.canSave onClicked: { if (invoice.amount.isEmpty) { - invoice.amountOverride = amountMax.checked ? MAX : Config.unitsToSats(amountBtc.text) + invoice.amountOverride = Config.unitsToSats(amountBtc.text) + if (amountMax.checked) + invoice.amountOverride.isMax = true } invoice.saveInvoice() app.stack.push(Qt.resolvedUrl('Invoices.qml')) @@ -440,7 +480,9 @@ ElDialog { enabled: invoice.invoiceType != Invoice.Invalid && invoice.canPay onClicked: { if (invoice.amount.isEmpty) { - invoice.amountOverride = amountMax.checked ? MAX : Config.unitsToSats(amountBtc.text) + invoice.amountOverride = Config.unitsToSats(amountBtc.text) + if (amountMax.checked) + invoice.amountOverride.isMax = true } if (!invoice.isSaved) { // save invoice if newly parsed @@ -468,4 +510,9 @@ ElDialog { } } } + + FontMetrics { + id: amountFontMetrics + font: amountBtc.font + } } diff --git a/electrum/gui/qml/components/OpenChannelDialog.qml b/electrum/gui/qml/components/OpenChannelDialog.qml index b1a3df358..393bc6948 100644 --- a/electrum/gui/qml/components/OpenChannelDialog.qml +++ b/electrum/gui/qml/components/OpenChannelDialog.qml @@ -167,11 +167,25 @@ ElDialog { } BtcField { - id: amount + id: amountBtc fiatfield: amountFiat - Layout.preferredWidth: parent.width /3 - onTextChanged: channelopener.amount = Config.unitsToSats(amount.text) - enabled: !is_max.checked + Layout.preferredWidth: amountFontMetrics.advanceWidth('0') * 14 + leftPadding + rightPadding + onTextAsSatsChanged: { + if (!is_max.checked) + channelopener.amount.satsInt = amountBtc.textAsSats.satsInt + } + readOnly: is_max.checked + color: readOnly + ? Material.accentColor + : Material.foreground + + Connections { + target: channelopener.amount + function onSatsIntChanged() { + if (is_max.checked) // amount updated by max amount estimate + amountBtc.text = Config.formatSats(channelopener.amount.satsInt) + } + } } RowLayout { @@ -184,7 +198,13 @@ ElDialog { id: is_max text: qsTr('Max') onCheckedChanged: { - channelopener.amount = checked ? MAX : Config.unitsToSats(amount.text) + if (activeFocus) { + channelopener.amount.isMax = checked + if (checked) { + maxAmountMessage.text = '' + channelopener.updateMaxAmount() + } + } } } } @@ -193,10 +213,13 @@ ElDialog { FiatField { id: amountFiat - btcfield: amount + Layout.preferredWidth: amountFontMetrics.advanceWidth('0') * 14 + leftPadding + rightPadding + btcfield: amountBtc visible: Daemon.fx.enabled - Layout.preferredWidth: parent.width /3 - enabled: !is_max.checked + readOnly: is_max.checked + color: readOnly + ? Material.accentColor + : Material.foreground } Label { @@ -207,6 +230,16 @@ ElDialog { } Item { visible: Daemon.fx.enabled ; height: 1; width: 1 } + + InfoTextArea { + Layout.topMargin: constants.paddingMedium + Layout.fillWidth: true + Layout.columnSpan: 3 + id: maxAmountMessage + visible: is_max.checked && text + compact: true + } + } } @@ -288,6 +321,13 @@ ElDialog { // TODO: handle incomplete TX root.close() } + onMaxAmountMessage: (message) => { + maxAmountMessage.text = message + } } + FontMetrics { + id: amountFontMetrics + font: amountBtc.font + } } diff --git a/electrum/gui/qml/qechannelopener.py b/electrum/gui/qml/qechannelopener.py index 74c5e2a90..f75f42207 100644 --- a/electrum/gui/qml/qechannelopener.py +++ b/electrum/gui/qml/qechannelopener.py @@ -1,13 +1,14 @@ import threading from concurrent.futures import CancelledError from asyncio.exceptions import TimeoutError -from typing import TYPE_CHECKING, Optional +from typing import Optional +import electrum_ecc as ecc from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from electrum.i18n import _ from electrum.gui import messages -from electrum.util import bfh +from electrum.util import bfh, NotEnoughFunds, NoDynamicFeeEstimates from electrum.lntransport import extract_nodeid, ConnStringFormatError from electrum.bitcoin import DummyAddress from electrum.lnworker import hardcoded_trampoline_nodes @@ -29,6 +30,7 @@ class QEChannelOpener(QObject, AuthMixin): channelOpenError = pyqtSignal([str], arguments=['message']) channelOpenSuccess = pyqtSignal([str, bool, int, bool], arguments=['cid', 'has_onchain_backup', 'min_depth', 'tx_complete']) + maxAmountMessage = pyqtSignal([str], arguments=['message']) dataChanged = pyqtSignal() # generic notify signal @@ -46,6 +48,8 @@ class QEChannelOpener(QObject, AuthMixin): self._node_pubkey = None self._connect_str_resolved = None + self._updating_max = False + walletChanged = pyqtSignal() @pyqtProperty(QEWallet, notify=walletChanged) def wallet(self): @@ -228,3 +232,32 @@ class QEChannelOpener(QObject, AuthMixin): @pyqtSlot(str, result=str) def channelBackup(self, cid): return self._wallet.wallet.lnworker.export_channel_backup(bfh(cid)) + + @pyqtSlot() + def updateMaxAmount(self): + if self._updating_max: + return + + self._updating_max = True + + def calc_max(): + try: + coins = self._wallet.wallet.get_spendable_coins(None, nonlocal_only=True) + dummy_nodeid = ecc.GENERATOR.get_public_key_bytes(compressed=True) + make_tx = lambda amt: self._wallet.wallet.lnworker.mktx_for_open_channel( + coins=coins, + funding_sat='!', + node_id=dummy_nodeid, + fee_est=None) + + amount, message = self._wallet.determine_max(mktx=make_tx) + if amount is None: + self._amount.isMax = False + else: + self._amount.satsInt = amount + if message: + self.maxAmountMessage.emit(message) + finally: + self._updating_max = False + + threading.Thread(target=calc_max, daemon=True).start() diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index a09d5eb79..68b6759c4 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -1,3 +1,4 @@ +import threading from enum import IntEnum from typing import Optional, Dict, Any from urllib.parse import urlparse @@ -9,10 +10,12 @@ from electrum.logging import get_logger from electrum.invoices import (Invoice, PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING, PR_UNCONFIRMED, PR_BROADCASTING, PR_BROADCAST, LN_EXPIRY_NEVER) from electrum.transaction import PartialTxOutput, TxOutput +from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates from electrum.lnutil import format_short_channel_id -from electrum.bitcoin import COIN +from electrum.bitcoin import COIN, address_to_script from electrum.paymentrequest import PaymentRequest from electrum.payment_identifier import (PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType) + from .qetypes import QEAmount from .qewallet import QEWallet from .util import status_update_timer_interval, QtEventListener, event_listener @@ -42,6 +45,7 @@ class QEInvoice(QObject, QtEventListener): invoiceChanged = pyqtSignal() invoiceSaved = pyqtSignal([str], arguments=['key']) amountOverrideChanged = pyqtSignal() + maxAmountMessage = pyqtSignal([str], arguments=['message']) def __init__(self, parent=None): super().__init__(parent) @@ -64,6 +68,8 @@ class QEInvoice(QObject, QtEventListener): self._amountOverride.valueChanged.connect(self._on_amountoverride_value_changed) + self._updating_max = False + self.register_callbacks() self.destroyed.connect(lambda: self.on_destroy()) @@ -392,6 +398,38 @@ class QEInvoice(QObject, QtEventListener): def get_max_spendable_lightning(self): return self._wallet.wallet.lnworker.num_sats_can_send() if self._wallet.wallet.lnworker else 0 + @pyqtSlot() + def updateMaxAmount(self): + if self._updating_max: + return + + assert self.invoiceType == QEInvoice.Type.OnchainInvoice + + # only single address invoice supported + invoice_address = self._effectiveInvoice.get_address() + + self._updating_max = True + + def calc_max(address): + try: + outputs = [PartialTxOutput(scriptpubkey=address_to_script(address), value='!')] + make_tx = lambda fee_est, *, confirmed_only=False: self._wallet.wallet.make_unsigned_transaction( + coins=self._wallet.wallet.get_spendable_coins(None), + outputs=outputs, + fee=fee_est, + is_sweep=False) + amount, message = self._wallet.determine_max(mktx=make_tx) + if amount is None: + self._amountOverride.isMax = False + else: + self._amountOverride.satsInt = amount + if message: + self.maxAmountMessage.emit(message) + finally: + self._updating_max = False + + threading.Thread(target=calc_max, args=(invoice_address,), daemon=True).start() + class QEInvoiceParser(QEInvoice): _logger = get_logger(__name__) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 6990e05ed..6b2da34f3 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -3,7 +3,7 @@ import base64 import queue import threading import time -from typing import TYPE_CHECKING, Callable, Optional, Any +from typing import TYPE_CHECKING, Callable, Optional, Any, Tuple from functools import partial from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer @@ -13,7 +13,8 @@ from electrum.invoices import InvoiceError, PR_PAID, PR_BROADCASTING, PR_BROADCA from electrum.logging import get_logger from electrum.network import TxBroadcastError, BestEffortRequestFailed from electrum.transaction import PartialTransaction, Transaction -from electrum.util import InvalidPassword, event_listener, AddTransactionException, get_asyncio_loop +from electrum.util import InvalidPassword, event_listener, AddTransactionException, get_asyncio_loop, NotEnoughFunds, \ + NoDynamicFeeEstimates from electrum.plugin import run_hook from electrum.wallet import Multisig_Wallet from electrum.crypto import pw_decode_with_version_and_mac @@ -817,3 +818,32 @@ class QEWallet(AuthMixin, QObject, QtEventListener): def signMessage(self, address, message): sig = self.wallet.sign_message(address, message, self.password) return base64.b64encode(sig).decode('ascii') + + def get_text_not_enough_funds_mentioning_frozen(self) -> str: + text = _('Not enough funds') + frozen_str = self.get_frozen_balance_str() + if frozen_str: + text += " ({} {})".format(frozen_str, _('are frozen')) + return text + + def get_frozen_balance_str(self) -> Optional[str]: + frozen_bal = sum(self.wallet.get_frozen_balance()) + if not frozen_bal: + return None + return self.wallet.config.format_amount_and_units(frozen_bal) + + def determine_max(self, *, mktx: Callable[[int], PartialTransaction]) -> Tuple[int, str]: + amount = message = None + try: + try: + tx = mktx(None) + except (NotEnoughFunds, NoDynamicFeeEstimates) as e: + # Check if we had enough funds excluding fees, + # if so, still provide opportunity to set lower fees. + tx = mktx(0) + amount = tx.output_value() + except NotEnoughFunds as e: + self._logger.debug(str(e)) + message = self.get_text_not_enough_funds_mentioning_frozen() + + return amount, message From 605b511b43aedd9cae7e6185fcebf266af2463de Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 31 Jan 2025 18:03:21 +0100 Subject: [PATCH 2/2] qt,qml: move get_text_not_enough_funds_mentioning_frozen and get_frozen_balance_str to backend wallet Note: the qt gui used to include FX in get_frozen_balance_str, but that is not replicated now. --- electrum/gui/qml/components/InvoiceDialog.qml | 4 ++-- .../gui/qml/components/OpenChannelDialog.qml | 2 +- electrum/gui/qml/qewallet.py | 18 +++--------------- electrum/gui/qt/send_tab.py | 17 ++--------------- electrum/wallet.py | 13 +++++++++++++ 5 files changed, 21 insertions(+), 33 deletions(-) diff --git a/electrum/gui/qml/components/InvoiceDialog.qml b/electrum/gui/qml/components/InvoiceDialog.qml index 4b79497ce..df23a92c2 100644 --- a/electrum/gui/qml/components/InvoiceDialog.qml +++ b/electrum/gui/qml/components/InvoiceDialog.qml @@ -230,9 +230,9 @@ ElDialog { Connections { target: invoice.amountOverride function onSatsIntChanged() { - console.log('amuontOverride satsIntChanged, sats=' + invoice.amountOverride.satsInt) + console.log('amountOverride satsIntChanged, sats=' + invoice.amountOverride.satsInt) if (amountMax.checked) // amountOverride updated by max amount estimate - amountBtc.text = Config.formatSats(invoice.amountOverride.satsInt) + amountBtc.text = Config.formatSatsForEditing(invoice.amountOverride.satsInt) } } } diff --git a/electrum/gui/qml/components/OpenChannelDialog.qml b/electrum/gui/qml/components/OpenChannelDialog.qml index 393bc6948..facff3d6e 100644 --- a/electrum/gui/qml/components/OpenChannelDialog.qml +++ b/electrum/gui/qml/components/OpenChannelDialog.qml @@ -183,7 +183,7 @@ ElDialog { target: channelopener.amount function onSatsIntChanged() { if (is_max.checked) // amount updated by max amount estimate - amountBtc.text = Config.formatSats(channelopener.amount.satsInt) + amountBtc.text = Config.formatSatsForEditing(channelopener.amount.satsInt) } } } diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 6b2da34f3..5442b1e9f 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -819,20 +819,8 @@ class QEWallet(AuthMixin, QObject, QtEventListener): sig = self.wallet.sign_message(address, message, self.password) return base64.b64encode(sig).decode('ascii') - def get_text_not_enough_funds_mentioning_frozen(self) -> str: - text = _('Not enough funds') - frozen_str = self.get_frozen_balance_str() - if frozen_str: - text += " ({} {})".format(frozen_str, _('are frozen')) - return text - - def get_frozen_balance_str(self) -> Optional[str]: - frozen_bal = sum(self.wallet.get_frozen_balance()) - if not frozen_bal: - return None - return self.wallet.config.format_amount_and_units(frozen_bal) - - def determine_max(self, *, mktx: Callable[[int], PartialTransaction]) -> Tuple[int, str]: + def determine_max(self, *, mktx: Callable[[Optional[int]], PartialTransaction]) -> Tuple[Optional[int], Optional[str]]: + # TODO: merge with SendTab.spend_max() and move to backend wallet amount = message = None try: try: @@ -844,6 +832,6 @@ class QEWallet(AuthMixin, QObject, QtEventListener): amount = tx.output_value() except NotEnoughFunds as e: self._logger.debug(str(e)) - message = self.get_text_not_enough_funds_mentioning_frozen() + message = self.wallet.get_text_not_enough_funds_mentioning_frozen() return amount, message diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index edfe3d63d..402dd729b 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -267,7 +267,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): tx = make_tx(0) except NotEnoughFunds as e: self.max_button.setChecked(False) - text = self.get_text_not_enough_funds_mentioning_frozen() + text = self.wallet.get_text_not_enough_funds_mentioning_frozen() self.show_error(text) return @@ -283,7 +283,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): if x_fee_amount: twofactor_fee_str = self.format_amount_and_units(x_fee_amount) msg += "\n" + _("2fa fee: {} (for the next batch of transactions)").format(twofactor_fee_str) - frozen_bal = self.get_frozen_balance_str() + frozen_bal = self.wallet.get_frozen_balance_str() if frozen_bal: msg += "\n" + _("Some coins are frozen: {} (can be unfrozen in the Addresses or in the Coins tab)").format(frozen_bal) QToolTip.showText(self.max_button.mapToGlobal(QPoint(0, 0)), msg) @@ -353,19 +353,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger): callback=sign_done, external_keypairs=external_keypairs) - def get_text_not_enough_funds_mentioning_frozen(self) -> str: - text = _("Not enough funds") - frozen_str = self.get_frozen_balance_str() - if frozen_str: - text += " ({} {})".format(frozen_str, _("are frozen")) - return text - - def get_frozen_balance_str(self) -> Optional[str]: - frozen_bal = sum(self.wallet.get_frozen_balance()) - if not frozen_bal: - return None - return self.format_amount_and_units(frozen_bal) - def do_clear(self): self.logger.debug('do_clear') self.lock_fields(lock_recipient=False, lock_amount=False, lock_max=True, lock_description=False) diff --git a/electrum/wallet.py b/electrum/wallet.py index 2af3a0e25..6cdc84c4e 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -3356,6 +3356,19 @@ class Abstract_Wallet(ABC, Logger, EventListener): def get_unlocked_password(self): return self._password_in_memory + def get_text_not_enough_funds_mentioning_frozen(self) -> str: + text = _('Not enough funds') + frozen_str = self.get_frozen_balance_str() + if frozen_str: + text += ' ' + _('({} are frozen)').format(frozen_str) + return text + + def get_frozen_balance_str(self) -> Optional[str]: + frozen_bal = sum(self.get_frozen_balance()) + if not frozen_bal: + return None + return self.config.format_amount_and_units(frozen_bal) + class Simple_Wallet(Abstract_Wallet): # wallet with a single keystore