Browse Source

coin-specific and protocol-specific configuration options

Rationale: to enable communication with multiple coin daemons on multiple hosts
in a single program invocation, making possible the implementation of asset
swap functionality, for instance

Coin-specific options are prefixed with a coin symbol, proto-specific options
with a coin symbol plus a network name.

Coin- and protocol-specific options override their non-prefixed counterparts.
They are available via the command line, configuration file and Config API.

Currently available options:

    Option                   Supported Prefixes
    tw_name                  btc ltc bch
    rpc_user                 btc ltc bch
    rpc_password             btc ltc bch
    rpc_host                 btc ltc bch eth etc
    rpc_port                 btc ltc bch eth etc xmr
    ignore_daemon_version    btc ltc bch eth etc xmr
    max_tx_fee               btc ltc bch eth etc
    chain_names              eth_mainnet eth_testnet etc_mainnet etc_testnet

Example:

    $ mmgen-tool --coin=ltc --ltc-tw-name=ltc2 --ltc-ignore-daemon-version twview

Help:

    $ mmgen-tool --longhelp
    $ view mmgen/data/mmgen.cfg

Testing:

    $ test/daemontest.py rpc.btc rpc.geth
    $ test/cmdtest.py help opts cfgfile
The MMGen Project 2 months ago
parent
commit
f8a312e407

+ 37 - 41
mmgen/cfg.py

@@ -280,6 +280,8 @@ class Config(Lockable):
 		('autosign', 'outdir'),
 	)
 
+	# proto-specific only: eth_mainnet_chain_names eth_testnet_chain_names
+	# coin-specific only:  bch_cashaddr (alias of cashaddr)
 	_cfg_file_opts = (
 		'autochg_ignore_labels',
 		'color',
@@ -289,6 +291,7 @@ class Config(Lockable):
 		'force_256_color',
 		'hash_preset',
 		'http_timeout',
+		'ignore_daemon_version', # also coin-specific
 		'macos_autosign_ramdisk_size',
 		'max_input_size',
 		'max_tx_file_size',
@@ -298,27 +301,15 @@ class Config(Lockable):
 		'no_license',
 		'quiet',
 		'regtest',
-		'rpc_host',
-		'rpc_password',
-		'rpc_port',
-		'rpc_user',
+		'rpc_host',     # also coin-specific
+		'rpc_password', # also coin-specific
+		'rpc_port',     # also coin-specific
+		'rpc_user',     # also coin-specific
 		'scroll',
 		'subseeds',
 		'testnet',
-		'usr_randchars',
-		'bch_cashaddr',
-		'bch_max_tx_fee',
-		'btc_max_tx_fee',
-		'eth_max_tx_fee',
-		'ltc_max_tx_fee',
-		'bch_ignore_daemon_version',
-		'btc_ignore_daemon_version',
-		'etc_ignore_daemon_version',
-		'eth_ignore_daemon_version',
-		'ltc_ignore_daemon_version',
-		'xmr_ignore_daemon_version',
-		'eth_mainnet_chain_names',
-		'eth_testnet_chain_names')
+		'tw_name',      # also coin-specific
+		'usr_randchars')
 
 	# Supported environmental vars
 	# The corresponding attributes (lowercase, without 'mmgen_') must exist in the class.
@@ -563,6 +554,9 @@ class Config(Lockable):
 
 		del self._cloned
 
+		if hasattr(self, 'bch_cashaddr') and not hasattr(self, 'cashaddr'):
+			self.cashaddr = self.bch_cashaddr
+
 		self._lock()
 
 		if need_proto:
@@ -634,34 +628,29 @@ class Config(Lockable):
 		non_auto_opts = []
 		already_set = tuple(self._uopts) + env_cfg
 
+		def set_opt(d, obj, name, refval):
+			val = ucfg.parse_value(d.value, refval)
+			if not val:
+				die('CfgFileParseError', f'Parse error in file {ucfg.fn!r}, line {d.lineno}')
+			val_conv = conv_type(name, val, refval, src=ucfg.fn)
+			setattr(obj, name, val_conv)
+			non_auto_opts.append(name)
+
 		for d in ucfg.get_lines():
 			if d.name in self._cfg_file_opts:
-				ns = d.name.split('_')
-				if ns[0] in gc.core_coins:
-					if not need_proto:
-						continue
-					nse, tn = (
-						(ns[2:], ns[1]=='testnet') if len(ns) > 2 and ns[1] in ('mainnet', 'testnet') else
-						(ns[1:], False)
-					)
-					# no instance yet, so override _class_ attr:
-					cls = init_proto(self, ns[0], tn, need_amt=True, return_cls=True)
-					attr = '_'.join(nse)
-				else:
-					cls = self
-					attr = d.name
-				refval = getattr(cls, attr)
-				val = ucfg.parse_value(d.value, refval)
-				if not val:
-					die('CfgFileParseError', f'Parse error in file {ucfg.fn!r}, line {d.lineno}')
-				val_conv = conv_type(attr, val, refval, src=ucfg.fn)
-				if not attr in already_set:
-					setattr(cls, attr, val_conv)
-					non_auto_opts.append(attr)
+				if not d.name in already_set:
+					set_opt(d, self, d.name, getattr(self, d.name))
 			elif d.name in self._autoset_opts:
 				autoset_opts[d.name] = d.value
 			elif d.name in self._auto_typeset_opts:
 				auto_typeset_opts[d.name] = d.value
