Multiple max spends functionality added (#7492)
this implements https://github.com/spesmilo/electrum/issues/7054
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user