diff --git a/electrum/commands.py b/electrum/commands.py index a0cbce7e6..3be45e86e 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -42,7 +42,8 @@ import os from .import util, ecc from .util import (bfh, format_satoshis, json_decode, json_normalize, - is_hash256_str, is_hex_str, to_bytes, parse_max_spend, to_decimal) + is_hash256_str, is_hex_str, to_bytes, parse_max_spend, to_decimal, + UserFacingException) from . import bitcoin from .bitcoin import is_address, hash_160, COIN from .bip32 import BIP32Node @@ -77,7 +78,7 @@ if TYPE_CHECKING: known_commands = {} # type: Dict[str, Command] -class NotSynchronizedException(Exception): +class NotSynchronizedException(UserFacingException): pass @@ -144,21 +145,21 @@ def command(s): if isinstance(wallet, str): wallet = daemon.get_wallet(wallet) if wallet is None: - raise Exception('wallet not loaded') + raise UserFacingException('wallet not loaded') kwargs['wallet'] = wallet if cmd.requires_password and password is None and wallet.has_password(): password = wallet.get_unlocked_password() if password: kwargs['password'] = password else: - raise Exception('Password required. Unlock the wallet, or add a --password option to your command') + raise UserFacingException('Password required. Unlock the wallet, or add a --password option to your command') wallet = kwargs.get('wallet') # type: Optional[Abstract_Wallet] if cmd.requires_wallet and not wallet: - raise Exception('wallet not loaded') + raise UserFacingException('wallet not loaded') if cmd.requires_password and password is None and wallet.has_password(): - raise Exception('Password required') + raise UserFacingException('Password required') if cmd.requires_lightning and (not wallet or not wallet.has_lightning()): - raise Exception('Lightning not enabled in this wallet') + raise UserFacingException('Lightning not enabled in this wallet') return await func(*args, **kwargs) return func_wrapper return decorator @@ -251,7 +252,7 @@ class Commands: """ wallet = self.daemon.load_wallet(wallet_path, password, upgrade=True) if wallet is None: - raise Exception('could not load wallet') + raise UserFacingException('could not load wallet') if unlock: wallet.unlock(password) run_hook('load_wallet', wallet, None) @@ -301,7 +302,7 @@ class Commands: async def password(self, password=None, new_password=None, encrypt_file=None, wallet: Abstract_Wallet = None): """Change wallet password. """ if wallet.storage.is_encrypted_with_hw_device() and new_password: - raise Exception("Can't change the password of a wallet encrypted with a hw device.") + raise UserFacingException("Can't change the password of a wallet encrypted with a hw device.") if encrypt_file is None: if not password and new_password: # currently no password, setting one now: we encrypt by default @@ -403,18 +404,18 @@ class Commands: keypairs = {} inputs = [] # type: List[PartialTxInput] locktime = jsontx.get('locktime', 0) - for txin_dict in jsontx.get('inputs'): + for txin_idx, txin_dict in enumerate(jsontx.get('inputs')): if txin_dict.get('prevout_hash') is not None and txin_dict.get('prevout_n') is not None: prevout = TxOutpoint(txid=bfh(txin_dict['prevout_hash']), out_idx=int(txin_dict['prevout_n'])) elif txin_dict.get('output'): prevout = TxOutpoint.from_str(txin_dict['output']) else: - raise Exception("missing prevout for txin") + raise UserFacingException(f"missing prevout for txin {txin_idx}") txin = PartialTxInput(prevout=prevout) try: txin._trusted_value_sats = int(txin_dict.get('value') or txin_dict['value_sats']) except KeyError: - raise Exception("missing 'value_sats' field for txin") + raise UserFacingException(f"missing 'value_sats' field for txin {txin_idx}") nsequence = txin_dict.get('nsequence', None) if nsequence is not None: txin.nsequence = nsequence @@ -428,15 +429,15 @@ class Commands: inputs.append(txin) outputs = [] # type: List[PartialTxOutput] - for txout_dict in jsontx.get('outputs'): + for txout_idx, txout_dict in enumerate(jsontx.get('outputs')): try: txout_addr = txout_dict['address'] except KeyError: - raise Exception("missing 'address' field for txout") + raise UserFacingException(f"missing 'address' field for txout {txout_idx}") try: txout_val = int(txout_dict.get('value') or txout_dict['value_sats']) except KeyError: - raise Exception("missing 'value_sats' field for txout") + raise UserFacingException(f"missing 'value_sats' field for txout {txout_idx}") txout = PartialTxOutput.from_address_and_value(txout_addr, txout_val) outputs.append(txout) @@ -651,7 +652,7 @@ class Commands: try: node = BIP32Node.from_xkey(xkey) except Exception: - raise Exception('xkey should be a master public/private key') + raise UserFacingException('xkey should be a master public/private key') return node._replace(xtype=xtype).to_xkey() @command('wp') @@ -672,12 +673,12 @@ class Commands: out = "Error: " + repr(e) return out - def _resolver(self, x, wallet): + def _resolver(self, x, wallet: Abstract_Wallet): if x is None: return None out = wallet.contacts.resolve(x) if out.get('type') == 'openalias' and self.nocheck is False and out.get('validated') is False: - raise Exception('cannot verify alias', x) + raise UserFacingException(f"cannot verify alias: {x}") return out['address'] @command('n') @@ -802,13 +803,13 @@ class Commands: if is_hash256_str(tx): # txid tx = wallet.db.get_transaction(tx) if tx is None: - raise Exception("Transaction not in wallet.") + raise UserFacingException("Transaction not in wallet.") else: # raw tx try: tx = Transaction(tx) tx.deserialize() except transaction.SerializationError as e: - raise Exception(f"Failed to deserialize transaction: {e}") from e + raise UserFacingException(f"Failed to deserialize transaction: {e}") from e domain_coins = from_coins.split(',') if from_coins else None coins = wallet.get_spendable_coins(None) if domain_coins is not None: @@ -891,20 +892,20 @@ class Commands: if raw: tx = Transaction(raw) else: - raise Exception("Unknown transaction") + raise UserFacingException("Unknown transaction") if tx.txid() != txid: - raise Exception("Mismatching txid") + raise UserFacingException("Mismatching txid") return tx.serialize() @command('') async def encrypt(self, pubkey, message) -> str: """Encrypt a message with a public key. Use quotes if the message contains whitespaces.""" if not is_hex_str(pubkey): - raise Exception(f"pubkey must be a hex string instead of {repr(pubkey)}") + raise UserFacingException(f"pubkey must be a hex string instead of {repr(pubkey)}") try: message = to_bytes(message) except TypeError: - raise Exception(f"message must be a string-like object instead of {repr(message)}") + raise UserFacingException(f"message must be a string-like object instead of {repr(message)}") public_key = ecc.ECPubkey(bfh(pubkey)) encrypted = public_key.encrypt_message(message) return encrypted.decode('utf-8') @@ -913,9 +914,9 @@ class Commands: async def decrypt(self, pubkey, encrypted, password=None, wallet: Abstract_Wallet = None) -> str: """Decrypt a message encrypted with a public key.""" if not is_hex_str(pubkey): - raise Exception(f"pubkey must be a hex string instead of {repr(pubkey)}") + raise UserFacingException(f"pubkey must be a hex string instead of {repr(pubkey)}") if not isinstance(encrypted, (str, bytes, bytearray)): - raise Exception(f"encrypted must be a string-like object instead of {repr(encrypted)}") + raise UserFacingException(f"encrypted must be a string-like object instead of {repr(encrypted)}") decrypted = wallet.decrypt_message(pubkey, encrypted, password) return decrypted.decode('utf-8') @@ -924,7 +925,7 @@ class Commands: """Returns a payment request""" r = wallet.get_request(request_id) if not r: - raise Exception("Request not found") + raise UserFacingException("Request not found") return wallet.export_request(r) @command('w') @@ -932,7 +933,7 @@ class Commands: """Returns an invoice (request for outgoing payment)""" r = wallet.get_invoice(invoice_id) if not r: - raise Exception("Request not found") + raise UserFacingException("Request not found") return wallet.export_invoice(r) #@command('w') @@ -976,13 +977,14 @@ class Commands: async def changegaplimit(self, new_limit, iknowwhatimdoing=False, wallet: Abstract_Wallet = None): """Change the gap limit of the wallet.""" if not iknowwhatimdoing: - raise Exception("WARNING: Are you SURE you want to change the gap limit?\n" - "It makes recovering your wallet from seed difficult!\n" - "Please do your research and make sure you understand the implications.\n" - "Typically only merchants and power users might want to do this.\n" - "To proceed, try again, with the --iknowwhatimdoing option.") + raise UserFacingException( + "WARNING: Are you SURE you want to change the gap limit?\n" + "It makes recovering your wallet from seed difficult!\n" + "Please do your research and make sure you understand the implications.\n" + "Typically only merchants and power users might want to do this.\n" + "To proceed, try again, with the --iknowwhatimdoing option.") if not isinstance(wallet, Deterministic_Wallet): - raise Exception("This wallet is not deterministic.") + raise UserFacingException("This wallet is not deterministic.") return wallet.change_gap_limit(new_limit) @command('wn') @@ -991,7 +993,7 @@ class Commands: known addresses in the wallet. """ if not isinstance(wallet, Deterministic_Wallet): - raise Exception("This wallet is not deterministic.") + raise UserFacingException("This wallet is not deterministic.") if not wallet.is_up_to_date(): raise NotSynchronizedException("Wallet not fully synchronized.") return wallet.min_acceptable_gap() @@ -1091,11 +1093,12 @@ class Commands: transactions. """ if not is_hash256_str(txid): - raise Exception(f"{repr(txid)} is not a txid") + raise UserFacingException(f"{repr(txid)} is not a txid") height = wallet.adb.get_tx_height(txid).height if height != TX_HEIGHT_LOCAL: - raise Exception(f'Only local transactions can be removed. ' - f'This tx has height: {height} != {TX_HEIGHT_LOCAL}') + raise UserFacingException( + f'Only local transactions can be removed. ' + f'This tx has height: {height} != {TX_HEIGHT_LOCAL}') wallet.adb.remove_transaction(txid) wallet.save_db() @@ -1105,9 +1108,9 @@ class Commands: The transaction must be related to the wallet. """ if not is_hash256_str(txid): - raise Exception(f"{repr(txid)} is not a txid") + raise UserFacingException(f"{repr(txid)} is not a txid") if not wallet.db.get_transaction(txid): - raise Exception("Transaction not in wallet.") + raise UserFacingException("Transaction not in wallet.") return { "confirmations": wallet.adb.get_tx_height(txid).conf, } @@ -1253,8 +1256,9 @@ class Commands: async def get_channel_ctx(self, channel_point, iknowwhatimdoing=False, wallet: Abstract_Wallet = None): """ return the current commitment transaction of a channel """ if not iknowwhatimdoing: - raise Exception("WARNING: this command is potentially unsafe.\n" - "To proceed, try again, with the --iknowwhatimdoing option.") + raise UserFacingException( + "WARNING: this command is potentially unsafe.\n" + "To proceed, try again, with the --iknowwhatimdoing option.") txid, index = channel_point.split(':') chan_id, _ = channel_id_from_funding_tx(txid, int(index)) chan = wallet.lnworker.channels[chan_id] @@ -1354,7 +1358,7 @@ class Commands: configured exchange rate source. """ if not self.daemon.fx.is_enabled(): - raise Exception("FX is disabled. To enable, run: 'electrum setconfig use_exchange_rate true'") + raise UserFacingException("FX is disabled. To enable, run: 'electrum setconfig use_exchange_rate true'") # Currency codes are uppercase from_ccy = from_ccy.upper() to_ccy = to_ccy.upper() @@ -1368,9 +1372,9 @@ class Commands: rate_to = self.daemon.fx.exchange.get_cached_spot_quote(to_ccy) # Test if currencies exist if rate_from.is_nan(): - raise Exception(f'Currency to convert from ({from_ccy}) is unknown or rate is unavailable') + raise UserFacingException(f'Currency to convert from ({from_ccy}) is unknown or rate is unavailable') if rate_to.is_nan(): - raise Exception(f'Currency to convert to ({to_ccy}) is unknown or rate is unavailable') + raise UserFacingException(f'Currency to convert to ({to_ccy}) is unknown or rate is unavailable') # Conversion try: from_amount = to_decimal(from_amount) diff --git a/electrum/daemon.py b/electrum/daemon.py index 4cfd0012d..e6d446705 100644 --- a/electrum/daemon.py +++ b/electrum/daemon.py @@ -35,6 +35,7 @@ from base64 import b64decode, b64encode from collections import defaultdict import json import socket +from enum import IntEnum import aiohttp from aiohttp import web, client_exceptions @@ -44,7 +45,7 @@ from . import util from .network import Network from .util import (json_decode, to_bytes, to_string, profiler, standardize_path, constant_time_compare, InvalidPassword) from .invoices import PR_PAID, PR_EXPIRED -from .util import log_exceptions, ignore_exceptions, randrange, OldTaskGroup +from .util import log_exceptions, ignore_exceptions, randrange, OldTaskGroup, UserFacingException, JsonRPCError from .util import EventListener, event_listener from .wallet import Wallet, Abstract_Wallet from .storage import WalletStorage @@ -251,11 +252,20 @@ class AuthenticatedServer(Logger): response['result'] = await f(**params) else: response['result'] = await f(*params) + except UserFacingException as e: + response['error'] = { + 'code': JsonRPCError.Codes.USERFACING, + 'message': str(e), + } except BaseException as e: self.logger.exception("internal error while executing RPC") response['error'] = { - 'code': 1, - 'message': str(e), + 'code': JsonRPCError.Codes.INTERNAL, + 'message': "internal error while executing RPC", + 'data': { + "exception": repr(e), + "traceback": "".join(traceback.format_exception(e)), + }, } return web.json_response(response) @@ -325,12 +335,11 @@ class CommandsServer(AuthenticatedServer): if hasattr(self.daemon.gui_object, 'new_window'): path = config_options.get('wallet_path') or self.config.get_wallet_path(use_gui_last_wallet=True) self.daemon.gui_object.new_window(path, config_options.get('url')) - response = "ok" + return True else: - response = "error: current GUI does not support multiple windows" + raise UserFacingException("error: current GUI does not support multiple windows") else: - response = "Error: Electrum is running in daemon mode. Please stop the daemon first." - return response + raise UserFacingException("error: Electrum is running in daemon mode. Please stop the daemon first.") async def run_cmdline(self, config_options): cmdname = config_options['cmd'] @@ -348,11 +357,8 @@ class CommandsServer(AuthenticatedServer): elif 'wallet' in cmd.options: kwargs['wallet'] = config_options.get('wallet_path') func = getattr(self.cmd_runner, cmd.name) - # fixme: not sure how to retrieve message in jsonrpcclient - try: - result = await func(*args, **kwargs) - except Exception as e: - result = {'error':str(e)} + # execute requested command now. note: cmd can raise, the caller (self.handle) will wrap it. + result = await func(*args, **kwargs) return result diff --git a/electrum/util.py b/electrum/util.py index 8f8b9219a..f2c6f5743 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -50,6 +50,7 @@ import functools from functools import partial from abc import abstractmethod, ABC import socket +import enum import attr import aiohttp @@ -1932,6 +1933,20 @@ class MySocksProxy(aiorpcx.SOCKSProxy): return ret +class JsonRPCError(Exception): + + class Codes(enum.IntEnum): + # application-specific error codes + USERFACING = 1 + INTERNAL = 2 + + def __init__(self, *, code: int, message: str, data: Optional[dict] = None): + Exception.__init__(self) + self.code = code + self.message = message + self.data = data + + class JsonRPCClient: def __init__(self, session: aiohttp.ClientSession, url: str): @@ -1940,6 +1955,10 @@ class JsonRPCClient: self._id = 0 async def request(self, endpoint, *args): + """Send request to server, parse and return result. + note: parsing code is naive, the server is assumed to be well-behaved. + Up to the caller to handle exceptions, including those arising from parsing errors. + """ self._id += 1 data = ('{"jsonrpc": "2.0", "id":"%d", "method": "%s", "params": %s }' % (self._id, endpoint, json.dumps(args))) @@ -1949,7 +1968,7 @@ class JsonRPCClient: result = r.get('result') error = r.get('error') if error: - return 'Error: ' + str(error) + raise JsonRPCError(code=error["code"], message=error["message"], data=error.get("data")) else: return result else: diff --git a/electrum/wallet.py b/electrum/wallet.py index d5a5f29f8..cbf960b29 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -817,7 +817,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): return serialized_privkey def export_private_key_for_path(self, path: Union[Sequence[int], str], password: Optional[str]) -> str: - raise Exception("this wallet is not deterministic") + raise UserFacingException("this wallet is not deterministic") @abstractmethod def get_public_keys(self, address: str) -> Sequence[str]: @@ -1440,7 +1440,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): # FIXME: Lightning capital gains would requires FIFO if (from_timestamp is not None or to_timestamp is not None) \ and (from_height is not None or to_height is not None): - raise Exception('timestamp and block height based filtering cannot be used together') + raise UserFacingException('timestamp and block height based filtering cannot be used together') show_fiat = fx and fx.is_enabled() and fx.has_history() out = [] @@ -2591,17 +2591,17 @@ class Abstract_Wallet(ABC, Logger, EventListener): return choice def create_new_address(self, for_change: bool = False): - raise Exception("this wallet cannot generate new addresses") + raise UserFacingException("this wallet cannot generate new addresses") def import_address(self, address: str) -> str: - raise Exception("this wallet cannot import addresses") + raise UserFacingException("this wallet cannot import addresses") def import_addresses(self, addresses: List[str], *, write_to_disk=True) -> Tuple[List[str], List[Tuple[str, str]]]: - raise Exception("this wallet cannot import addresses") + raise UserFacingException("this wallet cannot import addresses") def delete_address(self, address: str) -> None: - raise Exception("this wallet cannot delete addresses") + raise UserFacingException("this wallet cannot delete addresses") def get_request_URI(self, req: Request) -> Optional[str]: lightning_invoice = None @@ -3050,7 +3050,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): ) -> PartialTransaction: """Helper function for make_unsigned_transaction.""" if fee is not None and feerate is not None: - raise Exception("Cannot specify both 'fee' and 'feerate' at the same time!") + raise UserFacingException("Cannot specify both 'fee' and 'feerate' at the same time!") coins = self.get_spendable_coins(domain_addr, nonlocal_only=nonlocal_only) if domain_coins is not None: coins = [coin for coin in coins if (coin.prevout.to_str() in domain_coins)] @@ -3892,7 +3892,7 @@ def create_new_wallet(*, path, config: SimpleConfig, passphrase=None, password=N """Create a new wallet""" storage = WalletStorage(path) if storage.file_exists(): - raise Exception("Remove the existing wallet first!") + raise UserFacingException("Remove the existing wallet first!") db = WalletDB('', storage=storage, upgrade=True) seed = Mnemonic('en').make_seed(seed_type=seed_type) @@ -3929,7 +3929,7 @@ def restore_wallet_from_text( else: storage = WalletStorage(path) if storage.file_exists(): - raise Exception("Remove the existing wallet first!") + raise UserFacingException("Remove the existing wallet first!") if encrypt_file is None: encrypt_file = True db = WalletDB('', storage=storage, upgrade=True) @@ -3940,7 +3940,7 @@ def restore_wallet_from_text( good_inputs, bad_inputs = wallet.import_addresses(addresses, write_to_disk=False) # FIXME tell user about bad_inputs if not good_inputs: - raise Exception("None of the given addresses can be imported") + raise UserFacingException("None of the given addresses can be imported") elif keystore.is_private_key_list(text, allow_spaces_inside_key=False): k = keystore.Imported_KeyStore({}) db.put('keystore', k.dump()) @@ -3949,7 +3949,7 @@ def restore_wallet_from_text( good_inputs, bad_inputs = wallet.import_private_keys(keys, None, write_to_disk=False) # FIXME tell user about bad_inputs if not good_inputs: - raise Exception("None of the given privkeys can be imported") + raise UserFacingException("None of the given privkeys can be imported") else: if keystore.is_master_key(text): k = keystore.from_master_key(text) @@ -3958,7 +3958,7 @@ def restore_wallet_from_text( if k.can_have_deterministic_lightning_xprv(): db.put('lightning_xprv', k.get_lightning_xprv(None)) else: - raise Exception("Seed or key not recognized") + raise UserFacingException("Seed or key not recognized") db.put('keystore', k.dump()) db.put('wallet_type', 'standard') if gap_limit is not None: diff --git a/run_electrum b/run_electrum index ccf608e53..ad36be39a 100755 --- a/run_electrum +++ b/run_electrum @@ -104,7 +104,7 @@ from electrum.util import InvalidPassword from electrum.commands import get_parser, known_commands, Commands, config_variables from electrum import daemon from electrum import keystore -from electrum.util import create_and_start_event_loop +from electrum.util import create_and_start_event_loop, UserFacingException, JsonRPCError from electrum.i18n import set_language if TYPE_CHECKING: @@ -462,7 +462,17 @@ def handle_cmd(*, cmdname: str, config: 'SimpleConfig', config_options: dict): else: sys_exit(0) else: - result = daemon.request(config, 'gui', (config_options,)) + try: + result = daemon.request(config, 'gui', (config_options,)) + except JsonRPCError as e: + if e.code == JsonRPCError.Codes.USERFACING: + print_stderr(e.message) + elif e.code == JsonRPCError.Codes.INTERNAL: + print_stderr("(inside daemon): " + e.data["traceback"]) + print_stderr(e.message) + else: + raise Exception(f"unknown error code {e.code}") + sys_exit(1) elif cmdname == 'daemon': @@ -497,8 +507,17 @@ def handle_cmd(*, cmdname: str, config: 'SimpleConfig', config_options: dict): print_msg("Found lingering lockfile for daemon. Removing.") daemon.remove_lockfile(lockfile) sys_exit(1) + except JsonRPCError as e: + if e.code == JsonRPCError.Codes.USERFACING: + print_stderr(e.message) + elif e.code == JsonRPCError.Codes.INTERNAL: + print_stderr("(inside daemon): " + e.data["traceback"]) + print_stderr(e.message) + else: + raise Exception(f"unknown error code {e.code}") + sys_exit(1) except Exception as e: - print_stderr(str(e) or repr(e)) + _logger.exception("error running command (with daemon)") sys_exit(1) else: if cmd.requires_network: @@ -520,14 +539,15 @@ def handle_cmd(*, cmdname: str, config: 'SimpleConfig', config_options: dict): finally: plugins.stop() plugins.stopped_event.wait(1) + except UserFacingException as e: + print_stderr(str(e)) + sys_exit(1) except Exception as e: - print_stderr(str(e) or repr(e)) + _logger.exception("error running command (without daemon)") sys_exit(1) + # print result if isinstance(result, str): print_msg(result) - elif type(result) is dict and result.get('error'): - print_stderr(result.get('error')) - sys_exit(1) elif result is not None: print_msg(json_encode(result)) sys_exit(0)