Browse Source

modularize RPC library

The MMGen Project 8 months ago
parent
commit
318b3351e8

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-15.1.dev38
+15.1.dev39

+ 1 - 1
mmgen/help/help_notes.py

@@ -48,7 +48,7 @@ class help_notes:
 			+ (f',{linebreak}respectively' if len(cu) > 1 else ''))
 			+ (f',{linebreak}respectively' if len(cu) > 1 else ''))
 
 
 	def dfl_twname(self):
 	def dfl_twname(self):
-		from ..proto.btc.rpc import BitcoinRPCClient
+		from ..proto.btc.rpc.local import BitcoinRPCClient
 		return BitcoinRPCClient.dfl_twname
 		return BitcoinRPCClient.dfl_twname
 
 
 	def MasterShareIdx(self):
 	def MasterShareIdx(self):

+ 2 - 1
mmgen/main_cli.py

@@ -16,7 +16,8 @@ import asyncio, json
 
 
 from .util2 import cliargs_convert
 from .util2 import cliargs_convert
 from .cfg import gc, Config
 from .cfg import gc, Config
-from .rpc import rpc_init, json_encoder
+from .rpc import rpc_init
+from .rpc.util import json_encoder
 
 
 opts_data = {
 opts_data = {
 	'text': {
 	'text': {

+ 2 - 1
mmgen/proto/btc/regtest.py

@@ -24,7 +24,8 @@ import os, shutil, json
 from ...util import msg, gmsg, die, capfirst, suf
 from ...util import msg, gmsg, die, capfirst, suf
 from ...util2 import cliargs_convert
 from ...util2 import cliargs_convert
 from ...protocol import init_proto
 from ...protocol import init_proto
-from ...rpc import rpc_init, json_encoder
+from ...rpc import rpc_init
+from ...rpc.util import json_encoder
 from ...objmethods import MMGenObject
 from ...objmethods import MMGenObject
 from ...daemon import CoinDaemon
 from ...daemon import CoinDaemon
 
 

+ 8 - 7
mmgen/proto/btc/rpc.py → mmgen/proto/btc/rpc/local.py

@@ -9,16 +9,17 @@
 #   https://gitlab.com/mmgen/mmgen-wallet
 #   https://gitlab.com/mmgen/mmgen-wallet
 
 
 """
 """
-proto.btc.rpc: Bitcoin base protocol RPC client class
+proto.btc.rpc.local: Bitcoin base protocol local RPC client for the MMGen Project
 """
 """
 
 
 import os
 import os
 
 
-from ...base_obj import AsyncInit
-from ...obj import TrackingWalletName
-from ...util import ymsg, die, fmt
-from ...fileutil import get_lines_from_file
-from ...rpc import RPCClient, auth_data
+from ....base_obj import AsyncInit
+from ....obj import TrackingWalletName
+from ....util import ymsg, die, fmt
+from ....fileutil import get_lines_from_file
+from ....rpc.local import RPCClient
+from ....rpc.util import auth_data
 
 
 no_credentials_errmsg = """
 no_credentials_errmsg = """
 	Error: no {proto_name} RPC authentication method found
 	Error: no {proto_name} RPC authentication method found
@@ -212,7 +213,7 @@ class BitcoinRPCClient(RPCClient, metaclass=AsyncInit):
 		MMGen's credentials override coin daemon's
 		MMGen's credentials override coin daemon's
 		"""
 		"""
 		if self.cfg.network == 'regtest':
 		if self.cfg.network == 'regtest':
-			from .regtest import MMGenRegtest
+			from ..regtest import MMGenRegtest
 			user = MMGenRegtest.rpc_user
 			user = MMGenRegtest.rpc_user
 			passwd = MMGenRegtest.rpc_password
 			passwd = MMGenRegtest.rpc_password
 		else:
 		else:

+ 1 - 1
mmgen/proto/btc/tw/txhistory.py

@@ -307,7 +307,7 @@ class BitcoinTwTxHistory(TwTxHistory, BitcoinTwRPC):
 
 
 		if self.cfg.debug_tw:
 		if self.cfg.debug_tw:
 			import json
 			import json
-			from ....rpc import json_encoder
+			from ....rpc.util import json_encoder
 			def do_json_dump(*data):
 			def do_json_dump(*data):
 				nw = f'{self.proto.coin.lower()}-{self.proto.network}'
 				nw = f'{self.proto.coin.lower()}-{self.proto.network}'
 				for d, fn_stem in data:
 				for d, fn_stem in data:

+ 5 - 5
mmgen/proto/eth/rpc.py → mmgen/proto/eth/rpc/local.py

@@ -9,15 +9,15 @@
 #   https://gitlab.com/mmgen/mmgen-wallet
 #   https://gitlab.com/mmgen/mmgen-wallet
 
 
 """
 """
-proto.eth.rpc: Ethereum base protocol RPC client class
+proto.eth.rpc.local: Ethereum base protocol local RPC client for the MMGen Project
 """
 """
 
 
 import re
 import re
 
 
-from ...base_obj import AsyncInit
-from ...obj import Int
-from ...util import die, fmt, oneshot_warning_group
-from ...rpc import RPCClient
+from ....base_obj import AsyncInit
+from ....obj import Int
+from ....util import die, fmt, oneshot_warning_group
+from ....rpc.local import RPCClient
 
 
 class daemon_warning(oneshot_warning_group):
 class daemon_warning(oneshot_warning_group):
 
 

+ 3 - 1
mmgen/proto/xmr/rpc.py

@@ -13,7 +13,9 @@ proto.xmr.rpc: Monero base protocol RPC client class
 """
 """
 
 
 import re
 import re
-from ...rpc import RPCClient, IPPort, auth_data
+
+from ...rpc.local import RPCClient
+from ...rpc.util import IPPort, auth_data
 
 
 class MoneroRPCClient(RPCClient):
 class MoneroRPCClient(RPCClient):
 
 

+ 0 - 507
mmgen/rpc.py

@@ -1,507 +0,0 @@
-#!/usr/bin/env python3
-#
-# MMGen Wallet, a terminal-based cryptocurrency wallet
-# Copyright (C)2013-2025 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: Cryptocoin RPC library for the MMGen suite
-"""
-
-import sys, re, base64, json, asyncio, importlib
-from collections import namedtuple
-
-from .util import msg, ymsg, die, fmt, fmt_list, pp_fmt, oneshot_warning
-from .base_obj import AsyncInit
-from .obj import NonNegativeInt
-from .objmethods import HiliteStr, InitErrors, MMGenObject
-
-auth_data = namedtuple('rpc_auth_data', ['user', 'passwd'])
-
-def dmsg_rpc(fs, data=None, *, is_json=False):
-	msg(
-		fs if data is None else
-		fs.format(pp_fmt(json.loads(data) if is_json else data))
-	)
-
-def dmsg_rpc_backend(host_url, host_path, payload):
-	msg(
-		f'\n    RPC URL: {host_url}{host_path}' +
-		'\n    RPC PAYLOAD data (httplib) ==>' +
-		f'\n{pp_fmt(payload)}\n')
-
-def noop(*args, **kwargs):
-	pass
-
-class IPPort(HiliteStr, InitErrors):
-	color = 'yellow'
-	width = 0
-	trunc_ok = False
-	min_len = 9  # 0.0.0.0:0
-	max_len = 21 # 255.255.255.255:65535
-	def __new__(cls, s):
-		if isinstance(s, cls):
-			return s
-		try:
-			m = re.fullmatch(r'{q}\.{q}\.{q}\.{q}:(\d{{1,10}})'.format(q=r'([0-9]{1,3})'), s)
-			assert m is not None, f'{s!r}: invalid IP:HOST specifier'
-			for e in m.groups():
-				if len(e) != 1 and e[0] == '0':
-					raise ValueError(f'{e}: leading zeroes not permitted in dotted decimal element or port number')
-			res = [int(e) for e in m.groups()]
-			for e in res[:4]:
-				assert e <= 255, f'{e}: dotted decimal element > 255'
-			assert res[4] <= 65535, f'{res[4]}: port number > 65535'
-			me = str.__new__(cls, s)
-			me.ip = '{}.{}.{}.{}'.format(*res)
-			me.ip_num = sum(res[i] * (2 ** (-(i-3)*8)) for i in range(4))
-			me.port = res[4]
-			return me
-		except Exception as e:
-			return cls.init_fail(e, s)
-
-class json_encoder(json.JSONEncoder):
-	def default(self, o):
-		if type(o).__name__.endswith('Amt'):
-			return str(o)
-		else:
-			return json.JSONEncoder.default(self, o)
-
-class RPCBackends:
-
-	class base:
-
-		def __init__(self, caller):
-			self.cfg            = caller.cfg
-			self.host           = caller.host
-			self.port           = caller.port
-			self.proxy          = caller.proxy
-			self.host_url       = caller.host_url
-			self.timeout        = caller.timeout
-			self.http_hdrs      = caller.http_hdrs
-			self.name           = type(self).__name__
-			self.caller         = caller
-
-	class aiohttp(base, metaclass=AsyncInit):
-		"""
-		Contrary to the requests library, aiohttp won’t read environment variables by
-		default.  But you can do so by passing trust_env=True into aiohttp.ClientSession
-		constructor to honor HTTP_PROXY, HTTPS_PROXY, WS_PROXY or WSS_PROXY environment
-		variables (all are case insensitive).
-		"""
-
-		def __del__(self):
-			self.connector.close()
-			self.session.detach()
-			del self.session
-
-		async def __init__(self, caller):
-			super().__init__(caller)
-			import aiohttp
-			self.connector = aiohttp.TCPConnector(limit_per_host=self.cfg.aiohttp_rpc_queue_len)
-			self.session = aiohttp.ClientSession(
-				headers = {'Content-Type': 'application/json'},
-				connector = self.connector,
-			)
-			if caller.auth_type == 'basic':
-				self.auth = aiohttp.BasicAuth(*caller.auth, encoding='UTF-8')
-			else:
-				self.auth = None
-
-		async def run(self, payload, timeout, host_path):
-			dmsg_rpc_backend(self.host_url, host_path, payload)
-			async with self.session.post(
-				url     = self.host_url + host_path,
-				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(base):
-
-		def __del__(self):
-			self.session.close()
-
-		def __init__(self, caller):
-			super().__init__(caller)
-			import requests, urllib3
-			urllib3.disable_warnings()
-			self.session = requests.Session()
-			self.session.trust_env = False # ignore *_PROXY environment vars
-			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)
-			if self.proxy: # used only by XMR for now: requires pysocks package
-				self.session.proxies.update({
-					'http':  f'socks5h://{self.proxy}',
-					'https': f'socks5h://{self.proxy}'
-				})
-
-		async def run(self, *args, **kwargs):
-			return self.run_noasync(*args, **kwargs)
-
-		def run_noasync(self, payload, timeout, host_path):
-			dmsg_rpc_backend(self.host_url, host_path, payload)
-			res = self.session.post(
-				url     = self.host_url + host_path,
-				data    = json.dumps(payload, cls=json_encoder),
-				timeout = timeout or self.timeout,
-				verify  = False)
-			return (res.content, res.status_code)
-
-	class httplib(base):
-		"""
-		Ignores *_PROXY environment vars
-		"""
-		def __del__(self):
-			self.session.close()
-
-		def __init__(self, caller):
-			super().__init__(caller)
-			import http.client
-			self.session = http.client.HTTPConnection(caller.host, caller.port, caller.timeout)
-			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})
-				dmsg_rpc(f'    RPC AUTHORIZATION data ==> raw: [{auth_str}]\n{"":>31}enc: [{auth_str_b64}]\n')
-
-		async def run(self, payload, timeout, host_path):
-			dmsg_rpc_backend(self.host_url, host_path, 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     = host_path,
-					body    = json.dumps(payload, cls=json_encoder),
-					headers = self.http_hdrs)
-				r = s.getresponse() # => http.client.HTTPResponse instance
-			except Exception as e:
-				die('RPCFailure', str(e))
-
-			if timeout:
-				ret = (r.read(), r.status)
-				s.close()
-				return ret
-			else:
-				return (r.read(), r.status)
-
-	class curl(base):
-
-		def __init__(self, caller):
-
-			def gen_opts():
-				for k, v in caller.http_hdrs.items():
-					yield from ('--header', f'{k}: {v}')
-				if caller.auth_type:
-					# Authentication with curl is insecure, as it exposes the user's credentials
-					# via the command line.  Use for testing only.
-					yield from ('--user', f'{caller.auth.user}:{caller.auth.passwd}')
-				if caller.auth_type == 'digest':
-					yield '--digest'
-				if caller.network_proto == 'https' and caller.verify_server is False:
-					yield '--insecure'
-
-			super().__init__(caller)
-			self.exec_opts = list(gen_opts()) + ['--silent']
-			self.arg_max = 8192 # set way below system ARG_MAX, just to be safe
-
-		async def run(self, payload, timeout, host_path):
-			data = json.dumps(payload, cls=json_encoder)
-			if len(data) > self.arg_max:
-				ymsg('Warning: Curl data payload length exceeded - falling back on httplib')
-				return RPCBackends.httplib(self.caller).run(payload, timeout, host_path)
-			dmsg_rpc_backend(self.host_url, host_path, payload)
-			exec_cmd = [
-				'curl',
-				'--proxy', f'socks5h://{self.proxy}' if self.proxy else '',
-				'--connect-timeout', str(timeout or self.timeout),
-				'--write-out', '%{http_code}',
-				'--data-binary', data
-				] + self.exec_opts + [self.host_url + host_path]
-
-			dmsg_rpc('    RPC curl exec data ==>\n{}\n', exec_cmd)
-
-			from subprocess import run, PIPE
-			from .color import set_vt100
-			res = run(exec_cmd, stdout=PIPE, check=True, text=True).stdout
-			set_vt100()
-			return (res[:-3], int(res[-3:]))
-
-class RPCClient(MMGenObject):
-
-	auth_type = None
-	has_auth_cookie = False
-	network_proto = 'http'
-	proxy = None
-
-	def __init__(self, cfg, host, port, *, test_connection=True):
-
-		self.cfg = cfg
-		self.name = type(self).__name__
-
-		# aiohttp workaround, and may speed up RPC performance overall on some systems:
-		if sys.platform == 'win32' and host == 'localhost':
-			host = '127.0.0.1'
-
-		global dmsg_rpc, dmsg_rpc_backend
-		if not self.cfg.debug_rpc:
-			dmsg_rpc = dmsg_rpc_backend = noop
-
-		dmsg_rpc(f'=== {self.name}.__init__() debug ===')
-		dmsg_rpc(f'    cls [{self.name}] host [{host}] port [{port}]\n')
-
-		if test_connection:
-			import socket
-			try:
-				socket.create_connection((host, port), timeout=1).close()
-			except:
-				die('SocketError', f'Unable to connect to {host}:{port}')
-
-		self.http_hdrs = {'Content-Type': 'application/json'}
-		self.host_url = f'{self.network_proto}://{host}:{port}'
-		self.host = host
-		self.port = port
-		self.timeout = self.cfg.http_timeout
-		self.auth = None
-
-	def _get_backend(self, backend):
-		backend_id = backend or self.cfg.rpc_backend
-		if backend_id == 'auto':
-			return {
-				'linux': RPCBackends.httplib,
-				'darwin': RPCBackends.httplib,
-				'win32': RPCBackends.requests
-			}[sys.platform](self)
-		else:
-			return getattr(RPCBackends, backend_id)(self)
-
-	def set_backend(self, backend=None):
-		self.backend = self._get_backend(backend)
-
-	async def set_backend_async(self, backend=None):
-		ret = self._get_backend(backend)
-		self.backend = (await ret) if type(ret).__name__ == 'coroutine' else ret
-
-	# Call family of methods - direct-to-daemon RPC call:
-	# - positional params are passed to the daemon, 'timeout' and 'wallet' kwargs to the backend
-	# - 'wallet' kwarg is used only by regtest
-
-	async def call(self, method, *params, timeout=None, wallet=None):
-		"""
-		default call: call with param list unrolled, exactly as with cli
-		"""
-		return self.process_http_resp(await self.backend.run(
-			payload = {'id': 1, 'jsonrpc': '2.0', 'method': method, 'params': params},
-			timeout = timeout,
-			host_path = self.make_host_path(wallet)
-		))
-
-	async def batch_call(self, method, param_list, *, timeout=None, wallet=None):
-		"""
-		Make a single call with a list of tuples as first argument
-		For RPC calls that return a list of results
-		"""
-		return self.process_http_resp(await self.backend.run(
-			payload = [{
-				'id': n,
-				'jsonrpc': '2.0',
-				'method': method,
-				'params': params} for n, params in enumerate(param_list, 1)],
-			timeout = timeout,
-			host_path = self.make_host_path(wallet)
-		), batch=True)
-
-	async def gathered_call(self, method, args_list, *, timeout=None, wallet=None):
-		"""
-		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 is 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.backend.run(
-						payload = {'id': n, 'jsonrpc': '2.0', 'method': method, 'params': params},
-						timeout = timeout,
-						host_path = self.make_host_path(wallet)
-					) 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 [self.process_http_resp(r) for r in ret]
-
-	# Icall family of methods - indirect RPC call using CallSigs mechanism:
-	# - 'timeout' and 'wallet' kwargs are passed to corresponding Call method
-	# - remaining kwargs are passed to CallSigs method
-	# - CallSigs method returns method and positional params for Call method
-
-	def icall(self, method, **kwargs):
-		timeout = kwargs.pop('timeout', None)
-		wallet = kwargs.pop('wallet', None)
-		return self.call(
-			*getattr(self.call_sigs, method)(**kwargs),
-			timeout = timeout,
-			wallet = wallet)
-
-	def gathered_icall(self, method, args_list, *, timeout=None, wallet=None):
-		return self.gathered_call(
-			method,
-			[getattr(self.call_sigs, method)(*a)[1:] for a in args_list],
-			timeout = timeout,
-			wallet = wallet)
-
-	def process_http_resp(self, run_ret, *, batch=False, json_rpc=True):
-
-		def float_parser(n):
-			return n
-
-		text, status = run_ret
-
-		if status == 200:
-			dmsg_rpc('    RPC RESPONSE data ==>\n{}\n', text, is_json=True)
-			m = None
-			if batch:
-				return [r['result'] for r in json.loads(text, parse_float=float_parser)]
-			else:
-				try:
-					if json_rpc:
-						ret = json.loads(text, parse_float=float_parser)['result']
-						if isinstance(ret, list) and ret and type(ret[0]) == dict and 'success' in ret[0]:
-							for res in ret:
-								if not res['success']:
-									m = str(res['error'])
-									assert False
-						return ret
-					else:
-						return json.loads(text, parse_float=float_parser)
-				except:
-					if not m:
-						t = json.loads(text)
-						try:
-							m = t['error']['message']
-						except:
-							try:
-								m = t['error']
-							except:
-								m = t
-					die('RPCFailure', m)
-		else:
-			import http
-			m, s = ('', http.HTTPStatus(status))
-			if text:
-				try:
-					m = json.loads(text)['error']['message']
-				except:
-					try:
-						m = text.decode()
-					except:
-						m = text
-			die('RPCFailure', f'{s.value} {s.name}: {m}')
-
-	async def stop_daemon(self, *, quiet=False, silent=False):
-		if self.daemon.state == 'ready':
-			if not (quiet or silent):
-				msg(f'Stopping {self.daemon.desc} on port {self.daemon.bind_port}')
-			ret = await self.do_stop_daemon(silent=silent)
-			if self.daemon.wait:
-				self.daemon.wait_for_state('stopped')
-			return ret
-		else:
-			if not (quiet or silent):
-				msg(f'{self.daemon.desc} on port {self.daemon.bind_port} not running')
-			return True
-
-	def start_daemon(self, *, silent=False):
-		return self.daemon.start(silent=silent)
-
-	async def restart_daemon(self, *, quiet=False, silent=False):
-		await self.stop_daemon(quiet=quiet, silent=silent)
-		return self.daemon.start(silent=silent)
-
-	def handle_unsupported_daemon_version(self, name, warn_only):
-
-		class daemon_version_warning(oneshot_warning):
-			color = 'yellow'
-			message = 'ignoring unsupported {} daemon version at user request'
-
-		if warn_only:
-			daemon_version_warning(div=name, fmt_args=[self.daemon.coind_name])
-		else:
-			name = self.daemon.coind_name
-			die(2, '\n'+fmt(f"""
-				The running {name} daemon has version {self.daemon_version_str}.
-				This version of MMGen is tested only on {name} v{self.daemon.coind_version_str} and below.
-
-				To avoid this error, downgrade your daemon to a supported version.
-
-				Alternatively, you may invoke the command with the --ignore-daemon-version
-				option, in which case you proceed at your own risk.
-				""", indent='    '))
-
-async def rpc_init(
-		cfg,
-		proto                 = None,
-		*,
-		backend               = None,
-		daemon                = None,
-		ignore_daemon_version = False,
-		ignore_wallet         = False):
-
-	proto = proto or cfg._proto
-
-	if not 'rpc_init' in proto.mmcaps:
-		die(1, f'rpc_init() not supported for {proto.name} protocol!')
-
-	cls = getattr(
-		importlib.import_module(f'mmgen.proto.{proto.base_proto_coin.lower()}.rpc'),
-			proto.base_proto + 'RPCClient')
-
-	from .daemon import CoinDaemon
-	rpc = await cls(
-		cfg           = cfg,
-		proto         = proto,
-		daemon        = daemon or CoinDaemon(cfg, proto=proto, test_suite=cfg.test_suite),
-		backend       = backend or cfg.rpc_backend,
-		ignore_wallet = ignore_wallet)
-
-	if rpc.daemon_version > rpc.daemon.coind_version:
-		rpc.handle_unsupported_daemon_version(
-			proto.name,
-			ignore_daemon_version or proto.ignore_daemon_version or cfg.ignore_daemon_version)
-
-	if rpc.chain not in proto.chain_names:
-		die('RPCChainMismatch', '\n' + fmt(f"""
-			Protocol:           {proto.cls_name}
-			Valid chain names:  {fmt_list(proto.chain_names, fmt='bare')}
-			RPC client chain:   {rpc.chain}
-			""", indent='  ').rstrip())
-
-	rpc.blockcount = NonNegativeInt(rpc.blockcount)
-
-	return rpc

+ 60 - 0
mmgen/rpc/__init__.py

@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 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-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+rpc: RPC library for the MMGen Project
+"""
+
+import importlib
+
+from ..util import die, fmt, fmt_list
+from ..obj import NonNegativeInt
+
+async def rpc_init(
+		cfg,
+		proto                 = None,
+		*,
+		backend               = None,
+		daemon                = None,
+		ignore_daemon_version = False,
+		ignore_wallet         = False):
+
+	proto = proto or cfg._proto
+
+	if not 'rpc_init' in proto.mmcaps:
+		die(1, f'rpc_init() not supported for {proto.name} protocol!')
+
+	cls = getattr(
+		importlib.import_module(f'mmgen.proto.{proto.base_proto_coin.lower()}.rpc.local'),
+			proto.base_proto + 'RPCClient')
+
+	from ..daemon import CoinDaemon
+	rpc = await cls(
+		cfg           = cfg,
+		proto         = proto,
+		daemon        = daemon or CoinDaemon(cfg, proto=proto, test_suite=cfg.test_suite),
+		backend       = backend or cfg.rpc_backend,
+		ignore_wallet = ignore_wallet)
+
+	if rpc.daemon_version > rpc.daemon.coind_version:
+		rpc.handle_unsupported_daemon_version(
+			proto.name,
+			ignore_daemon_version or proto.ignore_daemon_version or cfg.ignore_daemon_version)
+
+	if rpc.chain not in proto.chain_names:
+		die('RPCChainMismatch', '\n' + fmt(f"""
+			Protocol:           {proto.cls_name}
+			Valid chain names:  {fmt_list(proto.chain_names, fmt='bare')}
+			RPC client chain:   {rpc.chain}
+			""", indent='  ').rstrip())
+
+	rpc.blockcount = NonNegativeInt(rpc.blockcount)
+
+	return rpc

+ 57 - 0
mmgen/rpc/backends/aiohttp.py

@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 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-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+rpc.backends.aiohttp: aiohttp RPC backend for the MMGen Project
+"""
+
+import json
+
+from ...base_obj import AsyncInit
+
+from ..util import dmsg_rpc_backend, json_encoder
+
+from .base import base
+
+class aiohttp(base, metaclass=AsyncInit):
+	"""
+	Contrary to the requests library, aiohttp won’t read environment variables by
+	default.  But you can do so by passing trust_env=True into aiohttp.ClientSession
+	constructor to honor HTTP_PROXY, HTTPS_PROXY, WS_PROXY or WSS_PROXY environment
+	variables (all are case insensitive).
+	"""
+
+	def __del__(self):
+		self.connector.close()
+		self.session.detach()
+		del self.session
+
+	async def __init__(self, caller):
+		super().__init__(caller)
+		import aiohttp
+		self.connector = aiohttp.TCPConnector(limit_per_host=self.cfg.aiohttp_rpc_queue_len)
+		self.session = aiohttp.ClientSession(
+			headers = {'Content-Type': 'application/json'},
+			connector = self.connector,
+		)
+		if caller.auth_type == 'basic':
+			self.auth = aiohttp.BasicAuth(*caller.auth, encoding='UTF-8')
+		else:
+			self.auth = None
+
+	async def run(self, payload, timeout, host_path):
+		dmsg_rpc_backend(self.host_url, host_path, payload)
+		async with self.session.post(
+			url     = self.host_url + host_path,
+			auth    = self.auth,
+			data    = json.dumps(payload, cls=json_encoder),
+			timeout = timeout or self.timeout,
+		) as res:
+			return (await res.text(), res.status)

+ 26 - 0
mmgen/rpc/backends/base.py

@@ -0,0 +1,26 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 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-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+rpc.backends.base: base RPC backend class for the MMGen Project
+"""
+
+class base:
+
+	def __init__(self, caller):
+		self.cfg            = caller.cfg
+		self.host           = caller.host
+		self.port           = caller.port
+		self.proxy          = caller.proxy
+		self.host_url       = caller.host_url
+		self.timeout        = caller.timeout
+		self.http_hdrs      = caller.http_hdrs
+		self.name           = type(self).__name__
+		self.caller         = caller

+ 64 - 0
mmgen/rpc/backends/curl.py

@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 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-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+rpc.backends.curl: curl RPC backend for the MMGen Project
+"""
+
+import json
+
+from ...util import ymsg
+
+from ..util import dmsg_rpc, dmsg_rpc_backend, json_encoder
+
+from .base import base
+
+class curl(base):
+
+	def __init__(self, caller):
+
+		def gen_opts():
+			for k, v in caller.http_hdrs.items():
+				yield from ('--header', f'{k}: {v}')
+			if caller.auth_type:
+				# Authentication with curl is insecure, as it exposes the user's credentials
+				# via the command line.  Use for testing only.
+				yield from ('--user', f'{caller.auth.user}:{caller.auth.passwd}')
+			if caller.auth_type == 'digest':
+				yield '--digest'
+			if caller.network_proto == 'https' and caller.verify_server is False:
+				yield '--insecure'
+
+		super().__init__(caller)
+		self.exec_opts = list(gen_opts()) + ['--silent']
+		self.arg_max = 8192 # set way below system ARG_MAX, just to be safe
+
+	async def run(self, payload, timeout, host_path):
+		data = json.dumps(payload, cls=json_encoder)
+		if len(data) > self.arg_max:
+			from .httplib import httplib
+			ymsg('Warning: Curl data payload length exceeded - falling back on httplib')
+			return httplib(self.caller).run(payload, timeout, host_path)
+		dmsg_rpc_backend(self.host_url, host_path, payload)
+		exec_cmd = [
+			'curl',
+			'--proxy', f'socks5h://{self.proxy}' if self.proxy else '',
+			'--connect-timeout', str(timeout or self.timeout),
+			'--write-out', '%{http_code}',
+			'--data-binary', data
+			] + self.exec_opts + [self.host_url + host_path]
+
+		dmsg_rpc('    RPC curl exec data ==>\n{}\n', exec_cmd)
+
+		from subprocess import run, PIPE
+		from ...color import set_vt100
+		res = run(exec_cmd, stdout=PIPE, check=True, text=True).stdout
+		set_vt100()
+		return (res[:-3], int(res[-3:]))

+ 64 - 0
mmgen/rpc/backends/httplib.py

@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 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-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+rpc.backends.httplib: httplib RPC backend for the MMGen Project
+"""
+
+import json, base64
+
+from ...util import die
+
+from ..util import dmsg_rpc, dmsg_rpc_backend, json_encoder
+
+from .base import base
+
+class httplib(base):
+	"""
+	Ignores *_PROXY environment vars
+	"""
+	def __del__(self):
+		self.session.close()
+
+	def __init__(self, caller):
+		super().__init__(caller)
+		import http.client
+		self.session = http.client.HTTPConnection(caller.host, caller.port, caller.timeout)
+		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})
+			dmsg_rpc(f'    RPC AUTHORIZATION data ==> raw: [{auth_str}]\n{"":>31}enc: [{auth_str_b64}]\n')
+
+	async def run(self, payload, timeout, host_path):
+		dmsg_rpc_backend(self.host_url, host_path, 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     = host_path,
+				body    = json.dumps(payload, cls=json_encoder),
+				headers = self.http_hdrs)
+			r = s.getresponse() # => http.client.HTTPResponse instance
+		except Exception as e:
+			die('RPCFailure', str(e))
+
+		if timeout:
+			ret = (r.read(), r.status)
+			s.close()
+			return ret
+		else:
+			return (r.read(), r.status)

+ 52 - 0
mmgen/rpc/backends/requests.py

@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 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-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+rpc.backends.requests: requests RPC backend for the MMGen Project
+"""
+
+import json
+
+from ..util import dmsg_rpc_backend, json_encoder
+
+from .base import base
+
+class requests(base):
+
+	def __del__(self):
+		self.session.close()
+
+	def __init__(self, caller):
+		super().__init__(caller)
+		import requests, urllib3
+		urllib3.disable_warnings()
+		self.session = requests.Session()
+		self.session.trust_env = False # ignore *_PROXY environment vars
+		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)
+		if self.proxy: # used only by XMR for now: requires pysocks package
+			self.session.proxies.update({
+				'http':  f'socks5h://{self.proxy}',
+				'https': f'socks5h://{self.proxy}'
+			})
+
+	async def run(self, *args, **kwargs):
+		return self.run_noasync(*args, **kwargs)
+
+	def run_noasync(self, payload, timeout, host_path):
+		dmsg_rpc_backend(self.host_url, host_path, payload)
+		res = self.session.post(
+			url     = self.host_url + host_path,
+			data    = json.dumps(payload, cls=json_encoder),
+			timeout = timeout or self.timeout,
+			verify  = False)
+		return (res.content, res.status_code)

+ 233 - 0
mmgen/rpc/local.py

@@ -0,0 +1,233 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 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-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+rpc: local RPC client class for the MMGen Project
+"""
+
+import sys, json, asyncio, importlib
+
+from ..util import msg, die, fmt, oneshot_warning
+
+from . import util
+
+class RPCClient:
+
+	auth_type = None
+	has_auth_cookie = False
+	network_proto = 'http'
+	proxy = None
+
+	def __init__(self, cfg, host, port, *, test_connection=True):
+
+		self.cfg = cfg
+		self.name = type(self).__name__
+
+		# aiohttp workaround, and may speed up RPC performance overall on some systems:
+		if sys.platform == 'win32' and host == 'localhost':
+			host = '127.0.0.1'
+
+		if not self.cfg.debug_rpc:
+			util.dmsg_rpc = util.dmsg_rpc_backend = util.noop
+
+		util.dmsg_rpc(f'=== {self.name}.__init__() debug ===')
+		util.dmsg_rpc(f'    cls [{self.name}] host [{host}] port [{port}]\n')
+
+		if test_connection:
+			import socket
+			try:
+				socket.create_connection((host, port), timeout=1).close()
+			except:
+				die('SocketError', f'Unable to connect to {host}:{port}')
+
+		self.http_hdrs = {'Content-Type': 'application/json'}
+		self.host_url = f'{self.network_proto}://{host}:{port}'
+		self.host = host
+		self.port = port
+		self.timeout = self.cfg.http_timeout
+		self.auth = None
+
+	def _get_backend(self, backend):
+		dfl_backends = {
+			'linux': 'httplib',
+			'darwin': 'httplib',
+			'win32': 'requests'}
+		def get_cls(backend_id):
+			return getattr(importlib.import_module(f'mmgen.rpc.backends.{backend_id}'), backend_id)
+		backend_id = backend or self.cfg.rpc_backend
+		return get_cls(dfl_backends[sys.platform] if backend_id == 'auto' else backend_id)(self)
+
+	def set_backend(self, backend=None):
+		self.backend = self._get_backend(backend)
+
+	async def set_backend_async(self, backend=None):
+		ret = self._get_backend(backend)
+		self.backend = (await ret) if type(ret).__name__ == 'coroutine' else ret
+
+	# Call family of methods - direct-to-daemon RPC call:
+	# - positional params are passed to the daemon, 'timeout' and 'wallet' kwargs to the backend
+	# - 'wallet' kwarg is used only by regtest
+
+	async def call(self, method, *params, timeout=None, wallet=None):
+		"""
+		default call: call with param list unrolled, exactly as with cli
+		"""
+		return self.process_http_resp(await self.backend.run(
+			payload = {'id': 1, 'jsonrpc': '2.0', 'method': method, 'params': params},
+			timeout = timeout,
+			host_path = self.make_host_path(wallet)
+		))
+
+	async def batch_call(self, method, param_list, *, timeout=None, wallet=None):
+		"""
+		Make a single call with a list of tuples as first argument
+		For RPC calls that return a list of results
+		"""
+		return self.process_http_resp(await self.backend.run(
+			payload = [{
+				'id': n,
+				'jsonrpc': '2.0',
+				'method': method,
+				'params': params} for n, params in enumerate(param_list, 1)],
+			timeout = timeout,
+			host_path = self.make_host_path(wallet)
+		), batch=True)
+
+	async def gathered_call(self, method, args_list, *, timeout=None, wallet=None):
+		"""
+		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 is 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.backend.run(
+						payload = {'id': n, 'jsonrpc': '2.0', 'method': method, 'params': params},
+						timeout = timeout,
+						host_path = self.make_host_path(wallet)
+					) 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 [self.process_http_resp(r) for r in ret]
+
+	# Icall family of methods - indirect RPC call using CallSigs mechanism:
+	# - 'timeout' and 'wallet' kwargs are passed to corresponding Call method
+	# - remaining kwargs are passed to CallSigs method
+	# - CallSigs method returns method and positional params for Call method
+
+	def icall(self, method, **kwargs):
+		timeout = kwargs.pop('timeout', None)
+		wallet = kwargs.pop('wallet', None)
+		return self.call(
+			*getattr(self.call_sigs, method)(**kwargs),
+			timeout = timeout,
+			wallet = wallet)
+
+	def gathered_icall(self, method, args_list, *, timeout=None, wallet=None):
+		return self.gathered_call(
+			method,
+			[getattr(self.call_sigs, method)(*a)[1:] for a in args_list],
+			timeout = timeout,
+			wallet = wallet)
+
+	def process_http_resp(self, run_ret, *, batch=False, json_rpc=True):
+
+		def float_parser(n):
+			return n
+
+		text, status = run_ret
+
+		if status == 200:
+			util.dmsg_rpc('    RPC RESPONSE data ==>\n{}\n', text, is_json=True)
+			m = None
+			if batch:
+				return [r['result'] for r in json.loads(text, parse_float=float_parser)]
+			else:
+				try:
+					if json_rpc:
+						ret = json.loads(text, parse_float=float_parser)['result']
+						if isinstance(ret, list) and ret and type(ret[0]) == dict and 'success' in ret[0]:
+							for res in ret:
+								if not res['success']:
+									m = str(res['error'])
+									assert False
+						return ret
+					else:
+						return json.loads(text, parse_float=float_parser)
+				except:
+					if not m:
+						t = json.loads(text)
+						try:
+							m = t['error']['message']
+						except:
+							try:
+								m = t['error']
+							except:
+								m = t
+					die('RPCFailure', m)
+		else:
+			import http
+			m, s = ('', http.HTTPStatus(status))
+			if text:
+				try:
+					m = json.loads(text)['error']['message']
+				except:
+					try:
+						m = text.decode()
+					except:
+						m = text
+			die('RPCFailure', f'{s.value} {s.name}: {m}')
+
+	async def stop_daemon(self, *, quiet=False, silent=False):
+		if self.daemon.state == 'ready':
+			if not (quiet or silent):
+				msg(f'Stopping {self.daemon.desc} on port {self.daemon.bind_port}')
+			ret = await self.do_stop_daemon(silent=silent)
+			if self.daemon.wait:
+				self.daemon.wait_for_state('stopped')
+			return ret
+		else:
+			if not (quiet or silent):
+				msg(f'{self.daemon.desc} on port {self.daemon.bind_port} not running')
+			return True
+
+	def start_daemon(self, *, silent=False):
+		return self.daemon.start(silent=silent)
+
+	async def restart_daemon(self, *, quiet=False, silent=False):
+		await self.stop_daemon(quiet=quiet, silent=silent)
+		return self.daemon.start(silent=silent)
+
+	def handle_unsupported_daemon_version(self, name, warn_only):
+
+		class daemon_version_warning(oneshot_warning):
+			color = 'yellow'
+			message = 'ignoring unsupported {} daemon version at user request'
+
+		if warn_only:
+			daemon_version_warning(div=name, fmt_args=[self.daemon.coind_name])
+		else:
+			name = self.daemon.coind_name
+			die(2, '\n'+fmt(f"""
+				The running {name} daemon has version {self.daemon_version_str}.
+				This version of MMGen is tested only on {name} v{self.daemon.coind_version_str} and below.
+
+				To avoid this error, downgrade your daemon to a supported version.
+
+				Alternatively, you may invoke the command with the --ignore-daemon-version
+				option, in which case you proceed at your own risk.
+				""", indent='    '))

+ 70 - 0
mmgen/rpc/util.py

@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 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-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+rpc.util: RPC library utility functions for the MMGen Project
+"""
+
+import re, json
+from collections import namedtuple
+
+from ..util import msg, pp_fmt
+from ..objmethods import HiliteStr, InitErrors
+
+def dmsg_rpc(fs, data=None, *, is_json=False):
+	msg(
+		fs if data is None else
+		fs.format(pp_fmt(json.loads(data) if is_json else data))
+	)
+
+def dmsg_rpc_backend(host_url, host_path, payload):
+	msg(
+		f'\n    RPC URL: {host_url}{host_path}' +
+		'\n    RPC PAYLOAD data (httplib) ==>' +
+		f'\n{pp_fmt(payload)}\n')
+
+def noop(*args, **kwargs):
+	pass
+
+auth_data = namedtuple('rpc_auth_data', ['user', 'passwd'])
+
+class json_encoder(json.JSONEncoder):
+	def default(self, o):
+		if type(o).__name__.endswith('Amt'):
+			return str(o)
+		else:
+			return json.JSONEncoder.default(self, o)
+
+class IPPort(HiliteStr, InitErrors):
+	color = 'yellow'
+	width = 0
+	trunc_ok = False
+	min_len = 9  # 0.0.0.0:0
+	max_len = 21 # 255.255.255.255:65535
+	def __new__(cls, s):
+		if isinstance(s, cls):
+			return s
+		try:
+			m = re.fullmatch(r'{q}\.{q}\.{q}\.{q}:(\d{{1,10}})'.format(q=r'([0-9]{1,3})'), s)
+			assert m is not None, f'{s!r}: invalid IP:HOST specifier'
+			for e in m.groups():
+				if len(e) != 1 and e[0] == '0':
+					raise ValueError(f'{e}: leading zeroes not permitted in dotted decimal element or port number')
+			res = [int(e) for e in m.groups()]
+			for e in res[:4]:
+				assert e <= 255, f'{e}: dotted decimal element > 255'
+			assert res[4] <= 65535, f'{res[4]}: port number > 65535'
+			me = str.__new__(cls, s)
+			me.ip = '{}.{}.{}.{}'.format(*res)
+			me.ip_num = sum(res[i] * (2 ** (-(i-3)*8)) for i in range(4))
+			me.port = res[4]
+			return me
+		except Exception as e:
+			return cls.init_fail(e, s)

+ 1 - 1
mmgen/tw/json.py

@@ -18,7 +18,7 @@ from collections import namedtuple
 from ..util import msg, ymsg, fmt, suf, die, make_timestamp, make_chksum_8
 from ..util import msg, ymsg, fmt, suf, die, make_timestamp, make_chksum_8
 from ..base_obj import AsyncInit
 from ..base_obj import AsyncInit
 from ..objmethods import MMGenObject
 from ..objmethods import MMGenObject
-from ..rpc import json_encoder
+from ..rpc.util import json_encoder
 from .ctl import TwCtl
 from .ctl import TwCtl
 
 
 class TwJSON:
 class TwJSON:

+ 1 - 1
mmgen/xmrwallet/file/__init__.py

@@ -15,7 +15,7 @@ xmrwallet.file: Monero file base class for the MMGen Suite
 import json
 import json
 from ...util import make_chksum_N
 from ...util import make_chksum_N
 from ...fileutil import get_data_from_file
 from ...fileutil import get_data_from_file
-from ...rpc import json_encoder
+from ...rpc.util import json_encoder
 
 
 class MoneroMMGenFile:
 class MoneroMMGenFile:
 
 

+ 4 - 0
setup.cfg

@@ -81,6 +81,7 @@ packages =
 	mmgen.proto
 	mmgen.proto
 	mmgen.proto.bch
 	mmgen.proto.bch
 	mmgen.proto.btc
 	mmgen.proto.btc
+	mmgen.proto.btc.rpc
 	mmgen.proto.btc.tx
 	mmgen.proto.btc.tx
 	mmgen.proto.btc.tw
 	mmgen.proto.btc.tw
 	mmgen.proto.etc
 	mmgen.proto.etc
@@ -88,6 +89,7 @@ packages =
 	mmgen.proto.eth.pyethereum
 	mmgen.proto.eth.pyethereum
 	mmgen.proto.eth.rlp
 	mmgen.proto.eth.rlp
 	mmgen.proto.eth.rlp.sedes
 	mmgen.proto.eth.rlp.sedes
+	mmgen.proto.eth.rpc
 	mmgen.proto.eth.tx
 	mmgen.proto.eth.tx
 	mmgen.proto.eth.tw
 	mmgen.proto.eth.tw
 	mmgen.proto.ltc
 	mmgen.proto.ltc
@@ -96,6 +98,8 @@ packages =
 	mmgen.proto.xchain
 	mmgen.proto.xchain
 	mmgen.proto.xmr
 	mmgen.proto.xmr
 	mmgen.proto.zec
 	mmgen.proto.zec
+	mmgen.rpc
+	mmgen.rpc.backends
 	mmgen.swap
 	mmgen.swap
 	mmgen.swap.proto
 	mmgen.swap.proto
 	mmgen.swap.proto.thorchain
 	mmgen.swap.proto.thorchain

+ 1 - 1
test/cmdtest_d/main.py

@@ -709,7 +709,7 @@ class CmdTestMain(CmdTestBase, CmdTestShared):
 		ad, tx_data = self._create_tx_data(sources, addrs_per_wallet)
 		ad, tx_data = self._create_tx_data(sources, addrs_per_wallet)
 		dfake = self._create_fake_unspent_data(ad, tx_data, non_mmgen_input, non_mmgen_input_compressed)
 		dfake = self._create_fake_unspent_data(ad, tx_data, non_mmgen_input, non_mmgen_input_compressed)
 		import json
 		import json
-		from mmgen.rpc import json_encoder
+		from mmgen.rpc.util import json_encoder
 		self._write_fake_data_to_file(json.dumps(dfake, cls=json_encoder))
 		self._write_fake_data_to_file(json.dumps(dfake, cls=json_encoder))
 		cmd_args = self._make_txcreate_cmdline(tx_data)
 		cmd_args = self._make_txcreate_cmdline(tx_data)
 
 

+ 1 - 1
test/objtest_d/btc_mainnet.py

@@ -25,7 +25,7 @@ from mmgen.key import PrivKey, WifKey, PubKey
 from mmgen.amt import BTCAmt
 from mmgen.amt import BTCAmt
 from mmgen.addr import CoinAddr, MMGenID, MMGenAddrType, MMGenPasswordType
 from mmgen.addr import CoinAddr, MMGenID, MMGenAddrType, MMGenPasswordType
 from mmgen.tw.shared import TwMMGenID, TwLabel, TwComment
 from mmgen.tw.shared import TwMMGenID, TwLabel, TwComment
-from mmgen.rpc import IPPort
+from mmgen.rpc.util import IPPort
 from mmgen.protocol import init_proto
 from mmgen.protocol import init_proto
 
 
 from .common import r16, r32
 from .common import r16, r32