Browse Source

modularize RPC classes

- protocol-independent base classes remain in `rpc.py`
- protocol-dependent subclasses are moved to `base_proto/{name}/rpc.py`
The MMGen Project 3 years ago
parent
commit
f1844789d7

+ 236 - 0
mmgen/base_proto/bitcoin/rpc.py

@@ -0,0 +1,236 @@
+#!/usr/bin/env python3
+#
+# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
+# Copyright (C)2013-2022 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen
+#   https://gitlab.com/mmgen/mmgen
+
+"""
+base_proto.bitcoin.rpc: Bitcoin base protocol RPC client class
+"""
+
+import os
+
+from ...globalvars import g
+from ...base_obj import AsyncInit
+from ...util import ymsg,vmsg,die
+from ...fileutil import get_lines_from_file
+from ...rpc import RPCClient
+
+class CallSigs:
+
+	class bitcoin_core:
+
+		@classmethod
+		def createwallet(cls,wallet_name,no_keys=True,blank=True,passphrase='',load_on_startup=True):
+			"""
+			Quirk: when --datadir is specified (even if standard), wallet is created directly in
+			datadir, otherwise in datadir/wallets
+			"""
+			return (
+				'createwallet',
+				wallet_name,    # 1. wallet_name
+				no_keys,        # 2. disable_private_keys
+				blank,          # 3. blank (no keys or seed)
+				passphrase,     # 4. passphrase (empty string for non-encrypted)
+				False,          # 5. avoid_reuse (track address reuse)
+				False,          # 6. descriptors (native descriptor wallet)
+				load_on_startup # 7. load_on_startup
+			)
+
+	class litecoin_core(bitcoin_core):
+
+		@classmethod
+		def createwallet(cls,wallet_name,no_keys=True,blank=True,passphrase='',load_on_startup=True):
+			return (
+				'createwallet',
+				wallet_name,    # 1. wallet_name
+				no_keys,        # 2. disable_private_keys
+				blank,          # 3. blank (no keys or seed)
+			)
+
+	class bitcoin_cash_node(litecoin_core):
+		pass
+
+class BitcoinRPCClient(RPCClient,metaclass=AsyncInit):
+
+	auth_type = 'basic'
+	has_auth_cookie = True
+
+	async def __init__(self,proto,daemon,backend):
+
+		self.proto = proto
+		self.daemon = daemon
+		self.call_sigs = getattr(CallSigs,daemon.id,None)
+
+		super().__init__(
+			host = 'localhost' if g.test_suite else (g.rpc_host or 'localhost'),
+			port = daemon.rpc_port )
+
+		self.set_auth() # set_auth() requires cookie, so must be called after __init__() tests daemon is listening
+		self.set_backend(backend) # backend requires self.auth
+
+		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']
+		self.daemon_version_str = self.cached['networkinfo']['subversion']
+		self.chain = self.cached['blockchaininfo']['chain']
+
+		tip = await self.call('getblockhash',self.blockcount)
+		self.cur_date = (await self.call('getblockheader',tip))['time']
+		if self.chain != 'regtest':
+			self.chain += 'net'
+		assert self.chain in self.proto.networks
+
+		async def check_chainfork_mismatch(block0):
+			try:
+				if block0 != self.proto.block0:
+					raise ValueError(f'Invalid Genesis block for {self.proto.cls_name} protocol')
+				for fork in self.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,'{!s}\n{c!r} requested, but this is not the {c} chain!'.format(e,c=self.proto.coin))
+
+		if self.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,)
+
+		if not self.chain == 'regtest':
+			await self.check_tracking_wallet()
+
+	async def check_tracking_wallet(self,wallet_checked=[]):
+		if not wallet_checked:
+			wallets = await self.call('listwallets')
+			if len(wallets) == 0:
+				wname = self.daemon.tracking_wallet_name
+				await self.icall('createwallet',wallet_name=wname)
+				ymsg(f'Created {self.daemon.coind_name} wallet {wname!r}')
+			elif len(wallets) > 1: # support only one loaded wallet for now
+				die(4,f'ERROR: more than one {self.daemon.coind_name} wallet loaded: {wallets}')
+			wallet_checked.append(True)
+
+	def get_daemon_cfg_fn(self):
+		# Use dirname() to remove 'bob' or 'alice' component
+		return os.path.join(
+			(os.path.dirname(g.data_dir) if self.proto.regtest else self.daemon.datadir),
+			self.daemon.cfg_file )
+
+	def get_daemon_auth_cookie_fn(self):
+		return os.path.join(self.daemon.network_datadir,'.cookie')
+
+	def get_daemon_cfg_options(self,req_keys):
+
+		fn = self.get_daemon_cfg_fn()
+		from ...opts import opt
+		try:
+			lines = get_lines_from_file(fn,'daemon config file',silent=not opt.verbose)
+		except:
+			vmsg(f'Warning: {fn!r} does not exist or is unreadable')
+			return dict((k,None) for k in req_keys)
+
+		def gen():
+			for key in req_keys:
+				val = None
+				for l in lines:
+					if l.startswith(key):
+						res = l.split('=',1)
+						if len(res) == 2 and not ' ' in res[1].strip():
+							val = res[1].strip()
+				yield (key,val)
+
+		return dict(gen())
+
+	def get_daemon_auth_cookie(self):
+		fn = self.get_daemon_auth_cookie_fn()
+		return get_lines_from_file(fn,'cookie',quiet=True)[0] if os.access(fn,os.R_OK) else ''
+
+	@staticmethod
+	def make_host_path(wallet):
+		return (
+			'/wallet/{}'.format('bob' if g.bob else 'alice') if (g.bob or g.alice) else
+			'/wallet/{}'.format(wallet) if wallet else '/'
+		)
+
+	def info(self,info_id):
+
+		def segwit_is_active():
+			d = self.cached['blockchaininfo']
+			if d['chain'] == 'regtest':
+				return True
+
+			try:
+				if d['softforks']['segwit']['active'] == True:
+					return True
+			except:
+				pass
+
+			try:
+				if d['bip9_softforks']['segwit']['status'] == 'active':
+					return True
+			except:
+				pass
+
+			if g.test_suite:
+				return True
+
+			return False
+
+		return locals()[info_id]()
+
+	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',
+	)

