From f980bd97b5029ada1d796de5b12172b12dfe1926 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Sat, 8 Jul 2023 12:16:43 +0200 Subject: [PATCH] payment_identifier: factor out bip21 functions to bip21.py to break cyclic dependencies, parse bolt11 only once, store invoice internally instead of bolt11 string add is_onchain method to indicate if payment identifier can be paid onchain --- electrum/bip21.py | 127 ++++++++++++++++++++++ electrum/gui/kivy/main_window.py | 2 +- electrum/gui/kivy/uix/screens.py | 3 +- electrum/gui/qml/qeapp.py | 2 +- electrum/gui/qml/qeinvoice.py | 8 +- electrum/gui/qt/main_window.py | 3 +- electrum/invoices.py | 2 +- electrum/payment_identifier.py | 175 ++++++------------------------- electrum/tests/test_util.py | 2 +- electrum/transaction.py | 3 +- electrum/wallet.py | 3 +- 11 files changed, 174 insertions(+), 156 deletions(-) create mode 100644 electrum/bip21.py diff --git a/electrum/bip21.py b/electrum/bip21.py new file mode 100644 index 000000000..bcf6bd361 --- /dev/null +++ b/electrum/bip21.py @@ -0,0 +1,127 @@ +import urllib +import re +from decimal import Decimal +from typing import Optional + +from . import bitcoin +from .util import format_satoshis_plain +from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC +from .lnaddr import lndecode, LnDecodeException + +# note: when checking against these, use .lower() to support case-insensitivity +BITCOIN_BIP21_URI_SCHEME = 'bitcoin' +LIGHTNING_URI_SCHEME = 'lightning' + + +class InvalidBitcoinURI(Exception): + pass + + +def parse_bip21_URI(uri: str) -> dict: + """Raises InvalidBitcoinURI on malformed URI.""" + + if not isinstance(uri, str): + raise InvalidBitcoinURI(f"expected string, not {repr(uri)}") + + if ':' not in uri: + if not bitcoin.is_address(uri): + raise InvalidBitcoinURI("Not a bitcoin address") + return {'address': uri} + + u = urllib.parse.urlparse(uri) + if u.scheme.lower() != BITCOIN_BIP21_URI_SCHEME: + raise InvalidBitcoinURI("Not a bitcoin URI") + address = u.path + + # python for android fails to parse query + if address.find('?') > 0: + address, query = u.path.split('?') + pq = urllib.parse.parse_qs(query) + else: + pq = urllib.parse.parse_qs(u.query) + + for k, v in pq.items(): + if len(v) != 1: + raise InvalidBitcoinURI(f'Duplicate Key: {repr(k)}') + + out = {k: v[0] for k, v in pq.items()} + if address: + if not bitcoin.is_address(address): + raise InvalidBitcoinURI(f"Invalid bitcoin address: {address}") + out['address'] = address + if 'amount' in out: + am = out['amount'] + try: + m = re.match(r'([0-9.]+)X([0-9])', am) + if m: + k = int(m.group(2)) - 8 + amount = Decimal(m.group(1)) * pow(Decimal(10), k) + else: + amount = Decimal(am) * COIN + if amount > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN: + raise InvalidBitcoinURI(f"amount is out-of-bounds: {amount!r} BTC") + out['amount'] = int(amount) + except Exception as e: + raise InvalidBitcoinURI(f"failed to parse 'amount' field: {repr(e)}") from e + if 'message' in out: + out['message'] = out['message'] + out['memo'] = out['message'] + if 'time' in out: + try: + out['time'] = int(out['time']) + except Exception as e: + raise InvalidBitcoinURI(f"failed to parse 'time' field: {repr(e)}") from e + if 'exp' in out: + try: + out['exp'] = int(out['exp']) + except Exception as e: + raise InvalidBitcoinURI(f"failed to parse 'exp' field: {repr(e)}") from e + if 'sig' in out: + try: + out['sig'] = bitcoin.base_decode(out['sig'], base=58).hex() + except Exception as e: + raise InvalidBitcoinURI(f"failed to parse 'sig' field: {repr(e)}") from e + if 'lightning' in out: + try: + lnaddr = lndecode(out['lightning']) + except LnDecodeException as e: + raise InvalidBitcoinURI(f"Failed to decode 'lightning' field: {e!r}") from e + amount_sat = out.get('amount') + if amount_sat: + # allow small leeway due to msat precision + if abs(amount_sat - int(lnaddr.get_amount_sat())) > 1: + raise InvalidBitcoinURI("Inconsistent lightning field in bip21: amount") + address = out.get('address') + ln_fallback_addr = lnaddr.get_fallback_address() + if address and ln_fallback_addr: + if ln_fallback_addr != address: + raise InvalidBitcoinURI("Inconsistent lightning field in bip21: address") + + return out + + +def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str], + *, extra_query_params: Optional[dict] = None) -> str: + if not bitcoin.is_address(addr): + return "" + if extra_query_params is None: + extra_query_params = {} + query = [] + if amount_sat: + query.append('amount=%s' % format_satoshis_plain(amount_sat)) + if message: + query.append('message=%s' % urllib.parse.quote(message)) + for k, v in extra_query_params.items(): + if not isinstance(k, str) or k != urllib.parse.quote(k): + raise Exception(f"illegal key for URI: {repr(k)}") + v = urllib.parse.quote(v) + query.append(f"{k}={v}") + p = urllib.parse.ParseResult( + scheme=BITCOIN_BIP21_URI_SCHEME, + netloc='', + path=addr, + params='', + query='&'.join(query), + fragment='' + ) + return str(urllib.parse.urlunparse(p)) diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py index c3bcba6a7..590e7b0fb 100644 --- a/electrum/gui/kivy/main_window.py +++ b/electrum/gui/kivy/main_window.py @@ -87,7 +87,7 @@ Label.register( from electrum.util import NoDynamicFeeEstimates, NotEnoughFunds, UserFacingException -from electrum.payment_identifier import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME +from electrum.bip21 import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME from .uix.dialogs.lightning_open_channel import LightningOpenChannelDialog from .uix.dialogs.lightning_channels import LightningChannelsDialog, SwapDialog diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py index 73690c5af..d5191b97a 100644 --- a/electrum/gui/kivy/uix/screens.py +++ b/electrum/gui/kivy/uix/screens.py @@ -19,7 +19,8 @@ from electrum import bitcoin, constants from electrum import lnutil from electrum.transaction import tx_from_any, PartialTxOutput from electrum.util import TxMinedInfo, InvoiceError, format_time, parse_max_spend -from electrum.payment_identifier import parse_bip21_URI, BITCOIN_BIP21_URI_SCHEME, maybe_extract_lightning_payment_identifier, InvalidBitcoinURI +from electrum.bip21 import BITCOIN_BIP21_URI_SCHEME, parse_bip21_URI, InvalidBitcoinURI +from electrum.payment_identifier import maybe_extract_lightning_payment_identifier from electrum.lnaddr import lndecode, LnInvoiceException from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, LNURL6Data from electrum.logging import Logger diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 24f861434..bb2245c4f 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -16,7 +16,7 @@ from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType, QQmlApplica from electrum import version, constants from electrum.i18n import _ from electrum.logging import Logger, get_logger -from electrum.payment_identifier import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME +from electrum.bip21 import BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME from electrum.base_crash_reporter import BaseCrashReporter, EarlyExceptionsQueue from electrum.network import Network diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index e9e3a51b0..171959dbd 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -16,9 +16,9 @@ from electrum.lnutil import format_short_channel_id, IncompatibleOrInsaneFeature from electrum.lnurl import decode_lnurl, request_lnurl, callback_lnurl from electrum.bitcoin import COIN from electrum.paymentrequest import PaymentRequest -from electrum.payment_identifier import (parse_bip21_URI, InvalidBitcoinURI, maybe_extract_lightning_payment_identifier, +from electrum.payment_identifier import (maybe_extract_lightning_payment_identifier, PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType) - +from electrum.bip21 import parse_bip21_URI, InvalidBitcoinURI from .qetypes import QEAmount from .qewallet import QEWallet from .util import status_update_timer_interval, QtEventListener, event_listener @@ -526,7 +526,7 @@ class QEInvoiceParser(QEInvoice): self.validationSuccess.emit() return elif self._pi.type == PaymentIdentifierType.BOLT11: - lninvoice = Invoice.from_bech32(self._pi.bolt11) + lninvoice = self._pi.bolt11 if not self._wallet.wallet.has_lightning() and not lninvoice.get_address(): self.validationError.emit('no_lightning', _('Detected valid Lightning invoice, but Lightning not enabled for wallet and no fallback address found.')) @@ -539,7 +539,7 @@ class QEInvoiceParser(QEInvoice): self.validationSuccess.emit() elif self._pi.type == PaymentIdentifierType.BIP21: if self._wallet.wallet.has_lightning() and self._wallet.wallet.lnworker.channels and self._pi.bolt11: - lninvoice = Invoice.from_bech32(self._pi.bolt11) + lninvoice = self._pi.bolt11 self.setValidLightningInvoice(lninvoice) self.validationSuccess.emit() else: diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 9b9ff53d1..cf70c2f83 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -57,7 +57,8 @@ from electrum.i18n import _ from electrum.util import (format_time, UserCancelled, profiler, bfh, InvalidPassword, UserFacingException, get_new_wallet_name, send_exception_to_crash_reporter, AddTransactionException, os_chmod) -from electrum.payment_identifier import BITCOIN_BIP21_URI_SCHEME, PaymentIdentifier +from electrum.bip21 import BITCOIN_BIP21_URI_SCHEME +from electrum.payment_identifier import PaymentIdentifier from electrum.invoices import PR_PAID, Invoice from electrum.transaction import (Transaction, PartialTxInput, PartialTransaction, PartialTxOutput) diff --git a/electrum/invoices.py b/electrum/invoices.py index 61a90b781..d5bf7bfdc 100644 --- a/electrum/invoices.py +++ b/electrum/invoices.py @@ -7,7 +7,7 @@ import attr from .json_db import StoredObject, stored_in from .i18n import _ from .util import age, InvoiceError, format_satoshis -from .payment_identifier import create_bip21_uri +from .bip21 import create_bip21_uri from .lnutil import hex_to_bytes from .lnaddr import lndecode, LnAddr from . import constants diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 939a7fd2a..9b7719d64 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -9,14 +9,16 @@ from typing import NamedTuple, Optional, Callable, List, TYPE_CHECKING, Tuple from . import bitcoin from .contacts import AliasNotFoundException from .i18n import _ +from .invoices import Invoice from .logging import Logger -from .util import parse_max_spend, format_satoshis_plain +from .util import parse_max_spend, format_satoshis_plain, InvoiceError from .util import get_asyncio_loop, log_exceptions from .transaction import PartialTxOutput from .lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, lightning_address_to_url from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, opcodes, construct_script from .lnaddr import lndecode, LnDecodeException, LnInvoiceException from .lnutil import IncompatibleOrInsaneFeatures +from .bip21 import parse_bip21_URI, InvalidBitcoinURI, LIGHTNING_URI_SCHEME, BITCOIN_BIP21_URI_SCHEME if TYPE_CHECKING: from .wallet import Abstract_Wallet @@ -34,125 +36,6 @@ def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]: return None -# note: when checking against these, use .lower() to support case-insensitivity -BITCOIN_BIP21_URI_SCHEME = 'bitcoin' -LIGHTNING_URI_SCHEME = 'lightning' - - -class InvalidBitcoinURI(Exception): - pass - - -def parse_bip21_URI(uri: str) -> dict: - """Raises InvalidBitcoinURI on malformed URI.""" - - if not isinstance(uri, str): - raise InvalidBitcoinURI(f"expected string, not {repr(uri)}") - - if ':' not in uri: - if not bitcoin.is_address(uri): - raise InvalidBitcoinURI("Not a bitcoin address") - return {'address': uri} - - u = urllib.parse.urlparse(uri) - if u.scheme.lower() != BITCOIN_BIP21_URI_SCHEME: - raise InvalidBitcoinURI("Not a bitcoin URI") - address = u.path - - # python for android fails to parse query - if address.find('?') > 0: - address, query = u.path.split('?') - pq = urllib.parse.parse_qs(query) - else: - pq = urllib.parse.parse_qs(u.query) - - for k, v in pq.items(): - if len(v) != 1: - raise InvalidBitcoinURI(f'Duplicate Key: {repr(k)}') - - out = {k: v[0] for k, v in pq.items()} - if address: - if not bitcoin.is_address(address): - raise InvalidBitcoinURI(f"Invalid bitcoin address: {address}") - out['address'] = address - if 'amount' in out: - am = out['amount'] - try: - m = re.match(r'([0-9.]+)X([0-9])', am) - if m: - k = int(m.group(2)) - 8 - amount = Decimal(m.group(1)) * pow(Decimal(10), k) - else: - amount = Decimal(am) * COIN - if amount > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN: - raise InvalidBitcoinURI(f"amount is out-of-bounds: {amount!r} BTC") - out['amount'] = int(amount) - except Exception as e: - raise InvalidBitcoinURI(f"failed to parse 'amount' field: {repr(e)}") from e - if 'message' in out: - out['message'] = out['message'] - out['memo'] = out['message'] - if 'time' in out: - try: - out['time'] = int(out['time']) - except Exception as e: - raise InvalidBitcoinURI(f"failed to parse 'time' field: {repr(e)}") from e - if 'exp' in out: - try: - out['exp'] = int(out['exp']) - except Exception as e: - raise InvalidBitcoinURI(f"failed to parse 'exp' field: {repr(e)}") from e - if 'sig' in out: - try: - out['sig'] = bitcoin.base_decode(out['sig'], base=58).hex() - except Exception as e: - raise InvalidBitcoinURI(f"failed to parse 'sig' field: {repr(e)}") from e - if 'lightning' in out: - try: - lnaddr = lndecode(out['lightning']) - except LnDecodeException as e: - raise InvalidBitcoinURI(f"Failed to decode 'lightning' field: {e!r}") from e - amount_sat = out.get('amount') - if amount_sat: - # allow small leeway due to msat precision - if abs(amount_sat - int(lnaddr.get_amount_sat())) > 1: - raise InvalidBitcoinURI("Inconsistent lightning field in bip21: amount") - address = out.get('address') - ln_fallback_addr = lnaddr.get_fallback_address() - if address and ln_fallback_addr: - if ln_fallback_addr != address: - raise InvalidBitcoinURI("Inconsistent lightning field in bip21: address") - - return out - - -def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str], - *, extra_query_params: Optional[dict] = None) -> str: - if not bitcoin.is_address(addr): - return "" - if extra_query_params is None: - extra_query_params = {} - query = [] - if amount_sat: - query.append('amount=%s' % format_satoshis_plain(amount_sat)) - if message: - query.append('message=%s' % urllib.parse.quote(message)) - for k, v in extra_query_params.items(): - if not isinstance(k, str) or k != urllib.parse.quote(k): - raise Exception(f"illegal key for URI: {repr(k)}") - v = urllib.parse.quote(v) - query.append(f"{k}={v}") - p = urllib.parse.ParseResult( - scheme=BITCOIN_BIP21_URI_SCHEME, - netloc='', - path=addr, - params='', - query='&'.join(query), - fragment='' - ) - return str(urllib.parse.urlunparse(p)) - - def is_uri(data: str) -> bool: data = data.lower() if (data.startswith(LIGHTNING_URI_SCHEME + ":") or @@ -279,7 +162,14 @@ class PaymentIdentifier(Logger): return self._state in [PaymentIdentifierState.AVAILABLE] def is_lightning(self): - return self.lnurl or self.bolt11 + return bool(self.lnurl) or bool(self.bolt11) + + def is_onchain(self): + if self._type in [PaymentIdentifierType.SPK, PaymentIdentifierType.MULTILINE, PaymentIdentifierType.BIP70, + PaymentIdentifierType.OPENALIAS]: + return True + if self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNADDR]: + return bool(self.bolt11) and bool(self.bolt11.get_address()) def is_multiline(self): return bool(self.multiline_outputs) @@ -293,8 +183,7 @@ class PaymentIdentifier(Logger): elif self._type == PaymentIdentifierType.BIP70: return not self.need_resolve() # always fixed after resolve? elif self._type == PaymentIdentifierType.BOLT11: - lnaddr = lndecode(self.bolt11) - return bool(lnaddr.amount) + return bool(self.bolt11.get_amount_sat()) elif self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]: # amount limits known after resolve, might be specific amount or locked to range if self.need_resolve(): @@ -339,16 +228,12 @@ class PaymentIdentifier(Logger): else: self._type = PaymentIdentifierType.BOLT11 try: - lndecode(invoice_or_lnurl) - except LnInvoiceException as e: - self.error = _("Error parsing Lightning invoice") + f":\n{e}" - self.set_state(PaymentIdentifierState.INVALID) - return - except IncompatibleOrInsaneFeatures as e: - self.error = _("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}" + self.bolt11 = Invoice.from_bech32(invoice_or_lnurl) + except InvoiceError as e: + self.error = self._get_error_from_invoiceerror(e) self.set_state(PaymentIdentifierState.INVALID) + self.logger.debug(f'Exception cause {e.args!r}') return - self.bolt11 = invoice_or_lnurl self.set_state(PaymentIdentifierState.AVAILABLE) elif text.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'): try: @@ -643,6 +528,16 @@ class PaymentIdentifier(Logger): assert bitcoin.is_address(address) return address + def _get_error_from_invoiceerror(self, e: 'InvoiceError') -> str: + error = _("Error parsing Lightning invoice") + f":\n{e!r}" + if e.args and len(e.args): + arg = e.args[0] + if isinstance(arg, LnInvoiceException): + error = _("Error parsing Lightning invoice") + f":\n{e}" + elif isinstance(arg, IncompatibleOrInsaneFeatures): + error = _("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}" + return error + def get_fields_for_GUI(self) -> FieldsForGUI: recipient = None amount = None @@ -662,8 +557,8 @@ class PaymentIdentifier(Logger): self.warning = _('WARNING: the alias "{}" could not be validated via an additional ' 'security check, DNSSEC, and thus may not be correct.').format(key) - elif self.bolt11 and self.wallet.has_lightning(): - recipient, amount, description = self._get_bolt11_fields(self.bolt11) + elif self.bolt11: + recipient, amount, description = self._get_bolt11_fields() elif self.lnurl and self.lnurl_data: domain = urllib.parse.urlparse(self.lnurl).netloc @@ -705,9 +600,8 @@ class PaymentIdentifier(Logger): return FieldsForGUI(recipient=recipient, amount=amount, description=description, comment=comment, validated=validated, amount_range=amount_range) - def _get_bolt11_fields(self, bolt11_invoice): - """Parse ln invoice, and prepare the send tab for it.""" - lnaddr = lndecode(bolt11_invoice) # + def _get_bolt11_fields(self): + lnaddr = self.bolt11._lnaddr # TODO: improve access to lnaddr pubkey = lnaddr.pubkey.serialize().hex() for k, v in lnaddr.tags: if k == 'd': @@ -740,20 +634,17 @@ class PaymentIdentifier(Logger): if self.bip70: return self.bip70_data.has_expired() elif self.bolt11: - lnaddr = lndecode(self.bolt11) - return lnaddr.is_expired() + return self.bolt11.has_expired() elif self.bip21: expires = self.bip21.get('exp') + self.bip21.get('time') if self.bip21.get('exp') else 0 return bool(expires) and expires < time.time() return False def get_invoice(self, amount_sat, message): - from .invoices import Invoice if self.is_lightning(): - invoice_str = self.bolt11 - if not invoice_str: + invoice = self.bolt11 + if not invoice: return - invoice = Invoice.from_bech32(invoice_str) if invoice.amount_msat is None: invoice.amount_msat = int(amount_sat * 1000) return invoice diff --git a/electrum/tests/test_util.py b/electrum/tests/test_util.py index 6ff020710..1767bc8bf 100644 --- a/electrum/tests/test_util.py +++ b/electrum/tests/test_util.py @@ -5,7 +5,7 @@ from electrum import util from electrum.util import (format_satoshis, format_fee_satoshis, is_hash256_str, chunks, is_ip_address, list_enabled_bits, format_satoshis_plain, is_private_netaddress, is_hex_str, is_integer, is_non_negative_integer, is_int_or_float, is_non_negative_int_or_float) -from electrum.payment_identifier import parse_bip21_URI, InvalidBitcoinURI +from electrum.bip21 import parse_bip21_URI, InvalidBitcoinURI from . import ElectrumTestCase, as_testnet diff --git a/electrum/transaction.py b/electrum/transaction.py index 09fdf7ce4..248642905 100644 --- a/electrum/transaction.py +++ b/electrum/transaction.py @@ -42,8 +42,7 @@ import copy from . import ecc, bitcoin, constants, segwit_addr, bip32 from .bip32 import BIP32Node -from .util import profiler, to_bytes, bfh, chunks, is_hex_str -from .payment_identifier import parse_max_spend +from .util import profiler, to_bytes, bfh, chunks, is_hex_str, parse_max_spend from .bitcoin import (TYPE_ADDRESS, TYPE_SCRIPT, hash_160, hash160_to_p2sh, hash160_to_p2pkh, hash_to_segwit_addr, var_int, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, COIN, diff --git a/electrum/wallet.py b/electrum/wallet.py index 4376d8f77..65475b251 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -58,7 +58,6 @@ from .util import (NotEnoughFunds, UserCancelled, profiler, OldTaskGroup, ignore WalletFileException, BitcoinException, InvalidPassword, format_time, timestamp_to_datetime, Satoshis, Fiat, bfh, TxMinedInfo, quantize_feerate, OrderedDictWithIndex) -from .payment_identifier import create_bip21_uri, 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 @@ -66,7 +65,7 @@ from .crypto import sha256d from . import keystore from .keystore import (load_keystore, Hardware_KeyStore, KeyStore, KeyStoreWithMPK, AddressIndexGeneric, CannotDerivePubkey) -from .util import multisig_type +from .util import multisig_type, parse_max_spend from .storage import StorageEncryptionVersion, WalletStorage from .wallet_db import WalletDB from . import transaction, bitcoin, coinchooser, paymentrequest, ecc, bip32