+			elif any(d.name.startswith(coin + '_') for coin in gc.rpc_coins):
+				if need_proto and not d.name in already_set:
+					try:
+						refval = init_proto(self, d.name.split('_', 1)[0]).get_opt_clsval(self, d.name)
+					except AttributeError:
+						die('CfgFileParseError', f'{d.name!r}: unrecognized option in {ucfg.fn!r}, line {d.lineno}')
+					set_opt(d, self, d.name, refval)
 			else:
 				die('CfgFileParseError', f'{d.name!r}: unrecognized option in {ucfg.fn!r}, line {d.lineno}')
 
@@ -946,7 +935,8 @@ def conv_type(name, val, refval, src, invert_bool=False):
 			d = '' if src in ('cmdline', 'cfg', 'env') else f' in {src!r}',
 			e = type(refval).__name__))
 
-	if type(refval) is bool:
+	# refval is None = boolean opt with no cmdline parameter
+	if type(refval) is bool or refval is None:
 		v = str(val).lower()
 		ret = (
 			True  if v in ('true', 'yes', '1', 'on') else
@@ -954,6 +944,12 @@ def conv_type(name, val, refval, src, invert_bool=False):
 			None
 		)
 		return do_fail() if ret is None else (not ret) if invert_bool else ret
+	elif isinstance(refval, (list, tuple)):
+		if src == 'cmdline':
+			return type(refval)(val.split(','))
+		else:
+			assert isinstance(val, (list, tuple)), f'{val}: not a list or tuple'
+			return type(refval)(val)
 	else:
 		try:
 			return type(refval)(not val if invert_bool else val)

+ 9 - 7
mmgen/cfgfile.py

@@ -254,15 +254,17 @@ class CfgFileSampleUsr(cfg_file_sample):
 	def show_changes(self, diff):
 		ymsg('Warning: configuration file options have changed!\n')
 		for desc in ('added', 'removed'):
-			data = diff[desc]
-			if data:
-				opts = fmt_list([i.name for i in data], fmt='bare')
-				msg(f'  The following option{suf(data, verb="has")} been {desc}:\n    {opts}\n')
-				if desc == 'removed' and data:
+			changed_opts = [i.name for i in diff[desc]
+				# workaround for coin-specific opts previously listed in sample file:
+				if not (i.name.endswith('_ignore_daemon_version') and desc == 'removed')
+			]
+			if changed_opts:
+				msg(f'  The following option{suf(changed_opts, verb="has")} been {desc}:')
+				msg(f'    {fmt_list(changed_opts, fmt="bare")}\n')
+				if desc == 'removed':
 					uc = mmgen_cfg_file(self.cfg, 'usr')
 					usr_names = [i.name for i in uc.get_lines()]