+ 100 - 0
mmgen/base_proto/ethereum/rpc.py

@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+#
+# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
+# Copyright (C)2013-2022 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen
+#   https://gitlab.com/mmgen/mmgen
+
+"""
+base_proto.ethereum.rpc: Ethereum base protocol RPC client class
+"""
+
+import re
+
+from ...globalvars import g
+from ...base_obj import AsyncInit
+from ...obj import Int
+from ...util import die,oneshot_warning_group
+from ...rpc import RPCClient
+
+class daemon_warning(oneshot_warning_group):
+
+	class geth:
+		color = 'yellow'
+		message = 'Geth has not been tested on mainnet.  You may experience problems.'
+
+	class erigon:
+		color = 'red'
+		message = 'Erigon support is EXPERIMENTAL.  Use at your own risk!!!'
+
+class CallSigs:
+	pass
+
+class EthereumRPCClient(RPCClient,metaclass=AsyncInit):
+
+	async def __init__(self,proto,daemon,backend):
+		self.proto = proto
+		self.daemon = daemon
+		self.call_sigs = getattr(CallSigs,daemon.id,None)
+
+		super().__init__(
+			host = 'localhost' if g.test_suite else (g.rpc_host or 'localhost'),
+			port = daemon.rpc_port )
+
+		self.set_backend(backend)
+
+		vi,bh,ci = await self.gathered_call(None, (
+				('web3_clientVersion',()),
+				('eth_getBlockByNumber',('latest',False)),
+				('eth_chainId',()),
+			))
+
+		vip = re.match(self.daemon.version_pat,vi,re.ASCII)
+		if not vip:
+			die(2,fmt(f"""
+			Aborting on daemon mismatch:
+			  Requested daemon: {self.daemon.id}
+			  Running daemon:   {vi}
+			""",strip_char='\t').rstrip())
+
+		self.daemon_version = int('{:d}{:03d}{:03d}'.format(*[int(e) for e in vip.groups()]))
+		self.daemon_version_str = '{}.{}.{}'.format(*vip.groups())
+		self.daemon_version_info = vi
+
+		self.blockcount = int(bh['number'],16)
+		self.cur_date = int(bh['timestamp'],16)
+
+		self.caps = ()
+		if self.daemon.id in ('parity','openethereum'):
+			if (await self.call('parity_nodeKind'))['capability'] == 'full':
+				self.caps += ('full_node',)
+			self.chainID = None if ci == None else Int(ci,16) # parity/oe return chainID only for dev chain
+			self.chain = (await self.call('parity_chain')).replace(' ','_').replace('_testnet','')
+		elif self.daemon.id in ('geth','erigon'):
+			if self.daemon.network == 'mainnet':
+				daemon_warning(self.daemon.id)
+			self.caps += ('full_node',)
+			self.chainID = Int(ci,16)
+			self.chain = self.proto.chain_ids[self.chainID]
+
+	rpcmethods = (
+		'eth_blockNumber',
+		'eth_call',
+		# Returns the EIP155 chain ID used for transaction signing at the current best block.
+		# Parity: Null is returned if not available, ID not required in transactions
+		# Erigon: always returns ID, requires ID in transactions
+		'eth_chainId',
+		'eth_gasPrice',
+		'eth_getBalance',
+		'eth_getCode',
+		'eth_getTransactionCount',
+		'eth_getTransactionReceipt',
+		'eth_sendRawTransaction',
+		'parity_chain',
+		'parity_nodeKind',
+		'parity_pendingTransactions',
+		'txpool_content',
+	)

