diff --git a/electrum/gui/qml/components/InvoiceDialog.qml b/electrum/gui/qml/components/InvoiceDialog.qml index 4cd687e6efe..4b79497ce5b 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 b1a3df358bc..393bc69480e 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 74c5e2a904a..f75f4220715 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 @@ def __init__(self, parent=None): 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 @@ def open_thread(): @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 a09d5eb795b..68b6759c421 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.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 Status(IntEnum): 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 @@ def __init__(self, parent=None): 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 @@ def get_max_spendable_onchain(self): 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 6990e05ed42..6b2da34f390 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -3,7 +3,7 @@ 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.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 @@ def isAddressMine(self, addr): 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