Browse Source

Multiple max spends functionality added (#7492)

this implements https://github.com/spesmilo/electrum/issues/7054
master
Siddhant Chawla 4 years ago committed by GitHub
parent
commit
65c3a892cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      electrum/commands.py
  2. 4
      electrum/gui/kivy/uix/screens.py
  3. 11
      electrum/gui/qt/main_window.py
  4. 8
      electrum/gui/qt/paytoedit.py
  5. 28
      electrum/util.py
  6. 40
      electrum/wallet.py

6
electrum/commands.py

@ -41,7 +41,7 @@ from typing import Optional, TYPE_CHECKING, Dict, List
from .import util, ecc from .import util, ecc
from .util import (bfh, bh2u, format_satoshis, json_decode, json_normalize, 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 . import bitcoin
from .bitcoin import is_address, hash_160, COIN from .bitcoin import is_address, hash_160, COIN
from .bip32 import BIP32Node from .bip32 import BIP32Node
@ -77,7 +77,7 @@ class NotSynchronizedException(Exception):
def satoshis_or_max(amount): 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): def satoshis(amount):
# satoshi conversion must not be performed by the parser # satoshi conversion must not be performed by the parser
@ -1354,7 +1354,7 @@ arg_types = {
'inputs': json_loads, 'inputs': json_loads,
'outputs': json_loads, 'outputs': json_loads,
'fee': lambda x: str(Decimal(x)) if x is not None else None, '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, 'locktime': int,
'addtransaction': eval_bool, 'addtransaction': eval_bool,
'fee_method': str, 'fee_method': str,

4
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 import bitcoin, constants
from electrum.transaction import tx_from_any, PartialTxOutput from electrum.transaction import tx_from_any, PartialTxOutput
from electrum.util import (parse_URI, InvalidBitcoinURI, TxMinedInfo, maybe_extract_bolt11_invoice, 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.lnaddr import lndecode, LnInvoiceException
from electrum.logging import Logger from electrum.logging import Logger
@ -371,7 +371,7 @@ class SendScreen(CScreen, Logger):
def _do_pay_onchain(self, invoice: OnchainInvoice) -> None: def _do_pay_onchain(self, invoice: OnchainInvoice) -> None:
outputs = invoice.outputs 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) coins = self.app.wallet.get_spendable_coins(None)
make_tx = lambda rbf: self.app.wallet.make_unsigned_transaction(coins=coins, outputs=outputs, rbf=rbf) 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)) on_pay = lambda tx: self.app.protected(_('Send payment?'), self.send_tx, (tx, invoice))

11
electrum/gui/qt/main_window.py

@ -64,7 +64,7 @@ from electrum.util import (format_time,
InvalidBitcoinURI, maybe_extract_bolt11_invoice, NotEnoughFunds, InvalidBitcoinURI, maybe_extract_bolt11_invoice, NotEnoughFunds,
NoDynamicFeeEstimates, MultipleSpendMaxTxOutputs, NoDynamicFeeEstimates, MultipleSpendMaxTxOutputs,
AddTransactionException, BITCOIN_BIP21_URI_SCHEME, 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_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.invoices import PR_PAID, PR_FAILED, pr_expiration_values, LNInvoice, OnchainInvoice
from electrum.transaction import (Transaction, PartialTxInput, from electrum.transaction import (Transaction, PartialTxInput,
@ -1709,11 +1709,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
fee=fee_est, fee=fee_est,
is_sweep=is_sweep) is_sweep=is_sweep)
output_values = [x.value for x in outputs] output_values = [x.value for x in outputs]
if output_values.count('!') > 1: if any(parse_max_spend(outval) for outval in output_values):
self.show_error(_("More than one output set to spend max")) output_value = '!'
return else:
output_value = sum(output_values)
output_value = '!' if '!' in output_values else sum(output_values)
conf_dlg = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=output_value, is_sweep=is_sweep) conf_dlg = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=output_value, is_sweep=is_sweep)
if conf_dlg.not_enough_funds: if conf_dlg.not_enough_funds:
# Check if we had enough funds excluding fees, # Check if we had enough funds excluding fees,

8
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 PyQt5.QtGui import QFontMetrics, QFont
from electrum import bitcoin 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.transaction import PartialTxOutput
from electrum.bitcoin import opcodes, construct_script from electrum.bitcoin import opcodes, construct_script
from electrum.logging import Logger from electrum.logging import Logger
@ -131,8 +131,8 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger):
x = x.strip() x = x.strip()
if not x: if not x:
raise Exception("Amount is empty") raise Exception("Amount is empty")
if x == '!': if parse_max_spend(x):
return '!' return x
p = pow(10, self.amount_edit.decimal_point()) p = pow(10, self.amount_edit.decimal_point())
try: try:
return int(p * Decimal(x)) 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)) idx=i, line_content=line.strip(), exc=e, is_multiline=True))
continue continue
outputs.append(output) outputs.append(output)
if output.value == '!': if parse_max_spend(output.value):
is_max = True is_max = True
else: else:
total += output.value total += output.value

