diff --git a/electrum/commands.py b/electrum/commands.py index 805cedadd..f33a2ad4d 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -41,7 +41,7 @@ from typing import Optional, TYPE_CHECKING, Dict, List from .import util, ecc from .util import (bfh, bh2u, format_satoshis, json_decode, json_normalize, - is_hash256_str, is_hex_str, to_bytes) + is_hash256_str, is_hex_str, to_bytes, parse_max_spend) from . import bitcoin from .bitcoin import is_address, hash_160, COIN from .bip32 import BIP32Node @@ -77,7 +77,7 @@ class NotSynchronizedException(Exception): def satoshis_or_max(amount): - return satoshis(amount) if amount != '!' else '!' + return satoshis(amount) if not parse_max_spend(amount) else amount def satoshis(amount): # satoshi conversion must not be performed by the parser @@ -1354,7 +1354,7 @@ arg_types = { 'inputs': json_loads, 'outputs': json_loads, 'fee': lambda x: str(Decimal(x)) if x is not None else None, - 'amount': lambda x: str(Decimal(x)) if x != '!' else '!', + 'amount': lambda x: str(Decimal(x)) if not parse_max_spend(x) else x, 'locktime': int, 'addtransaction': eval_bool, 'fee_method': str, diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py index 0ed6f5eea..cf6c99e22 100644 --- a/electrum/gui/kivy/uix/screens.py +++ b/electrum/gui/kivy/uix/screens.py @@ -16,7 +16,7 @@ from electrum.invoices import (PR_TYPE_ONCHAIN, PR_TYPE_LN, PR_DEFAULT_EXPIRATIO from electrum import bitcoin, constants from electrum.transaction import tx_from_any, PartialTxOutput from electrum.util import (parse_URI, InvalidBitcoinURI, TxMinedInfo, maybe_extract_bolt11_invoice, - InvoiceError, format_time) + InvoiceError, format_time, parse_max_spend) from electrum.lnaddr import lndecode, LnInvoiceException from electrum.logging import Logger @@ -371,7 +371,7 @@ class SendScreen(CScreen, Logger): def _do_pay_onchain(self, invoice: OnchainInvoice) -> None: outputs = invoice.outputs - amount = sum(map(lambda x: x.value, outputs)) if '!' not in [x.value for x in outputs] else '!' + amount = sum(map(lambda x: x.value, outputs)) if not any(parse_max_spend(x.value) for x in outputs) else '!' coins = self.app.wallet.get_spendable_coins(None) make_tx = lambda rbf: self.app.wallet.make_unsigned_transaction(coins=coins, outputs=outputs, rbf=rbf) on_pay = lambda tx: self.app.protected(_('Send payment?'), self.send_tx, (tx, invoice)) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index cfb66a500..b12e734d8 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -64,7 +64,7 @@ from electrum.util import (format_time, InvalidBitcoinURI, maybe_extract_bolt11_invoice, NotEnoughFunds, NoDynamicFeeEstimates, MultipleSpendMaxTxOutputs, AddTransactionException, BITCOIN_BIP21_URI_SCHEME, - InvoiceError) + InvoiceError, parse_max_spend) from electrum.invoices import PR_TYPE_ONCHAIN, PR_TYPE_LN, PR_DEFAULT_EXPIRATION_WHEN_CREATING, Invoice from electrum.invoices import PR_PAID, PR_FAILED, pr_expiration_values, LNInvoice, OnchainInvoice from electrum.transaction import (Transaction, PartialTxInput, @@ -1709,11 +1709,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): fee=fee_est, is_sweep=is_sweep) output_values = [x.value for x in outputs] - if output_values.count('!') > 1: - self.show_error(_("More than one output set to spend max")) - return - - output_value = '!' if '!' in output_values else sum(output_values) + if any(parse_max_spend(outval) for outval in output_values): + output_value = '!' + else: + output_value = sum(output_values) conf_dlg = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=output_value, is_sweep=is_sweep) if conf_dlg.not_enough_funds: # Check if we had enough funds excluding fees, diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index 183534dd4..2a6262f0e 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -31,7 +31,7 @@ from typing import NamedTuple, Sequence, Optional, List, TYPE_CHECKING from PyQt5.QtGui import QFontMetrics, QFont from electrum import bitcoin -from electrum.util import bfh, maybe_extract_bolt11_invoice, BITCOIN_BIP21_URI_SCHEME +from electrum.util import bfh, maybe_extract_bolt11_invoice, BITCOIN_BIP21_URI_SCHEME, parse_max_spend from electrum.transaction import PartialTxOutput from electrum.bitcoin import opcodes, construct_script from electrum.logging import Logger @@ -131,8 +131,8 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): x = x.strip() if not x: raise Exception("Amount is empty") - if x == '!': - return '!' + if parse_max_spend(x): + return x p = pow(10, self.amount_edit.decimal_point()) try: return int(p * Decimal(x)) @@ -203,7 +203,7 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): idx=i, line_content=line.strip(), exc=e, is_multiline=True)) continue outputs.append(output) - if output.value == '!': + if parse_max_spend(output.value): is_max = True else: total += output.value diff --git a/electrum/util.py b/electrum/util.py index bcb275165..9142dd895 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -108,6 +108,30 @@ def base_unit_name_to_decimal_point(unit_name: str) -> int: except KeyError: raise UnknownBaseUnit(unit_name) from None +def parse_max_spend(amt: Any) -> Optional[int]: + """Checks if given amount is "spend-max"-like. + Returns None or the positive integer weight for "max". Never raises. + When creating invoices and on-chain txs, the user can specify to send "max". + This is done by setting the amount to '!'. Splitting max between multiple + tx outputs is also possible, and custom weights (positive ints) can also be used. + For example, to send 40% of all coins to address1, and 60% to address2: + ``` + address1, 2! + address2, 3! + ``` + """ + if not (isinstance(amt, str) and amt and amt[-1] == '!'): + return None + if amt == '!': + return 1 + x = amt[:-1] + try: + x = int(x) + except ValueError: + return None + if x > 0: + return x + return None class NotEnoughFunds(Exception): def __str__(self): @@ -663,8 +687,8 @@ def format_satoshis( ) -> str: if x is None: return 'unknown' - if x == '!': - return 'max' + if parse_max_spend(x): + return f'max ({x}) ' assert isinstance(x, (int, float, Decimal)), f"{x!r} should be a number" # lose redundant precision x = Decimal(x).quantize(Decimal(10) ** (-precision)) diff --git a/electrum/wallet.py b/electrum/wallet.py index 0fbc984f2..dd4597553 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -56,7 +56,7 @@ from .util import (NotEnoughFunds, UserCancelled, profiler, format_satoshis, format_fee_satoshis, NoDynamicFeeEstimates, WalletFileException, BitcoinException, MultipleSpendMaxTxOutputs, InvalidPassword, format_time, timestamp_to_datetime, Satoshis, - Fiat, bfh, bh2u, TxMinedInfo, quantize_feerate, create_bip21_uri, OrderedDictWithIndex) + Fiat, bfh, bh2u, TxMinedInfo, quantize_feerate, create_bip21_uri, OrderedDictWithIndex, parse_max_spend) from .simple_config import SimpleConfig, FEE_RATIO_HIGH_WARNING, FEERATE_WARNING_HIGH_FEE from .bitcoin import COIN, TYPE_ADDRESS from .bitcoin import is_address, address_to_script, is_minikey, relayfee, dust_threshold @@ -754,10 +754,13 @@ class Abstract_Wallet(AddressSynchronizer, ABC): height=self.get_local_height() if pr: return OnchainInvoice.from_bip70_payreq(pr, height) - if '!' in (x.value for x in outputs): - amount = '!' - else: - amount = sum(x.value for x in outputs) + amount = 0 + for x in outputs: + if parse_max_spend(x.value): + amount = '!' + break + else: + amount += x.value timestamp = None exp = None if URI: @@ -863,7 +866,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC): assert isinstance(invoice, OnchainInvoice) invoice_amounts = defaultdict(int) # type: Dict[bytes, int] # scriptpubkey -> value_sats for txo in invoice.outputs: # type: PartialTxOutput - invoice_amounts[txo.scriptpubkey] += 1 if txo.value == '!' else txo.value + invoice_amounts[txo.scriptpubkey] += 1 if parse_max_spend(txo.value) else txo.value relevant_txs = [] with self.transaction_lock: for invoice_scriptpubkey, invoice_amt in invoice_amounts.items(): @@ -1333,12 +1336,13 @@ class Abstract_Wallet(AddressSynchronizer, ABC): outputs = copy.deepcopy(outputs) # check outputs - i_max = None + i_max = [] + i_max_sum = 0 for i, o in enumerate(outputs): - if o.value == '!': - if i_max is not None: - raise MultipleSpendMaxTxOutputs() - i_max = i + weight = parse_max_spend(o.value) + if weight: + i_max_sum += weight + i_max.append((weight,i)) if fee is None and self.config.fee_per_kb() is None: raise NoDynamicFeeEstimates() @@ -1356,7 +1360,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC): else: raise Exception(f'Invalid argument fee: {fee}') - if i_max is None: + if len(i_max) == 0: # Let the coin chooser select the coins to spend coin_chooser = coinchooser.get_coin_chooser(self.config) # If there is an unconfirmed RBF tx, merge with it @@ -1400,13 +1404,21 @@ class Abstract_Wallet(AddressSynchronizer, ABC): # note: Actually it might be the case that not all UTXOs from the wallet are # being spent if the user manually selected UTXOs. sendable = sum(map(lambda c: c.value_sats(), coins)) - outputs[i_max].value = 0 + for (_,i) in i_max: + outputs[i].value = 0 tx = PartialTransaction.from_io(list(coins), list(outputs)) fee = fee_estimator(tx.estimated_size()) amount = sendable - tx.output_value() - fee if amount < 0: raise NotEnoughFunds() - outputs[i_max].value = amount + distr_amount = 0 + for (x,i) in i_max: + val = int((amount/i_max_sum)*x) + outputs[i].value = val + distr_amount += val + + (x,i) = i_max[-1] + outputs[i].value += (amount - distr_amount) tx = PartialTransaction.from_io(list(coins), list(outputs)) # Timelock tx to current height.