+ 2 - 2
mmgen/base_proto/monero/daemon.py

@@ -44,7 +44,7 @@ class monero_daemon(CoinDaemon):
 
 	def init_subclass(self):
 
-		from ...rpc import MoneroRPCClientRaw
+		from .rpc import MoneroRPCClientRaw
 		self.rpc = MoneroRPCClientRaw(
 			host   = self.host,
 			port   = self.rpc_port,
@@ -139,5 +139,5 @@ class MoneroWalletDaemon(RPCDaemon):
 			['--stagenet',                           self.network == 'testnet'],
 		)
 
-		from ...rpc import MoneroWalletRPCClient
+		from .rpc import MoneroWalletRPCClient
 		self.rpc = MoneroWalletRPCClient( daemon=self, test_connection=False )

+ 112 - 0
mmgen/base_proto/monero/rpc.py

@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+#
+# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
+# Copyright (C)2013-2022 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen
+#   https://gitlab.com/mmgen/mmgen
+
+"""
+base_proto.monero.rpc: Monero base protocol RPC client class
+"""
+
+from ...rpc import RPCClient,IPPort,auth_data
+
+class MoneroRPCClient(RPCClient):
+
+	auth_type = None
+	network_proto = 'https'
+	host_path = '/json_rpc'
+	verify_server = False
+
+	def __init__(self,host,port,user,passwd,test_connection=True,proxy=None,daemon=None):
+
+		if proxy is not None:
+			self.proxy = IPPort(proxy)
+			test_connection = False
+			if host.endswith('.onion'):
+				self.network_proto = 'http'
+
+		super().__init__(host,port,test_connection)
+
+		if self.auth_type:
+			self.auth = auth_data(user,passwd)
+
+		if True:
+			self.set_backend('requests')
+
+		else: # insecure, for debugging only
+			self.set_backend('curl')
+			self.backend.exec_opts.remove('--silent')
+			self.backend.exec_opts.append('--verbose')
+
+		self.daemon = daemon
+
+	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 },
+			timeout = 3600, # allow enough time to sync ≈1,000,000 blocks
+			wallet = None
+		))
+
+	rpcmethods = ( 'get_info', )
+
+class MoneroRPCClientRaw(MoneroRPCClient):
+
+	json_rpc = False
+	host_path = '/'
+
+	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 = kwargs,
+			timeout = self.timeout,
+			wallet = method
+		))
+
+	@staticmethod
+	def make_host_path(arg):
+		return arg
+
+	async def do_stop_daemon(self,silent=False):
+		return await self.call('stop_daemon')
+
+	rpcmethods = ( 'get_height', 'send_raw_transaction', 'stop_daemon' )
+
+class MoneroWalletRPCClient(MoneroRPCClient):
+
+	auth_type = 'digest'
+
+	def __init__(self,daemon,test_connection=True):
+
+		RPCClient.__init__(
+			self,
+			daemon.host,
+			daemon.rpc_port,
+			test_connection = test_connection )
+
+		self.daemon = daemon
+		self.auth = auth_data(daemon.user,daemon.passwd)
+		self.set_backend('requests')
+
+	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',
+		# filename,password,seed (restore_height,language,seed_offset,autosave_current)
+		'restore_deterministic_wallet',
+		'refresh',       # start_height
+	)
+
+	async def do_stop_daemon(self,silent=False):
+		"""
+		NB: the 'stop_wallet' RPC call closes the open wallet before shutting down the daemon,
+		returning an error if no wallet is open
+		"""
+		return await self.call('stop_wallet')

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-13.1.dev015
+13.1.dev016