28
electrum/util.py

@ -108,6 +108,30 @@ def base_unit_name_to_decimal_point(unit_name: str) -> int:
except KeyError: except KeyError:
raise UnknownBaseUnit(unit_name) from None 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): class NotEnoughFunds(Exception):
def __str__(self): def __str__(self):
@ -663,8 +687,8 @@ def format_satoshis(
) -> str: ) -> str:
if x is None: if x is None:
return 'unknown' return 'unknown'
if x == '!': if parse_max_spend(x):
return 'max' return f'max ({x}) '
assert isinstance(x, (int, float, Decimal)), f"{x!r} should be a number" assert isinstance(x, (int, float, Decimal)), f"{x!r} should be a number"
# lose redundant precision # lose redundant precision
x = Decimal(x).quantize(Decimal(10) ** (-precision)) x = Decimal(x).quantize(Decimal(10) ** (-precision))

40
electrum/wallet.py

@ -56,7 +56,7 @@ from .util import (NotEnoughFunds, UserCancelled, profiler,
format_satoshis, format_fee_satoshis, NoDynamicFeeEstimates, format_satoshis, format_fee_satoshis, NoDynamicFeeEstimates,
WalletFileException, BitcoinException, MultipleSpendMaxTxOutputs, WalletFileException, BitcoinException, MultipleSpendMaxTxOutputs,
InvalidPassword, format_time, timestamp_to_datetime, Satoshis, 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 .simple_config import SimpleConfig, FEE_RATIO_HIGH_WARNING, FEERATE_WARNING_HIGH_FEE
from .bitcoin import COIN, TYPE_ADDRESS from .bitcoin import COIN, TYPE_ADDRESS
from .bitcoin import is_address, address_to_script, is_minikey, relayfee, dust_threshold 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() height=self.get_local_height()
if pr: if pr:
return OnchainInvoice.from_bip70_payreq(pr, height) return OnchainInvoice.from_bip70_payreq(pr, height)
if '!' in (x.value for x in outputs): amount = 0
amount = '!' for x in outputs:
else: if parse_max_spend(x.value):
amount = sum(x.value for x in outputs) amount = '!'
break
else:
amount += x.value
timestamp = None timestamp = None
exp = None exp = None
if URI: if URI:
@ -863,7 +866,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
assert isinstance(invoice, OnchainInvoice) assert isinstance(invoice, OnchainInvoice)
invoice_amounts = defaultdict(int) # type: Dict[bytes, int] # scriptpubkey -> value_sats invoice_amounts = defaultdict(int) # type: Dict[bytes, int] # scriptpubkey -> value_sats
for txo in invoice.outputs: # type: PartialTxOutput 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 = [] relevant_txs = []
with self.transaction_lock: with self.transaction_lock:
for invoice_scriptpubkey, invoice_amt in invoice_amounts.items(): for invoice_scriptpubkey, invoice_amt in invoice_amounts.items():
@ -1333,12 +1336,13 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
outputs = copy.deepcopy(outputs) outputs = copy.deepcopy(outputs)
# check outputs # check outputs
i_max = None i_max = []
i_max_sum = 0
for i, o in enumerate(outputs): for i, o in enumerate(outputs):
if o.value == '!': weight = parse_max_spend(o.value)
if i_max is not None: if weight:
raise MultipleSpendMaxTxOutputs() i_max_sum += weight
i_max = i i_max.append((weight,i))
if fee is None and self.config.fee_per_kb() is None: if fee is None and self.config.fee_per_kb() is None:
raise NoDynamicFeeEstimates() raise NoDynamicFeeEstimates()
@ -1356,7 +1360,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
else: else:
raise Exception(f'Invalid argument fee: {fee}') 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 # Let the coin chooser select the coins to spend
coin_chooser = coinchooser.get_coin_chooser(self.config) coin_chooser = coinchooser.get_coin_chooser(self.config)
# If there is an unconfirmed RBF tx, merge with it # 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 # note: Actually it might be the case that not all UTXOs from the wallet are
# being spent if the user manually selected UTXOs. # being spent if the user manually selected UTXOs.
sendable = sum(map(lambda c: c.value_sats(), coins)) 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)) tx = PartialTransaction.from_io(list(coins), list(outputs))
fee = fee_estimator(tx.estimated_size()) fee = fee_estimator(tx.estimated_size())
amount = sendable - tx.output_value() - fee amount = sendable - tx.output_value() - fee
if amount < 0: if amount < 0:
raise NotEnoughFunds() 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)) tx = PartialTransaction.from_io(list(coins), list(outputs))
# Timelock tx to current height. # Timelock tx to current height.

Loading…
Cancel
Save