Asynchronous HTTP significantly speeds up operations involving multiple
JSON-RPC calls to the server, such as tracking wallet views for wallets
with a large number of outputs.
This patch adds base-level asyncio infrastructure plus aiohttp support to all
applicable MMGen commands.
The aiohttp package is not currently supported by MSYS2, so Windows users will
have to choose one of the other backends ('curl' is the default).
Tested on: Linux, Armbian, Windows; Python 3.6, 3.7, 3.8
New user features:
- configurable RPC backends via the 'rpc_backend' option. Supported
options are 'aiohttp' (Linux-only), 'httplib', 'requests' and 'curl'
- configurable RPC queue size via the 'aiohttp_rpc_queue_len' option
The patch also includes a rewrite/redesign of large parts of the MMGen code
base, most importantly:
- rpc.py - full rewrite of RPC library, new RPCBackends class
- main_addrimport.py - full rewrite
- main_autosign.py - LED code now handled by new LEDControl class
- eth/tw.py, eth/tx.py - reworked logic for resolving token symbols and
addresses
- eth/tx.py - separate classes for signed and unsigned transactions
Testing:
# Set a backend (choose one):
$ export MMGEN_RPC_BACKEND='aiohttp' # Linux-only
$ export MMGEN_RPC_BACKEND='curl' # Windows
$ export MMGEN_RPC_BACKEND='httplib' # compare performance with 'aiohttp'
# Bitcoin:
$ test/unit_tests.py rpc btc
$ test/test.py main regtest autosign
# Ethereum:
$ test/unit_tests.py rpc eth
$ test/tooltest2.py --coin=eth --testnet=1 txview
$ test/test.py --coin=eth ethdev
# Monero wallet:
$ test/unit_tests.py rpc xmr_wallet
$ test/test-release.sh -F xmr
553 lines
16 KiB
Python
Executable file
553 lines
16 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
#
|
|
# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
|
|
# Copyright (C)2013-2020 The MMGen Project <mmgen@tuta.io>
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
"""
|
|
rpc.py: Cryptocoin RPC library for the MMGen suite
|
|
"""
|
|
|
|
import base64,json,asyncio
|
|
from decimal import Decimal
|
|
from .common import *
|
|
from .obj import aInitMeta
|
|
|
|
rpc_credentials_msg = lambda: '\n'+fmt(f"""
|
|
Error: no {g.proto.name.capitalize()} RPC authentication method found
|
|
|
|
RPC credentials must be supplied using one of the following methods:
|
|
|
|
A) If daemon is local and running as same user as you:
|
|
|
|
- no credentials required, or matching rpcuser/rpcpassword and
|
|
rpc_user/rpc_password values in {g.proto.name}.conf and mmgen.cfg
|
|
|
|
B) If daemon is running remotely or as different user:
|
|
|
|
- matching credentials in {g.proto.name}.conf and mmgen.cfg as described above
|
|
|
|
The --rpc-user/--rpc-password options may be supplied on the MMGen command line.
|
|
They override the corresponding values in mmgen.cfg. Set them to an empty string
|
|
to use cookie authentication with a local server when the options are set
|
|
in mmgen.cfg.
|
|
|
|
For better security, rpcauth should be used in {g.proto.name}.conf instead of
|
|
rpcuser/rpcpassword.
|
|
|
|
""",strip_char='\t')
|
|
|
|
def dmsg_rpc(fs,data=None,is_json=False):
|
|
if g.debug_rpc:
|
|
msg(fs if data == None else fs.format(pp_fmt(json.loads(data) if is_json else data)))
|
|
|
|
class json_encoder(json.JSONEncoder):
|
|
def default(self,obj):
|
|
if isinstance(obj,Decimal):
|
|
return str(obj)
|
|
else:
|
|
return json.JSONEncoder.default(self,obj)
|
|
|
|
class RPCBackends:
|
|
|
|
class aiohttp:
|
|
|
|
def __init__(self,caller):
|
|
self.caller = caller
|
|
self.session = g.session
|
|
self.url = caller.url
|
|
self.timeout = caller.timeout
|
|
if caller.auth_type == 'basic':
|
|
import aiohttp
|
|
self.auth = aiohttp.BasicAuth(*caller.auth,encoding='UTF-8')
|
|
else:
|
|
self.auth = None
|
|
|
|
async def run(self,payload,timeout=None):
|
|
dmsg_rpc('\n RPC PAYLOAD data (aiohttp) ==>\n{}\n',payload)
|
|
async with self.session.post(
|
|
url = self.url,
|
|
auth = self.auth,
|
|
data = json.dumps(payload,cls=json_encoder),
|
|
timeout = timeout or self.timeout,
|
|
) as res:
|
|
return (await res.text(),res.status)
|
|
|
|
class requests:
|
|
|
|
def __init__(self,caller):
|
|
self.url = caller.url
|
|
self.timeout = caller.timeout
|
|
import requests,urllib3
|
|
urllib3.disable_warnings()
|
|
self.session = requests.Session()
|
|
self.session.headers = caller.http_hdrs
|
|
if caller.auth_type:
|
|
auth = 'HTTP' + caller.auth_type.capitalize() + 'Auth'
|
|
self.session.auth = getattr(requests.auth,auth)(*caller.auth)
|
|
|
|
async def run(self,payload,timeout=None):
|
|
dmsg_rpc('\n RPC PAYLOAD data (requests) ==>\n{}\n',payload)
|
|
res = self.session.post(
|
|
url = self.url,
|
|
data = json.dumps(payload,cls=json_encoder),
|
|
timeout = timeout or self.timeout,
|
|
verify = False )
|
|
return (res.content,res.status_code)
|
|
|
|
class httplib:
|
|
|
|
def __init__(self,caller):
|
|
import http.client
|
|
self.session = http.client.HTTPConnection(caller.host,caller.port,caller.timeout)
|
|
self.http_hdrs = caller.http_hdrs
|
|
self.host = caller.host
|
|
self.port = caller.port
|
|
if caller.auth_type == 'basic':
|
|
auth_str = f'{caller.auth.user}:{caller.auth.passwd}'
|
|
auth_str_b64 = 'Basic ' + base64.b64encode(auth_str.encode()).decode()
|
|
self.http_hdrs.update({ 'Host': self.host, 'Authorization': auth_str_b64 })
|
|
fs = ' RPC AUTHORIZATION data ==> raw: [{}]\n{:>31}enc: [{}]\n'
|
|
dmsg_rpc(fs.format(auth_str,'',auth_str_b64))
|
|
|
|
async def run(self,payload,timeout=None):
|
|
dmsg_rpc('\n RPC PAYLOAD data (httplib) ==>\n{}\n',payload)
|
|
if timeout:
|
|
import http.client
|
|
s = http.client.HTTPConnection(self.host,self.port,timeout)
|
|
else:
|
|
s = self.session
|
|
try:
|
|
s.request(
|
|
method = 'POST',
|
|
url = '/',
|
|
body = json.dumps(payload,cls=json_encoder),
|
|
headers = self.http_hdrs )
|
|
r = s.getresponse() # => http.client.HTTPResponse instance
|
|
except Exception as e:
|
|
raise RPCFailure(str(e))
|
|
return (r.read(),r.status)
|
|
|
|
class curl:
|
|
|
|
def __init__(self,caller):
|
|
|
|
def gen():
|
|
for k,v in caller.http_hdrs.items():
|
|
for s in ('--header',f'{k}: {v}'):
|
|
yield s
|
|
if caller.auth_type:
|
|
"""
|
|
Authentication with curl is insecure, as it exposes the user's credentials
|
|
via the command line. Use for testing only.
|
|
"""
|
|
for s in ('--user',f'{caller.auth.user}:{caller.auth.passwd}'):
|
|
yield s
|
|
if caller.auth_type == 'digest':
|
|
yield '--digest'
|
|
|
|
self.url = caller.url
|
|
self.exec_opts = list(gen()) + ['--silent']
|
|
self.arg_max = 8192 # set way below system ARG_MAX, just to be safe
|
|
self.timeout = caller.timeout
|
|
|
|
async def run(self,payload,timeout=None):
|
|
data = json.dumps(payload,cls=json_encoder)
|
|
if len(data) > self.arg_max:
|
|
return self.httplib(payload,timeout=timeout)
|
|
dmsg_rpc('\n RPC PAYLOAD data (curl) ==>\n{}\n',payload)
|
|
exec_cmd = [
|
|
'curl',
|
|
'--proxy', '',
|
|
'--connect-timeout', str(timeout or self.timeout),
|
|
'--request', 'POST',
|
|
'--write-out', '%{http_code}',
|
|
'--data-binary', data
|
|
] + self.exec_opts + [self.url]
|
|
|
|
dmsg_rpc(' RPC curl exec data ==>\n{}\n',exec_cmd)
|
|
|
|
from subprocess import run,PIPE
|
|
res = run(exec_cmd,stdout=PIPE,check=True).stdout.decode()
|
|
# res = run(exec_cmd,stdout=PIPE,check=True,text='UTF-8').stdout # Python 3.7+
|
|
return (res[:-3],int(res[-3:]))
|
|
|
|
from collections import namedtuple
|
|
auth_data = namedtuple('rpc_auth_data',['user','passwd'])
|
|
|
|
class RPCClient(MMGenObject):
|
|
|
|
has_auth_cookie = False
|
|
url_fs = 'http://{}:{}'
|
|
|
|
def __init__(self,host,port):
|
|
|
|
dmsg_rpc('=== {}.__init__() debug ==='.format(type(self).__name__))
|
|
dmsg_rpc(f' cls [{type(self).__name__}] host [{host}] port [{port}]\n')
|
|
|
|
import socket
|
|
try:
|
|
socket.create_connection((host,port),timeout=1).close()
|
|
except:
|
|
raise SocketError('Unable to connect to {}:{}'.format(host,port))
|
|
|
|
self.http_hdrs = { 'Content-Type': 'application/json' }
|
|
self.url = self.url_fs.format(host,port)
|
|
self.host = host
|
|
self.port = port
|
|
self.timeout = g.http_timeout
|
|
self.auth = None
|
|
|
|
def set_backend(self,backend=None):
|
|
bn = backend or opt.rpc_backend
|
|
if bn == 'auto':
|
|
self.backend = {'linux':RPCBackends.httplib,'win':RPCBackends.curl}[g.platform](self)
|
|
else:
|
|
self.backend = getattr(RPCBackends,bn)(self)
|
|
|
|
def set_auth(self):
|
|
"""
|
|
MMGen's credentials override coin daemon's
|
|
"""
|
|
if g.rpc_user:
|
|
user,passwd = (g.rpc_user,g.rpc_password)
|
|
else:
|
|
user,passwd = get_coin_daemon_cfg_options(('rpcuser','rpcpassword')).values()
|
|
|
|
if user and passwd:
|
|
self.auth = auth_data(user,passwd)
|
|
return
|
|
|
|
if self.has_auth_cookie:
|
|
cookie = self.get_daemon_auth_cookie()
|
|
if cookie:
|
|
self.auth = auth_data(*cookie.split(':'))
|
|
return
|
|
|
|
die(1,rpc_credentials_msg())
|
|
|
|
# positional params are passed to the daemon, kwargs to the backend
|
|
# 'timeout' is currently the only supported kwarg
|
|
|
|
async def call(self,method,*params,**kwargs):
|
|
"""
|
|
default call: call with param list unrolled, exactly as with cli
|
|
"""
|
|
if method == g.rpc_fail_on_command:
|
|
method = 'badcommand_' + method
|
|
return await self.process_http_resp(self.backend.run(
|
|
payload = {'id': 1, 'jsonrpc': '2.0', 'method': method, 'params': params },
|
|
**kwargs
|
|
))
|
|
|
|
async def batch_call(self,method,param_list,**kwargs):
|
|
"""
|
|
Make a single call with a list of tuples as first argument
|
|
For RPC calls that return a list of results
|
|
"""
|
|
return await self.process_http_resp(self.backend.run(
|
|
payload = [{
|
|
'id': n,
|
|
'jsonrpc': '2.0',
|
|
'method': method,
|
|
'params': params } for n,params in enumerate(param_list,1) ],
|
|
**kwargs
|
|
),batch=True)
|
|
|
|
async def gathered_call(self,method,args_list,**kwargs):
|
|
"""
|
|
Perform multiple RPC calls, returning results in a list
|
|
Can be called two ways:
|
|
1) method = methodname, args_list = [args_tuple1, args_tuple2,...]
|
|
2) method = None, args_list = [(methodname1,args_tuple1), (methodname2,args_tuple2), ...]
|
|
"""
|
|
cmd_list = args_list if method == None else tuple(zip([method] * len(args_list), args_list))
|
|
|
|
cur_pos = 0
|
|
chunk_size = 1024
|
|
ret = []
|
|
|
|
while cur_pos < len(cmd_list):
|
|
tasks = [self.process_http_resp(self.backend.run(
|
|
payload = {'id': n, 'jsonrpc': '2.0', 'method': method, 'params': params },
|
|
**kwargs
|
|
)) for n,(method,params) in enumerate(cmd_list[cur_pos:chunk_size+cur_pos],1)]
|
|
ret.extend(await asyncio.gather(*tasks))
|
|
cur_pos += chunk_size
|
|
|
|
return ret
|
|
|
|
async def process_http_resp(self,coro,batch=False):
|
|
text,status = await coro
|
|
if status == 200:
|
|
dmsg_rpc(' RPC RESPONSE data ==>\n{}\n',text,is_json=True)
|
|
if batch:
|
|
return [r['result'] for r in json.loads(text,parse_float=Decimal,encoding='UTF-8')]
|
|
else:
|
|
try:
|
|
return json.loads(text,parse_float=Decimal,encoding='UTF-8')['result']
|
|
except:
|
|
raise RPCFailure(json.loads(text)['error']['message'])
|
|
else:
|
|
import http
|
|
s = http.HTTPStatus(status)
|
|
m = ''
|
|
if text:
|
|
try: m = ': ' + json.loads(text)['error']['message']
|
|
except:
|
|
try: m = f': {text.decode()}'
|
|
except: m = f': {text}'
|
|
raise RPCFailure(f'{s.value} {s.name}{m}')
|
|
|
|
|
|
class BitcoinRPCClient(RPCClient,metaclass=aInitMeta):
|
|
|
|
auth_type = 'basic'
|
|
has_auth_cookie = True
|
|
|
|
def __init__(self,*args,**kwargs): pass
|
|
|
|
async def __ainit__(self,backend=None):
|
|
|
|
async def check_chainfork_mismatch(block0):
|
|
try:
|
|
assert block0 == g.proto.block0,'Incorrect Genesis block for {}'.format(g.proto.__name__)
|
|
for fork in g.proto.forks:
|
|
if fork.height == None or self.blockcount < fork.height:
|
|
break
|
|
if fork.hash != await self.call('getblockhash',fork.height):
|
|
die(3,f'Bad block hash at fork block {fork.height}. Is this the {fork.name} chain?')
|
|
except Exception as e:
|
|
die(2,"{}\n'{c}' requested, but this is not the {c} chain!".format(e.args[0],c=g.coin))
|
|
|
|
def check_chaintype_mismatch():
|
|
try:
|
|
if g.regtest: assert g.chain == 'regtest','--regtest option selected, but chain is not regtest'
|
|
if g.testnet: assert g.chain != 'mainnet','--testnet option selected, but chain is mainnet'
|
|
if not g.testnet: assert g.chain == 'mainnet','mainnet selected, but chain is not mainnet'
|
|
except Exception as e:
|
|
die(1,'{}\nChain is {}!'.format(e.args[0],g.chain))
|
|
|
|
user,passwd = get_coin_daemon_cfg_options(('rpcuser','rpcpassword')).values()
|
|
|
|
super().__init__(
|
|
host = g.rpc_host or 'localhost',
|
|
port = g.rpc_port or g.proto.rpc_port)
|
|
|
|
self.set_auth() # set_auth() requires cookie, so must be called after __init__() tests socket
|
|
self.set_backend(backend) # backend requires self.auth
|
|
|
|
if g.bob or g.alice:
|
|
from .regtest import MMGenRegtest
|
|
MMGenRegtest(g.coin).switch_user(('alice','bob')[g.bob],quiet=True)
|
|
|
|
self.cached = {}
|
|
(
|
|
self.cached['networkinfo'],
|
|
self.blockcount,
|
|
self.cached['blockchaininfo'],
|
|
block0
|
|
) = await self.gathered_call(None, (
|
|
('getnetworkinfo',()),
|
|
('getblockcount',()),
|
|
('getblockchaininfo',()),
|
|
('getblockhash',(0,)),
|
|
))
|
|
self.daemon_version = self.cached['networkinfo']['version']
|
|
g.chain = self.cached['blockchaininfo']['chain']
|
|
|
|
tip = await self.call('getblockhash',self.blockcount)
|
|
self.cur_date = (await self.call('getblockheader',tip))['time']
|
|
if g.chain != 'regtest':
|
|
g.chain += 'net'
|
|
assert g.chain in g.chains
|
|
check_chaintype_mismatch()
|
|
|
|
if g.chain == 'mainnet': # skip this for testnet, as Genesis block may change
|
|
await check_chainfork_mismatch(block0)
|
|
|
|
self.caps = ('full_node',)
|
|
for func,cap in (
|
|
('setlabel','label_api'),
|
|
('signrawtransactionwithkey','sign_with_key') ):
|
|
if len((await self.call('help',func)).split('\n')) > 3:
|
|
self.caps += (cap,)
|
|
|
|
# TODO: these belong in protocol.py
|
|
@classmethod
|
|
def get_daemon_auth_cookie_fn(cls):
|
|
cdir = os.path.join(
|
|
g.proto.daemon_data_dir,
|
|
g.proto.daemon_data_subdir )
|
|
return os.path.join(cdir,'.cookie')
|
|
|
|
@classmethod
|
|
def get_daemon_auth_cookie(cls):
|
|
fn = cls.get_daemon_auth_cookie_fn()
|
|
return get_lines_from_file(fn,'')[0] if file_is_readable(fn) else ''
|
|
|
|
rpcmethods = (
|
|
'backupwallet',
|
|
'createrawtransaction',
|
|
'decoderawtransaction',
|
|
'disconnectnode',
|
|
'estimatefee',
|
|
'estimatesmartfee',
|
|
'getaddressesbyaccount',
|
|
'getaddressesbylabel',
|
|
'getblock',
|
|
'getblockchaininfo',
|
|
'getblockcount',
|
|
'getblockhash',
|
|
'getblockheader',
|
|
'getblockstats', # mmgen-node-tools
|
|
'getmempoolinfo',
|
|
'getmempoolentry',
|
|
'getnettotals',
|
|
'getnetworkinfo',
|
|
'getpeerinfo',
|
|
'getrawmempool',
|
|
'getmempoolentry',
|
|
'getrawtransaction',
|
|
'gettransaction',
|
|
'importaddress',
|
|
'listaccounts',
|
|
'listlabels',
|
|
'listunspent',
|
|
'setlabel',
|
|
'sendrawtransaction',
|
|
'signrawtransaction',
|
|
'signrawtransactionwithkey', # method new to Core v0.17.0
|
|
'validateaddress',
|
|
'walletpassphrase',
|
|
)
|
|
|
|
class EthereumRPCClient(RPCClient,metaclass=aInitMeta):
|
|
|
|
auth_type = None
|
|
|
|
def __init__(self,*args,**kwargs): pass
|
|
|
|
async def __ainit__(self,backend=None):
|
|
|
|
super().__init__(
|
|
host = g.rpc_host or 'localhost',
|
|
port = g.rpc_port or g.proto.rpc_port )
|
|
|
|
self.set_backend(backend)
|
|
|
|
self.blockcount = int(await self.call('eth_blockNumber'),16)
|
|
|
|
vi,bh,ch,nk = await self.gathered_call(None, (
|
|
('parity_versionInfo',()),
|
|
('parity_getBlockHeaderByNumber',()),
|
|
('parity_chain',()),
|
|
('parity_nodeKind',()),
|
|
))
|
|
|
|
self.daemon_version = vi['version']
|
|
self.cur_date = int(bh['timestamp'],16)
|
|
g.chain = ch.replace(' ','_')
|
|
self.caps = ('full_node',) if nk['capability'] == 'full' else ()
|
|
|
|
try:
|
|
await self.call('eth_chainId')
|
|
self.caps += ('eth_chainId',)
|
|
except RPCFailure:
|
|
pass
|
|
|
|
rpcmethods = (
|
|
'eth_accounts',
|
|
'eth_blockNumber',
|
|
'eth_call',
|
|
# Returns the EIP155 chain ID used for transaction signing at the current best block.
|
|
# Null is returned if not available.
|
|
'eth_chainId',
|
|
'eth_gasPrice',
|
|
'eth_getBalance',
|
|
'eth_getBlockByHash',
|
|
'eth_getCode',
|
|
'eth_getTransactionByHash',
|
|
'eth_getTransactionReceipt',
|
|
'eth_protocolVersion',
|
|
'eth_sendRawTransaction',
|
|
'eth_signTransaction',
|
|
'eth_syncing',
|
|
'net_listening',
|
|
'net_peerCount',
|
|
'net_version',
|
|
'parity_chain',
|
|
'parity_chainId', # superseded by eth_chainId
|
|
'parity_chainStatus',
|
|
'parity_composeTransaction',
|
|
'parity_gasCeilTarget',
|
|
'parity_gasFloorTarget',
|
|
'parity_getBlockHeaderByNumber',
|
|
'parity_localTransactions',
|
|
'parity_minGasPrice',
|
|
'parity_mode',
|
|
'parity_netPeers',
|
|
'parity_nextNonce',
|
|
'parity_nodeKind',
|
|
'parity_nodeName',
|
|
'parity_pendingTransactions',
|
|
'parity_pendingTransactionsStats',
|
|
'parity_versionInfo',
|
|
)
|
|
|
|
class MoneroWalletRPCClient(RPCClient):
|
|
|
|
auth_type = 'digest'
|
|
url_fs = 'http://{}:{}/json_rpc'
|
|
|
|
def __init__(self,host,port,user,passwd):
|
|
super().__init__(host,port)
|
|
self.auth = auth_data(user,passwd)
|
|
self.set_backend('requests')
|
|
if False: # insecure, for debugging only
|
|
self.backend = RPCBackends.curl(self)
|
|
self.backend.exec_opts.remove('--silent')
|
|
self.backend.exec_opts.extend(['--insecure','--verbose'])
|
|
|
|
async def call(self,method,*params,**kwargs):
|
|
assert params == (), f'{type(self).__name__}.call() accepts keyword arguments only'
|
|
return await self.process_http_resp(self.backend.run(
|
|
payload = {'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': kwargs },
|
|
))
|
|
|
|
rpcmethods = (
|
|
'get_version',
|
|
'get_height', # sync height of the open wallet
|
|
'get_balance', # account_index=0, address_indices=[]
|
|
'create_wallet', # filename, password, language="English"
|
|
'open_wallet', # filename, password
|
|
'close_wallet',
|
|
'restore_deterministic_wallet', # name,password,seed (restore_height,language,seed_offset,autosave_current)
|
|
'refresh', # start_height
|
|
)
|
|
|
|
async def rpc_init(proto=None,backend=None):
|
|
|
|
proto = proto or g.proto
|
|
|
|
if not 'rpc' in proto.mmcaps:
|
|
die(1,'Coin daemon operations not supported for {}!'.format(proto.__name__))
|
|
|
|
g.rpc = await {
|
|
'bitcoind': BitcoinRPCClient,
|
|
'parity': EthereumRPCClient,
|
|
}[proto.daemon_family](backend=backend)
|
|
|
|
return g.rpc
|