+ 14 - 398
mmgen/rpc.py

@@ -20,12 +20,14 @@
 rpc.py:  Cryptocoin RPC library for the MMGen suite
 """
 
-import base64,json,asyncio
+import base64,json,asyncio,importlib
 from decimal import Decimal
+from collections import namedtuple
+
 from .common import *
-from .fileutil import get_lines_from_file
 from .objmethods import Hilite,InitErrors
-from .base_obj import AsyncInit
+
+auth_data = namedtuple('rpc_auth_data',['user','passwd'])
 
 rpc_credentials_msg = '\n'+fmt("""
 	Error: no {proto_name} RPC authentication method found
@@ -243,47 +245,6 @@ class RPCBackends:
 			# 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 CallSigs:
-
-	class Bitcoin:
-
-		class bitcoin_core:
-
-			@classmethod
-			def createwallet(cls,wallet_name,no_keys=True,blank=True,passphrase='',load_on_startup=True):
-				"""
-				Quirk: when --datadir is specified (even if standard), wallet is created directly in
-				datadir, otherwise in datadir/wallets
-				"""
-				return (
-					'createwallet',
-					wallet_name,    # 1. wallet_name
-					no_keys,        # 2. disable_private_keys
-					blank,          # 3. blank (no keys or seed)
-					passphrase,     # 4. passphrase (empty string for non-encrypted)
-					False,          # 5. avoid_reuse (track address reuse)
-					False,          # 6. descriptors (native descriptor wallet)
-					load_on_startup # 7. load_on_startup
-				)
-
-		class litecoin_core(bitcoin_core):
-
-			@classmethod
-			def createwallet(cls,wallet_name,no_keys=True,blank=True,passphrase='',load_on_startup=True):
-				return (
-					'createwallet',
-					wallet_name,    # 1. wallet_name
-					no_keys,        # 2. disable_private_keys
-					blank,          # 3. blank (no keys or seed)
-				)
-
-		class bitcoin_cash_node(litecoin_core): pass
-
-	class Ethereum: pass
-
 class RPCClient(MMGenObject):
 
 	json_rpc = True
@@ -463,361 +424,14 @@ class RPCClient(MMGenObject):
 		await self.stop_daemon(quiet=quiet,silent=silent)
 		return self.daemon.start(silent=silent)
 
