Browse Source

Merge pull request #8888 from SomberNight/202402_jsonrpc_errors

cli/rpc: nicer error messages and error-passing
master
ThomasV 2 years ago committed by GitHub
parent
commit
9799603779
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 82
      electrum/commands.py
  2. 28
      electrum/daemon.py
  3. 21
      electrum/util.py
  4. 24
      electrum/wallet.py
  5. 32
      run_electrum

82
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"
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,10 +1093,11 @@ 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. '
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,7 +1256,8 @@ 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"
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))
@ -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)

28
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:
# execute requested command now. note: cmd can raise, the caller (self.handle) will wrap it.
result = await func(*args, **kwargs)
except Exception as e:
result = {'error':str(e)}
return result

21
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:

24
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 = []
@ -2597,17 +2597,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
@ -3056,7 +3056,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)]
@ -3905,7 +3905,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)
@ -3942,7 +3942,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)
@ -3953,7 +3953,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())
@ -3962,7 +3962,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)
@ -3971,7 +3971,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:

32
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:
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)

Loading…
Cancel
Save