-					rm_names = [i.name for i in data]
-					bad = sorted(set(usr_names).intersection(rm_names))
+					bad = sorted(set(usr_names).intersection(changed_opts))
 					if bad:
 						m = f"""
 							The following removed option{suf(bad, verb='is')} set in {uc.fn!r}

+ 2 - 1
mmgen/daemon.py

@@ -420,7 +420,8 @@ class CoinDaemon(Daemon):
 		ps_adj = (port_shift or 0) + (self.test_suite_port_shift if test_suite else 0)
 
 		# user-set values take precedence
-		self.rpc_port = (cfg.rpc_port or 0) + (port_shift or 0) if cfg.rpc_port else ps_adj + self.get_rpc_port()
+		usr_rpc_port = self.proto.rpc_port or cfg.rpc_port
+		self.rpc_port = usr_rpc_port + (port_shift or 0) if usr_rpc_port else ps_adj + self.get_rpc_port()
 		self.p2p_port = (
 			p2p_port or (
 				self.get_p2p_port() + ps_adj if self.get_p2p_port() and (test_suite or ps_adj) else None

+ 32 - 28
mmgen/data/mmgen.cfg

@@ -26,18 +26,6 @@
 # Uncomment to use testnet instead of mainnet:
 # testnet true
 
-# Set the RPC host (the host the coin daemon is running on):
-# rpc_host localhost
-
-# Set the RPC host's port number:
-# rpc_port 8332
-
-# Uncomment to override 'rpcuser' from coin daemon config file:
-# rpc_user myusername
-
-# Uncomment to override 'rpcpassword' from coin daemon config file:
-# rpc_password mypassword
-
 # Choose the backend to use for JSON-RPC connections.  Valid choices:
 # 'auto' (defaults to 'httplib'), 'httplib', 'requests', 'curl', 'aiohttp':
 # rpc_backend auto
@@ -89,27 +77,44 @@
 # setups with unusually large Monero wallets:
 # macos_autosign_ramdisk_size 10
 
-############################
-## Ignore daemon versions ##
-############################
+# Ignore coin daemon version. This option also has coin-specific variants
+# (see below):
+# ignore_daemon_version false
+
+# Specify the tracking wallet name. This option also has coin-specific
+# variants (see below):
+# tw_name my-other-tracking-wallet
 
-# Ignore Bitcoin Core version:
-# btc_ignore_daemon_version false
+#####################################################################
+## RPC options. These also have coin-specific variants (see below) ##
+#####################################################################
 
-# Ignore Litecoin Core version:
-# ltc_ignore_daemon_version false
+# Set the RPC host (the host the coin daemon is running on):
+# rpc_host localhost
 
-# Ignore Bitcoin Cash Node version:
-# bch_ignore_daemon_version false
+# Set the RPC host's port number:
+# rpc_port 8332
 
-# Ignore OpenEthereum version for ETH:
-# eth_ignore_daemon_version false
+# Uncomment to override 'rpcuser' from coin daemon config file:
+# rpc_user myusername
 
-# Ignore OpenEthereum version for ETC:
-# etc_ignore_daemon_version false
+# Uncomment to override 'rpcpassword' from coin daemon config file:
+# rpc_password mypassword
 
-# Ignore daemon version for Monero:
-# xmr_ignore_daemon_version false
+#######################################################################
+#######################  COIN-SPECIFIC OPTIONS  #######################
+#######################################################################
+##     OPTION                   SUPPORTED PREFIXES                   ##
+##     tw_name                  btc ltc bch                          ##
+##     rpc_user                 btc ltc bch                          ##
+##     rpc_password             btc ltc bch                          ##
+##     rpc_host                 btc ltc bch eth etc                  ##
+##     rpc_port                 btc ltc bch eth etc xmr              ##
+##     ignore_daemon_version    btc ltc bch eth etc xmr              ##
+##     max_tx_fee               btc ltc bch eth etc                  ##
+## Note: prefix is followed by an underscore, e.g. ‘xmr_rpc_port’    ##
+#######################################################################
+#######################################################################
 
 #####################
 ## Altcoin options ##
@@ -139,7 +144,6 @@
 # Set the Monero wallet RPC password to something secure:
 # monero_wallet_rpc_password passw0rd
 
-
 #######################################################################
 ## The following options are probably of interest only to developers ##
 #######################################################################

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-15.1.dev11
+15.1.dev12

+ 10 - 9
mmgen/help/__init__.py

@@ -142,17 +142,18 @@ class GlobalHelp(Help):
 	data_desc = 'global_opts_data'
 
 	def gen_text(self, opts):
-		from ..opts import global_opts_pat
+		from ..opts import global_opts_help_pat
 		skipping = False
 		for line in opts.global_opts_data['text']['options'][1:-3].splitlines():
-			if m := global_opts_pat.match(line):
-				if m[1] in opts.global_opts_filter.coin and m[2] in opts.global_opts_filter.cmd:
-					yield '  --{} {}'.format(m[3], m[5])
-					skipping = False
-				else:
-					skipping = True
-			elif not skipping:
-				yield line[4:]
+			m = global_opts_help_pat.match(line)
+			if m[1] == '+':
+				if not skipping:
+					yield line[4:]
+			elif m[1] in opts.global_opts_filter.coin and m[2] in opts.global_opts_filter.cmd:
+				yield '  --{} {}'.format(m[3], m[5]) if m[3] else m[5]
+				skipping = False
+			else:
+				skipping = True
 
 def print_help(cfg, opts):
 

+ 48 - 5
mmgen/opts.py

@@ -43,7 +43,7 @@ def get_opt_by_substring(opt, opts):
 		from .util import die
 		die('CmdlineOptError', f'--{opt}: ambiguous option (not unique substring)')
 
-def process_uopts(opts_data, opts):
+def process_uopts(cfg, opts_data, opts, need_proto):
 
 	from .util import die
 
@@ -83,6 +83,32 @@ def process_uopts(opts_data, opts):
 					if parm:
 						die('CmdlineOptError', f'option --{_opt} requires no parameter')
 					yield (negated_opts(opts)[_opt].name, False)
+				elif (
+						need_proto
+						and (not gc.cmd_caps or gc.cmd_caps.rpc)
+						and any(opt.startswith(coin + '-') for coin in gc.rpc_coins)):
+					opt_name = opt.replace('-', '_')
+					from .protocol import init_proto
+					try:
+						refval = init_proto(cfg, opt.split('-', 1)[0], return_cls=True).get_opt_clsval(cfg, opt_name)
+					except AttributeError:
+						die('CmdlineOptError', f'--{opt}: unrecognized option')
+					else:
+						if refval is None: # None == no parm
+							if parm:
+								die('CmdlineOptError', f'option --{opt} requires no parameter')
+							yield (opt_name, True)
+						else:
+							from .cfg import conv_type
+							if parm:
+								yield (opt_name,
+									conv_type(opt_name, parm, refval, src='cmdline'))
+							else:
+								idx += 1
+								if idx == argv_len or (parm := sys.argv[idx]).startswith('-'):
+									die('CmdlineOptError', f'missing parameter for option --{opt}')
+								yield (opt_name,
+									conv_type(opt_name, parm, refval, src='cmdline'))
 				else:
 					die('CmdlineOptError', f'--{opt}: unrecognized option')
 			elif arg[0] == '-' and len(arg) > 1:
@@ -125,10 +151,11 @@ def process_uopts(opts_data, opts):
 	return uopts, uargs
 
 cmd_opts_pat = re.compile(r'^-([a-zA-Z0-9-]), --([a-zA-Z0-9-]{2,64})(=| )(.+)')
-global_opts_pat = re.compile(r'^\t\t\t(.)(.) --([a-zA-Z0-9-]{2,64})(=| )(.+)')
+global_opts_pat = re.compile(r'^\t\t\t(.)(.) --([a-z0-9-]{2,64})(=| )(.+)')
+global_opts_help_pat = re.compile(r'^\t\t\t(.)(.) (?:--([{}a-zA-Z0-9-]{2,64})(=| ))?(.+)')
 opt_tuple = namedtuple('cmdline_option', ['name', 'has_parm'])
 
-def parse_opts(opts_data, opt_filter, global_opts_data, global_opts_filter):
+def parse_opts(cfg, opts_data, opt_filter, global_opts_data, global_opts_filter, need_proto):
 
 	def parse_cmd_opts_text():
 		for line in opts_data['text']['options'].strip().splitlines():
@@ -146,7 +173,7 @@ def parse_opts(opts_data, opt_filter, global_opts_data, global_opts_filter):
 
 	opts = tuple(parse_cmd_opts_text()) + tuple(parse_global_opts_text())
 
-	uopts, uargs = process_uopts(opts_data, dict(opts))
+	uopts, uargs = process_uopts(cfg, opts_data, dict(opts), need_proto)
 
 	return namedtuple('parsed_cmd_opts', ['user_opts', 'cmd_args', 'opts'])(
 		uopts, # dict
@@ -214,10 +241,12 @@ class Opts:
 		self.opts_data = opts_data
 
 		po = parsed_opts or parse_opts(
+			cfg,
 			opts_data,
 			opt_filter,
 			self.global_opts_data,
-			self.global_opts_filter)
+			self.global_opts_filter,
+			need_proto)
 
 		cfg._args = po.cmd_args
 		cfg._uopts = uopts = po.user_opts
@@ -283,6 +312,20 @@ class UserOpts(Opts):
 			b- --bob                  Specify user ‘Bob’ in MMGen regtest mode
 			b- --alice                Specify user ‘Alice’ in MMGen regtest mode
 			b- --carol                Specify user ‘Carol’ in MMGen regtest mode
+			rr COIN-SPECIFIC OPTIONS:
+			rr   For descriptions, refer to the non-prefixed versions of these options above
+			rr   Prefixed options override their non-prefixed counterparts
+			rr   OPTION                            SUPPORTED PREFIXES
+			rr --PREFIX-ignore-daemon-version    btc ltc bch eth etc xmr
+			br --PREFIX-tw-name                  btc ltc bch
+			Rr --PREFIX-rpc-host                 btc ltc bch eth etc
+			rr --PREFIX-rpc-port                 btc ltc bch eth etc xmr
+			br --PREFIX-rpc-user                 btc ltc bch
+			br --PREFIX-rpc-password             btc ltc bch
+			Rr --PREFIX-max-tx-fee               btc ltc bch eth etc
+			Rr PROTO-SPECIFIC OPTIONS:
+			Rr   Option                            Supported Prefixes
+			Rr --PREFIX-chain-names              eth-mainnet eth-testnet etc-mainnet etc-testnet
 			""",
 		},
 		'code': {

+ 0 - 1
mmgen/proto/bch/params.py

@@ -28,7 +28,6 @@ class mainnet(mainnet):
 	caps = ()
 	coin_amt        = 'BCHAmt'
 	max_tx_fee      = 0.1
-	ignore_daemon_version = False
 	cashaddr_pfx    = 'bitcoincash'
 	cashaddr        = True
 

+ 11 - 1
mmgen/proto/btc/params.py

@@ -50,9 +50,19 @@ class mainnet(CoinProtocol.Secp256k1): # chainparams.cpp
 	diff_adjust_interval = 2016
 	max_halvings    = 64
 	start_subsidy   = 50
-	ignore_daemon_version = False
 	max_int         = 0xffffffff
 
+	coin_cfg_opts = (
+		'ignore_daemon_version',
+		'rpc_host',
+		'rpc_port',
+		'rpc_user',
+		'rpc_password',
+		'tw_name',
+		'max_tx_fee',
+		'cashaddr',
+	)
+
 	def encode_wif(self, privbytes, pubkey_type, compressed): # input is preprocessed hex
 		assert len(privbytes) == self.privkey_len, f'{len(privbytes)} bytes: incorrect private key length!'
 		assert pubkey_type in self.wif_ver_bytes, f'{pubkey_type!r}: invalid pubkey_type'

+ 15 - 9
mmgen/proto/btc/rpc.py

@@ -124,11 +124,13 @@ class BitcoinRPCClient(RPCClient, metaclass=AsyncInit):
 		self.proto = proto
 		self.daemon = daemon
 		self.call_sigs = getattr(CallSigs, daemon.id)(cfg)
-		self.twname = TrackingWalletName(cfg.regtest_user or cfg.tw_name or self.dfl_twname)
+		self.twname = TrackingWalletName(cfg.regtest_user or proto.tw_name or cfg.tw_name or self.dfl_twname)
 
 		super().__init__(
 			cfg  = cfg,
-			host = 'localhost' if cfg.test_suite or cfg.network == 'regtest' else (cfg.rpc_host or 'localhost'),
+			host = (
+				'localhost' if cfg.test_suite or cfg.network == 'regtest'
+				else (proto.rpc_host or cfg.rpc_host or 'localhost')),
 			port = daemon.rpc_port)
 
 		self.set_auth()
@@ -210,14 +212,15 @@ class BitcoinRPCClient(RPCClient, metaclass=AsyncInit):
 		"""
 		if self.cfg.network == 'regtest':
 			from .regtest import MMGenRegtest
-			user, passwd = (MMGenRegtest.rpc_user, MMGenRegtest.rpc_password)
-		elif self.cfg.rpc_user:
-			user, passwd = (self.cfg.rpc_user, self.cfg.rpc_password)
+			user = MMGenRegtest.rpc_user
+			passwd = MMGenRegtest.rpc_password
 		else:
-			user, passwd = self.get_daemon_cfg_options(('rpcuser', 'rpcpassword')).values()
-
-		if not (user and passwd):
-			user, passwd = (self.daemon.rpc_user, self.daemon.rpc_password)
+			user = (
+				self.proto.rpc_user or self.cfg.rpc_user or self.get_daemon_cfg_option('rpcuser')
+				or self.daemon.rpc_user)
+			passwd = (
+				self.proto.rpc_password or self.cfg.rpc_password or self.get_daemon_cfg_option('rpcpassword')
+				or self.daemon.rpc_password)
 
 		if user and passwd:
 			self.auth = auth_data(user, passwd)
@@ -260,6 +263,9 @@ class BitcoinRPCClient(RPCClient, metaclass=AsyncInit):
 			(os.path.dirname(self.cfg.data_dir) if self.proto.regtest else self.daemon.datadir),
 			self.daemon.cfg_file)
 
+	def get_daemon_cfg_option(self, req_key):
+		return list(self.get_daemon_cfg_options([req_key]).values())[0]
+
 	def get_daemon_cfg_options(self, req_keys):
 
 		fn = self.get_daemon_cfg_fn()

+ 0 - 1
mmgen/proto/etc/params.py

@@ -18,7 +18,6 @@ class mainnet(mainnet):
 	chain_names = ['classic', 'ethereum_classic']
 	max_tx_fee  = 0.005
 	coin_amt    = 'ETCAmt'
-	ignore_daemon_version = False
 
 class testnet(mainnet):
 	chain_names = ['morden', 'morden_testnet', 'classic-testnet']

+ 11 - 1
mmgen/proto/eth/params.py

@@ -35,7 +35,6 @@ class mainnet(CoinProtocol.DummyWIF, CoinProtocol.Secp256k1):
 	base_proto_coin = 'ETH'
 	base_coin     = 'ETH'
 	avg_bdi       = 15
-	ignore_daemon_version = False
 	decimal_prec  = 36
 
 	chain_ids = {
@@ -52,6 +51,17 @@ class mainnet(CoinProtocol.DummyWIF, CoinProtocol.Secp256k1):
 		711:  'ethereum',         # geth mainnet (empty chain)
 	}
 
+	coin_cfg_opts = (
+		'ignore_daemon_version',
+		'rpc_host',
+		'rpc_port',
+		'max_tx_fee',
+	)
+
+	proto_cfg_opts = (
+		'chain_names',
+	)
+
 	@property
 	def dcoin(self):
 		return self.tokensym or self.coin

+ 1 - 1
mmgen/proto/eth/rpc.py

@@ -48,7 +48,7 @@ class EthereumRPCClient(RPCClient, metaclass=AsyncInit):
 
 		super().__init__(
 			cfg  = cfg,
-			host = 'localhost' if cfg.test_suite else (cfg.rpc_host or 'localhost'),
+			host = 'localhost' if cfg.test_suite else (proto.rpc_host or cfg.rpc_host or 'localhost'),
 			port = daemon.rpc_port)
 
 		await self.set_backend_async(backend)

+ 0 - 1
mmgen/proto/ltc/params.py

@@ -26,7 +26,6 @@ class mainnet(mainnet):
 	bech32_hrp      = 'ltc'
 	avg_bdi         = 150
 	halving_interval = 840000
-	ignore_daemon_version = False
 
 class testnet(mainnet):
 	# addr ver nums same as Bitcoin testnet, except for 'p2sh'

+ 6 - 2
mmgen/proto/xmr/params.py

@@ -23,7 +23,7 @@ class MoneroViewKey(HexStr):
 	color, width, hexcase = 'cyan', 64, 'lower' # FIXME - no checking performed
 
 # https://github.com/monero-project/monero/blob/master/src/cryptonote_config.h
-class mainnet(CoinProtocol.DummyWIF, CoinProtocol.Base):
+class mainnet(CoinProtocol.RPC, CoinProtocol.DummyWIF, CoinProtocol.Base):
 
 	network_names  = _nw('mainnet', 'stagenet', None)
 	base_proto     = 'Monero'
@@ -37,10 +37,14 @@ class mainnet(CoinProtocol.DummyWIF, CoinProtocol.Base):
 	avg_bdi        = 120
 	privkey_len    = 32
 	mmcaps         = ('rpc',)
-	ignore_daemon_version = False
 	coin_amt       = 'XMRAmt'
 	sign_mode      = 'standalone'
 
+	coin_cfg_opts = (
+		'ignore_daemon_version',
+		'rpc_port',
+	)
+
 	def get_addr_len(self, addr_fmt):
 		return (64, 72)[addr_fmt == 'monero_integrated']
 

+ 41 - 1
mmgen/protocol.py

@@ -116,6 +116,11 @@ class CoinProtocol(MMGenObject):
 				self.coin_amt = None
 				self.max_tx_fee = None
 
+			self.set_cfg_opts()
+
+		def set_cfg_opts(self):
+			pass
+
 		@property
 		def dcoin(self):
 			return self.coin
@@ -192,8 +197,43 @@ class CoinProtocol(MMGenObject):
 			else:
 				return getattr(importlib.import_module(modpath), clsname)
 
+	class RPC:
+
+		# prefixed with coin, e.g. ‘ltc_rpc_host’: refvals taken from proto class
+		coin_cfg_opts = ()
+
+		# prefixed with coin + network, e.g. ‘eth_mainnet_chain_names’: refvals taken from proto class
+		proto_cfg_opts = ()
+
+		# default vals (refvals): bool(val) must be False (val = None -> option takes no parameter)
+		ignore_daemon_version = None
+		rpc_host              = ''
+		rpc_port              = 0
+		rpc_user              = ''
+		rpc_password          = ''
+		tw_name               = ''
+
+		@classmethod
+		def get_opt_clsval(cls, cfg, opt):
+			coin, *rem = opt.split('_', 2)
+			network = rem[0] if rem[0] in init_proto(cfg, coin, return_cls=True).network_names else None
+			opt_name = '_'.join(rem[bool(network):])
+			if ((network is None and opt_name in cls.coin_cfg_opts) or
+				(network and opt_name in cls.proto_cfg_opts)):
+				# raises AttributeError on failure:
+				return getattr(init_proto(cfg, coin, network=network, return_cls=True), opt_name)
+			else:
+				raise AttributeError(f'{opt_name}: unrecognized attribute')
+
+		def set_cfg_opts(self):
+			for opt in self.cfg.__dict__:
+				if opt.startswith(self.coin.lower() + '_'):
+					res = opt.split('_', 2)[1:]
+					network = res[0] if res[0] in self.network_names else None
+					if network is None or network == self.network:
+						setattr(self, '_'.join(res[bool(network):]), getattr(self.cfg, opt))
 
-	class Secp256k1(Base):
+	class Secp256k1(RPC, Base):
 		"""
 		Bitcoin and Ethereum protocols inherit from this class
 		"""

+ 3 - 3
test/cmdtest_d/ct_cfgfile.py

@@ -203,10 +203,10 @@ class CmdTestCfgFile(CmdTestBase):
 
 		for coin, res1_chk, res2_chk, res2_chk_eq in (
 			('BTC', 'True',  '1.2345', True),
-			('LTC', 'False', '1.2345', False),
-			('BCH', 'False', '1.2345', False),
+			('LTC', 'None',  '1.2345', False),
+			('BCH', 'None',  '1.2345', False),
 			('ETH', 'True',  '5.4321', True),
-			('ETC', 'False', '5.4321', False)
+			('ETC', 'None',  '5.4321', False)
 		):
 			if cfg.no_altcoin and coin != 'BTC':
 				continue

+ 116 - 2
test/cmdtest_d/ct_opts.py

@@ -46,6 +46,13 @@ class CmdTestOpts(CmdTestBase):
 		('opt_good22',           (41, 'good cmdline opt (opt + negated opt [substring])', [])),
 		('opt_good23',           (41, 'good cmdline opt (negated negative opt [substring])', [])),
 		('opt_good24',           (41, 'good cmdline opt (negated opt + opt [substring])', [])),
+		('opt_good25',           (41, 'good cmdline opt (--btc-rpc-host)', [])),
+		('opt_good26',           (41, 'good cmdline opt (--btc-rpc-port)', [])),
+		('opt_good27',           (41, 'good cmdline opt (--btc-ignore-daemon-version)', [])),
+		('opt_good28',           (41, 'good cmdline opt (--bch-cashaddr)', [])),
+		('opt_good29',           (41, 'good cmdline opt (--etc-max-tx-fee=0.1)', [])),
+		('opt_good30',           (41, 'good cmdline opt (--eth-chain-names=foo,bar)', [])),
+		('opt_good31',           (41, 'good cmdline opt (--xmr-rpc-port=28081)', [])),
 		('opt_bad_param',        (41, 'bad global opt (--pager=1)', [])),
 		('opt_bad_infile',       (41, 'bad infile parameter', [])),
 		('opt_bad_outdir',       (41, 'bad outdir parameter', [])),
@@ -65,6 +72,23 @@ class CmdTestOpts(CmdTestBase):
 		('opt_invalid_14',       (41, 'invalid cmdline opt (long opt substring too short)', [])),
 		('opt_invalid_15',       (41, 'invalid cmdline (too many args)', [])),
 		('opt_invalid_16',       (41, 'invalid cmdline (overlong arg)', [])),
+		('opt_invalid_17',       (41, 'invalid cmdline opt (--btc-rpc-host without ‘need_proto’)', [])),
+		('opt_invalid_18',       (41, 'invalid cmdline opt (--btc-rpc-port without ‘need_proto’)', [])),
+		('opt_invalid_19',       (41, 'invalid cmdline opt (--btc-rpc-port with non-integer param)', [])),
+		('opt_invalid_21',       (41, 'invalid cmdline opt (--btc-foo)', [])),
+		('opt_invalid_22',       (41, 'invalid cmdline opt (--btc-rpc-host with missing param)', [])),
+		('opt_invalid_23',       (41, 'invalid cmdline opt (--btc-ignore-daemon-version with param)', [])),
+		('opt_invalid_24',       (41, 'invalid cmdline opt (--bch-cashaddr without ‘need_proto’)', [])),
+		('opt_invalid_25',       (41, 'invalid cmdline opt (--bch-cashaddr without parameter)', [])),
+		('opt_invalid_26',       (41, 'invalid cmdline opt (--bch-cashaddr with non-bool parameter)', [])),
+		('opt_invalid_27',       (41, 'invalid cmdline opt (--ltc-cashaddr)', [])),
+		('opt_invalid_28',       (41, 'invalid cmdline opt (--xmr-max-tx-fee)', [])),
+		('opt_invalid_29',       (41, 'invalid cmdline opt (--eth-max-tx-fee without parameter)', [])),
+		('opt_invalid_30',       (41, 'invalid cmdline opt (--eth-max-tx-fee with non-numeric parameter)', [])),
+		('opt_invalid_31',       (41, 'invalid cmdline opt (--bch-cashaddr without --coin=bch)', [])),
+		('opt_invalid_32',       (41, 'invalid cmdline opt (--eth-chain-names without --coin=eth)', [])),
+		('opt_invalid_33',       (41, 'invalid cmdline opt (--xmr-rpc-host)', [])),
+		('opt_invalid_34',       (41, 'invalid cmdline opt (--eth-rpc-user)', [])),
 	)
 
 	def spawn_prog(self, args, opts=[], exit_val=None, need_proto=False):
@@ -242,6 +266,45 @@ class CmdTestOpts(CmdTestBase):
 	def opt_good24(self):
 		return self.check_vals(['--no-pag', '--pag'], (('cfg.pager', 'True'),))
 
+	def opt_good25(self):
+		return self.check_vals(
+			['--btc-rpc-host=pi5'],
+			(('cfg.btc_rpc_host', 'pi5'), ('proto.rpc_host', 'pi5')),
+			need_proto=True)
+
+	def opt_good26(self):
+		return self.check_vals(
+			['--btc-rpc-port=7272'],
+			(('cfg.btc_rpc_port', '7272'), ('proto.rpc_port', '7272')),
+			need_proto=True)
+
+	def opt_good27(self):
+		return self.check_vals(
+			['--btc-ignore-daemon-version'],
+			(('cfg.btc_ignore_daemon_version', 'True'), ('proto.ignore_daemon_version', 'True'),),
+			need_proto = True)
+
+	def opt_good28(self):
+		return self.check_vals(
+			['--coin=bch', '--bch-cashaddr=yes'],
+			(('cfg.bch_cashaddr', 'True'), ('proto.cashaddr', 'True'),),
+			need_proto = True)
+
+	def opt_good29(self):
+		return self.check_vals(['--etc-max-tx-fee=0.1'], (('cfg.etc_max_tx_fee', '0.1'),), need_proto=True)
+
+	def opt_good30(self):
+		return self.check_vals(
+			['--coin=eth', '--eth-mainnet-chain-names=foo,bar'],
+			(('cfg.eth_mainnet_chain_names', r"\['foo', 'bar'\]"), ('proto.chain_names', r"\['foo', 'bar'\]")),
+			need_proto = True)
+
+	def opt_good31(self):
+		return self.check_vals(
+			['--coin=xmr', '--xmr-rpc-port=28081'],
+			(('cfg.xmr_rpc_port', '28081'),('proto.rpc_port', '28081'),),
+			need_proto = True)
+
 	def opt_bad_param(self):
 		return self.do_run(['--pager=1'], 'no parameter', 1)
 
@@ -259,8 +322,8 @@ class CmdTestOpts(CmdTestBase):
 	def opt_bad_autoset(self):
 		return self.do_run(['--fee-estimate-mode=Fubar'], 'not unique substring', 1)
 
-	def opt_invalid(self, args, expect, exit_val=1):
-		t = self.spawn_prog(args, exit_val=exit_val)
+	def opt_invalid(self, args, expect, opts=[], need_proto=False, exit_val=1):
+		t = self.spawn_prog(args, opts=opts, exit_val=exit_val, need_proto=need_proto)
 		t.expect(expect)
 		return t
 
@@ -305,3 +368,54 @@ class CmdTestOpts(CmdTestBase):
 
 	def opt_invalid_16(self):
 		return self.opt_invalid(['e' * 4097], 'too long')
+
+	def opt_invalid_17(self):
+		return self.opt_invalid(['--btc-rpc-host'], 'unrecognized option')
+
+	def opt_invalid_18(self):
+		return self.opt_invalid(['--btc-rpc-port'], 'unrecognized option')
+
+	def opt_invalid_19(self):
+		return self.opt_invalid(['--btc-rpc-port=foo'], "must be of type 'int'", need_proto=True)
+
+	def opt_invalid_21(self):
+		return self.opt_invalid(['--btc-foo'], 'unrecognized option')
+
+	def opt_invalid_22(self):
+		return self.opt_invalid(['--btc-rpc-host'], 'missing parameter', need_proto=True)
+
+	def opt_invalid_23(self):
+		return self.opt_invalid(['--btc-ignore-daemon-version=1'], 'requires no parameter', need_proto=True)
+
+	def opt_invalid_24(self):
+		return self.opt_invalid(['--bch-cashaddr'], 'unrecognized option')
+
+	def opt_invalid_25(self):
+		return self.opt_invalid(['--bch-cashaddr'], 'missing parameter', need_proto=True)
+
+	def opt_invalid_26(self):
+		return self.opt_invalid(['--bch-cashaddr=foo'], "must be of type 'bool'", need_proto=True)
+
+	def opt_invalid_27(self):
+		return self.opt_invalid(['--ltc-cashaddr'], 'unrecognized option', need_proto=True)
+
+	def opt_invalid_28(self):
+		return self.opt_invalid(['--xmr-max-tx-fee=0.1'], 'unrecognized option', need_proto=True)
+
+	def opt_invalid_29(self):
+		return self.opt_invalid(['--eth-max-tx-fee'], 'missing parameter', need_proto=True)
+
+	def opt_invalid_30(self):
+		return self.opt_invalid(['--eth-max-tx-fee=true'], 'must be of type', need_proto=True)
+
+	def opt_invalid_31(self):
+		return self.opt_invalid(['--bch-cashaddr=true'], 'has no attribute', opts=['--show-opts=bch_cashaddr'], need_proto=True)
+
+	def opt_invalid_32(self):
+		return self.opt_invalid(['--eth-chain-names=foo,bar'], 'unrecognized option', need_proto=True)
+
+	def opt_invalid_33(self):
+		return self.opt_invalid(['--xmr-rpc-host=solaris'], 'unrecognized option', need_proto=True)
+
+	def opt_invalid_34(self):
+		return self.opt_invalid(['--eth-rpc-user=bob'], 'unrecognized option', need_proto=True)

+ 29 - 3
test/daemontest_d/ut_rpc.py

@@ -78,6 +78,9 @@ async def print_daemon_info(rpc):
     WALLETINFO:     {fmt_dict(await rpc.walletinfo)}
 		""".rstrip())
 