-class BitcoinRPCClient(RPCClient,metaclass=AsyncInit):
-
-	auth_type = 'basic'
-	has_auth_cookie = True
-
-	async def __init__(self,proto,daemon,backend):
-
-		self.proto = proto
-		self.daemon = daemon
-		self.call_sigs = getattr(getattr(CallSigs,proto.base_proto),daemon.id,None)
-
-		super().__init__(
-			host = 'localhost' if g.test_suite else (g.rpc_host or 'localhost'),
-			port = daemon.rpc_port )
-
-		self.set_auth() # set_auth() requires cookie, so must be called after __init__() tests daemon is listening
-		self.set_backend(backend) # backend requires self.auth
-
-		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']
-		self.daemon_version_str = self.cached['networkinfo']['subversion']
-		self.chain = self.cached['blockchaininfo']['chain']
-
-		tip = await self.call('getblockhash',self.blockcount)
-		self.cur_date = (await self.call('getblockheader',tip))['time']
-		if self.chain != 'regtest':
-			self.chain += 'net'
-		assert self.chain in self.proto.networks
-
-		async def check_chainfork_mismatch(block0):
-			try:
-				if block0 != self.proto.block0:
-					raise ValueError(f'Invalid Genesis block for {self.proto.cls_name} protocol')
-				for fork in self.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,'{!s}\n{c!r} requested, but this is not the {c} chain!'.format(e,c=self.proto.coin))
-
-		if self.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,)
-
-		if not self.chain == 'regtest':
-			await self.check_tracking_wallet()
-
-	async def check_tracking_wallet(self,wallet_checked=[]):
-		if not wallet_checked:
-			wallets = await self.call('listwallets')
-			if len(wallets) == 0:
-				wname = self.daemon.tracking_wallet_name
-				await self.icall('createwallet',wallet_name=wname)
-				ymsg(f'Created {self.daemon.coind_name} wallet {wname!r}')
-			elif len(wallets) > 1: # support only one loaded wallet for now
-				die(4,f'ERROR: more than one {self.daemon.coind_name} wallet loaded: {wallets}')
-			wallet_checked.append(True)
-
-	def get_daemon_cfg_fn(self):
-		# Use dirname() to remove 'bob' or 'alice' component
-		return os.path.join(
-			(os.path.dirname(g.data_dir) if self.proto.regtest else self.daemon.datadir),
-			self.daemon.cfg_file )
-
-	def get_daemon_auth_cookie_fn(self):
-		return os.path.join(self.daemon.network_datadir,'.cookie')
-
-	def get_daemon_cfg_options(self,req_keys):
-
-		fn = self.get_daemon_cfg_fn()
-		try:
-			lines = get_lines_from_file(fn,'daemon config file',silent=not opt.verbose)
-		except:
-			vmsg(f'Warning: {fn!r} does not exist or is unreadable')
-			return dict((k,None) for k in req_keys)
-
-		def gen():
-			for key in req_keys:
-				val = None
-				for l in lines:
-					if l.startswith(key):
-						res = l.split('=',1)
-						if len(res) == 2 and not ' ' in res[1].strip():
-							val = res[1].strip()
-				yield (key,val)
-
-		return dict(gen())
-
-	def get_daemon_auth_cookie(self):
-		fn = self.get_daemon_auth_cookie_fn()
-		return get_lines_from_file(fn,'cookie',quiet=True)[0] if os.access(fn,os.R_OK) else ''
-
-	@staticmethod
-	def make_host_path(wallet):
-		return (
-			'/wallet/{}'.format('bob' if g.bob else 'alice') if (g.bob or g.alice) else
-			'/wallet/{}'.format(wallet) if wallet else '/'
-		)
-
-	def info(self,info_id):
-
-		def segwit_is_active():
-			d = self.cached['blockchaininfo']
-			if d['chain'] == 'regtest':
-				return True
-
-			try:
-				if d['softforks']['segwit']['active'] == True:
-					return True
-			except:
-				pass
-
-			try:
-				if d['bip9_softforks']['segwit']['status'] == 'active':
-					return True
-			except:
-				pass
-
-			if g.test_suite:
-				return True
-
-			return False
-
-		return locals()[info_id]()
-
-	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=AsyncInit):
-
-	async def __init__(self,proto,daemon,backend):
-		self.proto = proto
-		self.daemon = daemon
-		self.call_sigs = getattr(getattr(CallSigs,proto.base_proto),daemon.id,None)
-
-		super().__init__(
-			host = 'localhost' if g.test_suite else (g.rpc_host or 'localhost'),
-			port = daemon.rpc_port )
-
-		self.set_backend(backend)
-
-		vi,bh,ci = await self.gathered_call(None, (
-				('web3_clientVersion',()),
-				('eth_getBlockByNumber',('latest',False)),
-				('eth_chainId',()),
-			))
-
-		import re
-		vip = re.match(self.daemon.version_pat,vi,re.ASCII)
-		if not vip:
-			die(2,fmt(f"""
-			Aborting on daemon mismatch:
-			  Requested daemon: {self.daemon.id}
-			  Running daemon:   {vi}
-			""",strip_char='\t').rstrip())
-
-		self.daemon_version = int('{:d}{:03d}{:03d}'.format(*[int(e) for e in vip.groups()]))
-		self.daemon_version_str = '{}.{}.{}'.format(*vip.groups())
-		self.daemon_version_info = vi
-
-		self.blockcount = int(bh['number'],16)
-		self.cur_date = int(bh['timestamp'],16)
-
-		self.caps = ()
-		from .obj import Int
-		if self.daemon.id in ('parity','openethereum'):
-			if (await self.call('parity_nodeKind'))['capability'] == 'full':
-				self.caps += ('full_node',)
-			self.chainID = None if ci == None else Int(ci,16) # parity/oe return chainID only for dev chain
-			self.chain = (await self.call('parity_chain')).replace(' ','_').replace('_testnet','')
-		elif self.daemon.id in ('geth','erigon'):
-			if self.daemon.network == 'mainnet':
-				daemon_warning(self.daemon.id)
-			self.caps += ('full_node',)
-			self.chainID = Int(ci,16)
-			self.chain = self.proto.chain_ids[self.chainID]
-
-	rpcmethods = (
-		'eth_blockNumber',
-		'eth_call',
-		# Returns the EIP155 chain ID used for transaction signing at the current best block.
-		# Parity: Null is returned if not available, ID not required in transactions
-		# Erigon: always returns ID, requires ID in transactions
-		'eth_chainId',
-		'eth_gasPrice',
-		'eth_getBalance',
-		'eth_getCode',
-		'eth_getTransactionCount',
-		'eth_getTransactionReceipt',
-		'eth_sendRawTransaction',
-		'parity_chain',
-		'parity_nodeKind',
-		'parity_pendingTransactions',
-		'txpool_content',
-	)
-
-class MoneroRPCClient(RPCClient):
-
-	auth_type = None
-	network_proto = 'https'
-	host_path = '/json_rpc'
-	verify_server = False
-
-	def __init__(self,host,port,user,passwd,test_connection=True,proxy=None,daemon=None):
-		if proxy is not None:
-			self.proxy = IPPort(proxy)
-			test_connection = False
-			if host.endswith('.onion'):
-				self.network_proto = 'http'
-		super().__init__(host,port,test_connection)
-		if self.auth_type:
-			self.auth = auth_data(user,passwd)
-		if True:
-			self.set_backend('requests')
-		else: # insecure, for debugging only
-			self.set_backend('curl')
-			self.backend.exec_opts.remove('--silent')
-			self.backend.exec_opts.append('--verbose')
-		self.daemon = daemon
-
-	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 },
-			timeout = 3600, # allow enough time to sync ≈1,000,000 blocks
-			wallet = None
-		))
-
-	rpcmethods = ( 'get_info', )
-
-class MoneroRPCClientRaw(MoneroRPCClient):
-
-	json_rpc = False
-	host_path = '/'
-
-	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 = kwargs,
-			timeout = self.timeout,
-			wallet = method
-		))
-
-	@staticmethod
-	def make_host_path(arg):
-		return arg
-
-	async def do_stop_daemon(self,silent=False):
-		return await self.call('stop_daemon')
-
-	rpcmethods = ( 'get_height', 'send_raw_transaction', 'stop_daemon' )
-
-class MoneroWalletRPCClient(MoneroRPCClient):
-
-	auth_type = 'digest'
-
-	def __init__(self,daemon,test_connection=True):
-
-		RPCClient.__init__(
-			self,
-			daemon.host,
-			daemon.rpc_port,
-			test_connection = test_connection )
-
-		self.daemon = daemon
-		self.auth = auth_data(daemon.user,daemon.passwd)
-		self.set_backend('requests')
-
-	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',
-		# filename,password,seed (restore_height,language,seed_offset,autosave_current)
-		'restore_deterministic_wallet',
-		'refresh',       # start_height
-	)
-
-	async def do_stop_daemon(self,silent=False):
-		"""
-		NB: the 'stop_wallet' RPC call closes the open wallet before shutting down the daemon,
-		returning an error if no wallet is open
-		"""
-		return await self.call('stop_wallet')
-
-class daemon_warning(oneshot_warning_group):
-
-	class geth:
-		color = 'yellow'
-		message = 'Geth has not been tested on mainnet.  You may experience problems.'
-
-	class erigon:
-		color = 'red'
-		message = 'Erigon support is EXPERIMENTAL.  Use at your own risk!!!'
+def handle_unsupported_daemon_version(rpc,name,warn_only):
 
-	class version:
+	class daemon_version_warning(oneshot_warning):
 		color = 'yellow'
 		message = 'ignoring unsupported {} daemon version at user request'
 
-def handle_unsupported_daemon_version(rpc,name,warn_only):
 	if warn_only:
-		daemon_warning('version',div=name,fmt_args=[rpc.daemon.coind_name])
+		daemon_version_warning(div=name,fmt_args=[rpc.daemon.coind_name])
 	else:
 		name = rpc.daemon.coind_name
 		die(2,'\n'+fmt(f"""
@@ -835,11 +449,13 @@ async def rpc_init(proto,backend=None,daemon=None,ignore_daemon_version=False):
 	if not 'rpc' in proto.mmcaps:
 		die(1,f'Coin daemon operations not supported for {proto.name} protocol!')
 
+
+	cls = getattr(
+		importlib.import_module(f'mmgen.base_proto.{proto.base_proto.lower()}.rpc'),
+			proto.base_proto + 'RPCClient' )
+
 	from .daemon import CoinDaemon
-	rpc = await {
-		'Bitcoin': BitcoinRPCClient,
-		'Ethereum': EthereumRPCClient,
-	}[proto.base_proto](
+	rpc = await cls(
 		proto   = proto,
 		daemon  = daemon or CoinDaemon(proto=proto,test_suite=g.test_suite),
 		backend = backend or opt.rpc_backend )

+ 2 - 1
mmgen/xmrwallet.py

@@ -30,7 +30,8 @@ from .protocol import init_proto
 from .proto.common import b58a
 from .addr import CoinAddr,AddrIdx
 from .addrlist import KeyAddrList,AddrIdxList
-from .rpc import MoneroRPCClientRaw,MoneroWalletRPCClient,json_encoder
+from .rpc import json_encoder
+from .base_proto.monero.rpc import MoneroRPCClientRaw,MoneroWalletRPCClient
 from .base_proto.monero.daemon import MoneroWalletDaemon
 
 xmrwallet_uarg_info = (

+ 1 - 1
test/test_py_d/ts_xmrwallet.py

@@ -185,7 +185,7 @@ class TestSuiteXMRWallet(TestSuiteBase):
 	def init_users(self):
 		from mmgen.daemon import CoinDaemon
 		from mmgen.base_proto.monero.daemon import MoneroWalletDaemon
-		from mmgen.rpc import MoneroRPCClient,MoneroRPCClientRaw,MoneroWalletRPCClient
+		from mmgen.base_proto.monero.rpc import MoneroRPCClient,MoneroRPCClientRaw,MoneroWalletRPCClient
 		self.users = {}
 		n = self.tmpdir_nums[0]
 		ud = namedtuple('user_data',[

+ 2 - 1
test/unit_tests_d/ut_rpc.py

@@ -6,8 +6,9 @@ test.unit_tests_d.ut_rpc: RPC unit test for the MMGen suite
 from mmgen.common import *
 
 from mmgen.protocol import init_proto
-from mmgen.rpc import rpc_init,MoneroWalletRPCClient
+from mmgen.rpc import rpc_init
 from mmgen.daemon import CoinDaemon
+from mmgen.base_proto.monero.rpc import MoneroWalletRPCClient
 from mmgen.base_proto.monero.daemon import MoneroWalletDaemon
 
 def cfg_file_auth_test(proto,d):