+	if rpc.proto.base_proto == 'Ethereum':
+		msg(f'    CHAIN_NAMES:    {" ".join(rpc.daemon.proto.chain_names)}')
+
 	msg('')
 
 def do_msg(rpc, backend):
@@ -92,7 +95,9 @@ class init_test:
 		do_msg(rpc, backend)
 
 		wi = await rpc.walletinfo
-		assert wi['walletname'] == cfg_override['tw_name']
+		assert wi['walletname'] == cfg_override['btc_tw_name']
+		assert wi['walletname'] == rpc.cfg._proto.tw_name, f'{wi["walletname"]!r} != {rpc.cfg._proto.tw_name!r}'
+		assert daemon.bind_port == cfg_override['btc_rpc_port']
 
 		bh = (await rpc.call('getblockchaininfo', timeout=300))['bestblockhash']
 		await rpc.gathered_call('getblock', ((bh,), (bh, 1)), timeout=300)
@@ -112,6 +117,9 @@ class init_test:
 		rpc = await rpc_init(cfg, daemon.proto, backend, daemon)
 		do_msg(rpc, backend)
 		await rpc.call('eth_blockNumber', timeout=300)
+		if rpc.proto.network == 'testnet':
+			assert daemon.proto.chain_names == cfg_override['eth_testnet_chain_names']
+			assert daemon.bind_port == cfg_override['eth_rpc_port']
 		return rpc
 
 	etc = eth
@@ -166,7 +174,15 @@ class unit_tests:
 		return await run_test(
 			['btc', 'btc_tn'],
 			test_cf_auth = True,
-			cfg_override = {'_clone': cfg, 'tw_name': 'alternate-tracking-wallet'})
+			cfg_override = {
+				'_clone': cfg,
+				'btc_rpc_port': 19777,
+				'rpc_port':     32323, # ignored
+				'btc_tw_name': 'alternate-tracking-wallet',
+				'tw_name':     'this-is-overridden',
+				'ltc_tw_name': 'this-is-ignored',
+				'eth_mainnet_chain_names': ['also', 'ignored'],
+		})
 
 	async def ltc(self, name, ut):
 		return await run_test(['ltc', 'ltc_tn'], test_cf_auth=True)
@@ -176,7 +192,17 @@ class unit_tests:
 
 	async def geth(self, name, ut):
 		# mainnet returns EIP-155 error on empty blockchain:
-		return await run_test(['eth_tn', 'eth_rt'], daemon_ids=['geth'])
+		return await run_test(
+			['eth_tn', 'eth_rt'],
+			daemon_ids = ['geth'],
+			cfg_override = {
+				'_clone': cfg,
+				'eth_rpc_port': 19777,
+				'rpc_port':     32323, # ignored
+				'btc_tw_name': 'ignored',
+				'tw_name':     'also-ignored',
+				'eth_testnet_chain_names': ['goerli', 'foo', 'bar', 'baz'],
+		})
 
 	async def erigon(self, name, ut):
 		return await run_test(['eth', 'eth_tn', 'eth_rt'], daemon_ids=['erigon'])

+ 5 - 0
test/misc/opts_main.py

@@ -60,6 +60,11 @@ if cfg.show_opts:
 	col1_w = max(len(s) for s in opts) + 5
 	for opt in opts:
 		msg('{:{w}} {}'.format(f'cfg.{opt}:', getattr(cfg, opt), w=col1_w))
+		if cfg._proto:
+			coin, *rem = opt.split('_')
+			network = rem[0] if rem[0] in cfg._proto.network_names else None
+			opt_name = '_'.join(rem[bool(network):])
+			msg('{:{w}} {}'.format(f'proto.{opt_name}:', getattr(cfg._proto, opt_name), w=col1_w))
 
 msg('')
 for n, arg in enumerate(cfg._args, 1):