Browse Source

tooltest2.py - add BTC test vectors
tooltest.py - bugfixes, remove some commands covered in tooltest2.py
mmgen-tool - bugfixes, cleanups, rename some commands, change some command
options
- all commands taking binary input can now receive it from file
or stdin

+ numerous minor fixes throughout

MMGen 6 years ago
parent
commit
0879e53e74

+ 3 - 0
data_files/mmgen.cfg

@@ -65,6 +65,9 @@
 # Set the maximum transaction file size:
 # max_tx_file_size 100000
 
+# Set the maximum input size - applies both to files and standard input:
+# max_input_size 1048576
+
 # Set the Ethereum mainnet name
 # eth_mainnet_chain_name foundation
 

+ 10 - 9
mmgen/addr.py

@@ -627,15 +627,16 @@ Removed {{}} duplicate WIF key{{}} from keylist (also in {pnm} key-address file
 
 			ret.append(a)
 
-		if self.has_keys and keypress_confirm('Check key-to-address validity?'):
-			kg = KeyGenerator(self.al_id.mmtype)
-			ag = AddrGenerator(self.al_id.mmtype)
-			llen = len(ret)
-			for n,e in enumerate(ret):
-				msg_r('\rVerifying keys {}/{}'.format(n+1,llen))
-				assert e.addr == ag.to_addr(kg.to_pubhex(e.sec)),(
-					"Key doesn't match address!\n  {}\n  {}".format(e.sec.wif,e.addr))
-			msg(' - done')
+		if self.has_keys:
+			if (hasattr(opt,'yes') and opt.yes) or keypress_confirm('Check key-to-address validity?'):
+				kg = KeyGenerator(self.al_id.mmtype)
+				ag = AddrGenerator(self.al_id.mmtype)
+				llen = len(ret)
+				for n,e in enumerate(ret):
+					qmsg_r('\rVerifying keys {}/{}'.format(n+1,llen))
+					assert e.addr == ag.to_addr(kg.to_pubhex(e.sec)),(
+						"Key doesn't match address!\n  {}\n  {}".format(e.sec.wif,e.addr))
+				qmsg(' - done')
 
 		return ret
 

+ 5 - 3
mmgen/crypto.py

@@ -182,7 +182,8 @@ def mmgen_encrypt(data,desc='data',hash_preset=''):
 	salt  = get_random(_salt_len)
 	iv    = get_random(g.aesctr_iv_len)
 	nonce = get_random(_nonce_len)
-	hp    = hash_preset or get_hash_preset_from_user('3',desc)
+	hp    = hash_preset or (
+		opt.hash_preset if 'hash_preset' in opt.set_by_user else get_hash_preset_from_user('3',desc))
 	m     = ('user-requested','default')[hp=='3']
 	vmsg('Encrypting {}'.format(desc))
 	qmsg("Using {} hash preset of '{}'".format(m,hp))
@@ -192,12 +193,13 @@ def mmgen_encrypt(data,desc='data',hash_preset=''):
 	return salt+iv+enc_d
 
 def mmgen_decrypt(data,desc='data',hash_preset=''):
+	vmsg('Preparing to decrypt {}'.format(desc))
 	dstart = _salt_len + g.aesctr_iv_len
 	salt   = data[:_salt_len]
 	iv     = data[_salt_len:dstart]
 	enc_d  = data[dstart:]
-	vmsg('Preparing to decrypt {}'.format(desc))
-	hp = hash_preset or get_hash_preset_from_user('3',desc)
+	hp     = hash_preset or (
+		opt.hash_preset if 'hash_preset' in opt.set_by_user else get_hash_preset_from_user('3',desc))
 	m  = ('user-requested','default')[hp=='3']
 	qmsg("Using {} hash preset of '{}'".format(m,hp))
 	passwd = get_mmgen_passphrase(desc)

+ 1 - 0
mmgen/exception.py

@@ -32,6 +32,7 @@ class TokenNotInBlockchain(Exception):    mmcode = 2
 # 3: yellow hl, 'MMGen Error' + exception + message
 class RPCFailure(Exception):              mmcode = 3
 class BadTxSizeEstimate(Exception):       mmcode = 3
+class MaxInputSizeExceeded(Exception):    mmcode = 3
 
 # 4: red hl, 'MMGen Fatal Error' + exception + message
 class BadMMGenTxID(Exception):            mmcode = 4

+ 2 - 1
mmgen/globalvars.py

@@ -140,7 +140,7 @@ class g(object):
 		'daemon_data_dir','force_256_color','regtest',
 		'btc_max_tx_fee','ltc_max_tx_fee','bch_max_tx_fee','eth_max_tx_fee',
 		'eth_mainnet_chain_name','eth_testnet_chain_name',
-		'max_tx_file_size'
+		'max_tx_file_size','max_input_size'
 	)
 	env_opts = (
 		'MMGEN_BOGUS_WALLET_DATA',
@@ -163,6 +163,7 @@ class g(object):
 	min_screen_width = 80
 	minconf = 1
 	max_tx_file_size = 100000
+	max_input_size   = 1024 * 1024
 
 	# Global var sets user opt:
 	global_sets_opt = ['minconf','seed_len','hash_preset','usr_randchars','debug',

+ 4 - 2
mmgen/main_tool.py

@@ -38,7 +38,7 @@ def make_cmd_help():
 				out.append('')
 		out.append('')
 
-		cls_funcs = bc.user_commands()
+		cls_funcs = bc._user_commands()
 		max_w = max(map(len,cls_funcs))
 		fs = '  {{:{}}} - {{}}'.format(max_w)
 		for func in cls_funcs:
@@ -60,6 +60,8 @@ opts_data = lambda: {
 -d, --outdir=       d Specify an alternate directory 'd' for output
 -h, --help            Print this help message
 --, --longhelp        Print help message for long options (common options)
+-p, --hash-preset= p  Use the scrypt hash parameters defined by preset 'p'
+                      for password hashing (default: '{g.hash_preset}')
 -P, --passwd-file= f  Get passphrase from file 'f'.
 -q, --quiet           Produce quieter output
 -r, --usr-randchars=n Get 'n' characters of additional randomness from
@@ -95,4 +97,4 @@ args,kwargs = tool._process_args(cmd,cmd_args)
 
 ret = getattr(tc,cmd)(*args,**kwargs)
 
-tool._process_result(ret,to_screen=True,pager='pager' in kwargs and kwargs['pager'])
+tool._process_result(ret,pager='pager' in kwargs and kwargs['pager'],print_result=True)

+ 7 - 5
mmgen/protocol.py

@@ -95,6 +95,7 @@ class BitcoinProtocol(MMGenObject):
 	bech32_hrp         = 'bc'
 	sign_mode          = 'daemon'
 	secp256k1_ge       = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141
+	privkey_len        = 32
 
 	@classmethod
 	def is_testnet(cls):
@@ -124,6 +125,7 @@ class BitcoinProtocol(MMGenObject):
 
 	@classmethod
 	def hex2wif(cls,hexpriv,pubkey_type,compressed): # PrivKey
+		assert len(hexpriv) == cls.privkey_len*2, '{} bytes: incorrect private key length!'.format(len(hexpriv)//2)
 		return _b58chk_encode(cls.wif_ver_num[pubkey_type] + hexpriv + (b'',b'01')[bool(compressed)])
 
 	@classmethod
@@ -405,9 +407,9 @@ class MoneroProtocol(DummyWIF,BitcoinProtocolAddrgen):
 
 		def b58dec(addr_str):
 			from mmgen.util import baseconv
-			dec,l = baseconv.tohex,len(addr_str)
-			a = ''.join([dec(addr_str[i*11:i*11+11],'b58',pad=16) for i in range(l//11)])
-			b = dec(addr_str[-(l%11):],'b58',pad=10)
+			l = len(addr_str)
+			a = b''.join([baseconv.tohex(addr_str[i*11:i*11+11],'b58',pad=16) for i in range(l//11)])
+			b = baseconv.tohex(addr_str[-(l%11):],'b58',pad=10)
 			return a + b
 
 		from mmgen.util import is_b58_str
@@ -417,9 +419,9 @@ class MoneroProtocol(DummyWIF,BitcoinProtocolAddrgen):
 		ret = b58dec(addr)
 		import sha3
 		chk = sha3.keccak_256(unhexlify(ret)[:-4]).hexdigest()[:8]
-		assert chk == ret[-8:],'Incorrect checksum'
+		assert chk.encode() == ret[-8:],'Incorrect checksum'
 
-		return { 'hex': ret.encode(), 'format': 'monero' } if return_dict else True
+		return { 'hex': ret, 'format': 'monero' } if return_dict else True
 
 class MoneroTestnetProtocol(MoneroProtocol):
 	addr_ver_num = { 'monero': (b'35','4'), 'monero_sub': (b'3f','8') } # 53,63

+ 27 - 11
mmgen/seed.py

@@ -34,6 +34,18 @@ def check_usr_seed_len(seed_len):
 		m = "ERROR: requested seed length ({}) doesn't match seed length of source ({})"
 		die(1,m.format((opt.seed_len,seed_len)))
 
+def is_mnemonic(s):
+	oq_save = opt.quiet
+	opt.quiet = True
+	try:
+		SeedSource(in_data=s,in_fmt='words')
+		ret = True
+	except:
+		ret = False
+	finally:
+		opt.quiet = oq_save
+	return ret
+
 class Seed(MMGenObject):
 	def __init__(self,seed_bin=None):
 		if not seed_bin:
@@ -63,7 +75,9 @@ class SeedSource(MMGenObject):
 
 	class SeedSourceData(MMGenObject): pass
 
-	def __new__(cls,fn=None,ss=None,seed=None,ignore_in_fmt=False,passchg=False):
+	def __new__(cls,fn=None,ss=None,seed=None,ignore_in_fmt=False,passchg=False,in_data=None,in_fmt=None):
+
+		in_fmt = in_fmt or opt.in_fmt
 
 		def die_on_opt_mismatch(opt,sstype):
 			opt_sstype = cls.fmt_code_to_type(opt)
@@ -86,13 +100,13 @@ class SeedSource(MMGenObject):
 				# permit comma in filename
 				fn = ','.join(opt.hidden_incog_input_params.split(',')[:-1])
 				f = Filename(fn,ftype=IncogWalletHidden)
-			if opt.in_fmt and not ignore_in_fmt:
-				die_on_opt_mismatch(opt.in_fmt,f.ftype)
+			if in_fmt and not ignore_in_fmt:
+				die_on_opt_mismatch(in_fmt,f.ftype)
 			me = super(cls,cls).__new__(f.ftype)
 			me.infile = f
 			me.op = ('old','pwchg_old')[bool(passchg)]
-		elif opt.in_fmt:  # Input format
-			sstype = cls.fmt_code_to_type(opt.in_fmt)
+		elif in_fmt:  # Input format
+			sstype = cls.fmt_code_to_type(in_fmt)
 			me = super(cls,cls).__new__(sstype)
 			me.op = ('old','pwchg_old')[bool(passchg)]
 		else: # Called with no inputs - initialize with random seed
@@ -104,10 +118,11 @@ class SeedSource(MMGenObject):
 
 		return me
 
-	def __init__(self,fn=None,ss=None,seed=None,ignore_in_fmt=False,passchg=False):
+	def __init__(self,fn=None,ss=None,seed=None,ignore_in_fmt=False,passchg=False,in_data=None,in_fmt=None):
 
 		self.ssdata = self.SeedSourceData()
 		self.msg = {}
+		self.in_data = in_data
 
 		for c in reversed(self.__class__.__mro__):
 			if hasattr(c,'_msg'):
@@ -116,7 +131,7 @@ class SeedSource(MMGenObject):
 		if hasattr(self,'seed'):
 			self._encrypt()
 			return
-		elif hasattr(self,'infile') or not g.stdin_tty:
+		elif hasattr(self,'infile') or self.in_data or not g.stdin_tty:
 			self._deformat_once()
 			self._decrypt_retry()
 		else:
@@ -130,8 +145,9 @@ class SeedSource(MMGenObject):
 
 	def _get_data(self):
 		if hasattr(self,'infile'):
-			self.fmt_data = get_data_from_file(self.infile.name,self.desc,
-								binary=self.file_mode=='binary',require_utf8=self.require_utf8_input)
+			self.fmt_data = get_data_from_file(self.infile.name,self.desc,binary=self.file_mode=='binary')
+		elif self.in_data:
+			self.fmt_data = self.in_data
 		else:
 			self.fmt_data = self._get_data_from_user(self.desc)
 
@@ -437,7 +453,7 @@ class Mnemonic (SeedSourceUnenc):
 		hexseed = self.seed.hexdata
 
 		mn  = baseconv.fromhex(hexseed,self.wl_id,self._hex2mn_pad(hexseed))
-		ret = baseconv.tohex(mn,self.wl_id,self._mn2hex_pad(mn)).encode()
+		ret = baseconv.tohex(mn,self.wl_id,self._mn2hex_pad(mn))
 
 		# Internal error, so just die on fail
 		compare_or_die(ret,'recomputed seed',hexseed,'original',e='Internal error')
@@ -459,7 +475,7 @@ class Mnemonic (SeedSourceUnenc):
 				msg('Invalid mnemonic: word #{} is not in the wordlist'.format(n))
 				return False
 
-		hexseed = baseconv.tohex(mn,self.wl_id,self._mn2hex_pad(mn)).encode()
+		hexseed = baseconv.tohex(mn,self.wl_id,self._mn2hex_pad(mn))
 		ret     = baseconv.fromhex(hexseed,self.wl_id,self._hex2mn_pad(hexseed))
 
 		if len(hexseed) * 4 not in g.seed_lens:

+ 100 - 59
mmgen/tool.py

@@ -43,12 +43,15 @@ def _create_call_sig(cmd,parsed=False):
 
 	nargs = len(args) - len(dfls)
 
+	def get_type_from_ann(arg):
+		return ann[arg][1:] + (' or STDIN','')[parsed] if ann[arg] == 'sstr' else ann[arg].__name__
+
 	if parsed:
-		c_args = [(a,'str' if ann[a] == 'sstr' else ann[a].__name__) for a in args[:nargs]]
+		c_args = [(a,get_type_from_ann(a)) for a in args[:nargs]]
 		c_kwargs = [(a,dfls[n]) for n,a in enumerate(args[nargs:])]
 		return c_args,dict(c_kwargs),'STDIN_OK' if c_args and ann[args[0]] == 'sstr' else flag
 	else:
-		c_args = ['{} [{}]'.format(a,'str or STDIN' if ann[a] == 'sstr' else ann[a].__name__) for a in args[:nargs]]
+		c_args = ['{} [{}]'.format(a,get_type_from_ann(a)) for a in args[:nargs]]
 		c_kwargs = ['"{}" [{}={!r}{}]'.format(
 					a, type(dfls[n]).__name__, dfls[n],
 					(' ' + ann[a] if a in ann else ''))
@@ -85,14 +88,14 @@ def _usage(cmd=None,exit_val=1):
 		for bc in MMGenToolCmd.__bases__:
 			cls_info = bc.__doc__.strip().split('\n')[0]
 			Msg('  {}{}\n'.format(cls_info[0].upper(),cls_info[1:]))
-			ucmds = bc.user_commands()
+			ucmds = bc._user_commands()
 			max_w = max(map(len,ucmds))
 			for cmd in ucmds:
 				if getattr(MMGenToolCmd,cmd).__doc__:
 					Msg('    {:{w}} {}'.format(cmd,_create_call_sig(cmd),w=max_w))
 			Msg('')
 		Msg(m2)
-	elif cmd in MMGenToolCmd.user_commands():
+	elif cmd in MMGenToolCmd._user_commands():
 		msg('USAGE: {} {} {}'.format(g.prog_name,cmd,_create_call_sig(cmd)))
 	else:
 		die(1,"'{}': no such tool command".format(cmd))
@@ -101,6 +104,7 @@ def _usage(cmd=None,exit_val=1):
 
 def _process_args(cmd,cmd_args):
 	c_args,c_kwargs,flag = _create_call_sig(cmd,parsed=True)
+	have_stdin_input = False
 
 	if flag != 'VAR_ARGS':
 		if len(cmd_args) < len(c_args):
@@ -115,7 +119,14 @@ def _process_args(cmd,cmd_args):
 			if sys.stdin.isatty():
 				raise BadFilename("Standard input is a TTY.  Can't use '-' as a filename")
 			else:
-				u_args[0] = sys.stdin.read().strip()
+				max_dlen_spec = '10kB' # limit input to 10KB for now
+				max_dlen = MMGenToolCmdUtil().bytespec(max_dlen_spec)
+				u_args[0] = os.read(0,max_dlen)
+# 				try: u_args[0] = u_args[0].decode()
+# 				except: pass
+				have_stdin_input = True
+				if len(u_args[0]) >= max_dlen:
+					die(2,'Maximum data input for this command is {}'.format(max_dlen_spec))
 				if not u_args[0]:
 					die(2,'{}: ERROR: no output from previous command in pipe'.format(cmd))
 
@@ -144,13 +155,19 @@ def _process_args(cmd,cmd_args):
 			_usage(cmd)
 
 	def conv_type(arg,arg_name,arg_type):
-		if arg_type == 'bytes': pdie(arg,arg_name,arg_type)
+		if arg_type == 'bytes' and type(arg) != bytes:
+			die(1,"'Binary input data must be supplied via STDIN")
+
+		if have_stdin_input and arg_type == 'str' and type(arg) == bytes:
+			arg = arg.decode().rstrip('\n')
+
 		if arg_type == 'bool':
 			if arg.lower() in ('true','yes','1','on'): arg = True
 			elif arg.lower() in ('false','no','0','off'): arg = False
 			else:
 				msg("'{}': invalid boolean value for keyword argument".format(arg))
 				_usage(cmd)
+
 		try:
 			return __builtins__[arg_type](arg)
 		except:
@@ -164,24 +181,31 @@ def _process_args(cmd,cmd_args):
 
 	return args,kwargs
 
-def _process_result(ret,to_screen=False,pager=False): # returns a string or string subclass
-	do_ret = not to_screen
+def _process_result(ret,pager=False,print_result=False):
+	"""
+	Convert result to something suitable for output to screen and return it.
+	If result is bytes and not convertible to utf8, output as binary using os.write().
+	If 'print_result' is True, send the converted result directly to screen or
+	pager instead of returning it.
+	"""
+	def triage_result(o):
+		return o if not print_result else do_pager(o) if pager else Msg(o)
+
 	if issubclass(type(ret),str):
-		return ret if do_ret else do_pager(ret) if pager else Msg(ret)
+		return triage_result(ret)
+	elif issubclass(type(ret),int):
+		return triage_result(str(ret))
 	elif type(ret) == tuple:
-		o = '\n'.join([r.decode() if issubclass(type(r),bytes) else r for r in ret])
-		return o if do_ret else do_pager(o) if pager else Msg(o)
+		return triage_result('\n'.join([r.decode() if issubclass(type(r),bytes) else r for r in ret]))
 	elif issubclass(type(ret),bytes):
-		if do_ret:
-			try: return ret.decode()
-			except: return repr(ret)
-		else:
-			try:
-				o = ret.decode()
-				do_pager(o) if pager else Msg(o)
-			except: os.write(1,ret)
+		try:
+			o = ret.decode()
+			return o if not print_result else do_pager(o) if pager else Msg(o)
+		except:
+			# don't add NL to binary data if it can't be converted to utf8
+			return ret if not print_result else os.write(1,ret)
 	elif ret == True:
-		if do_ret: return ''
+		return True
 	elif ret in (False,None):
 		ydie(1,"tool command returned '{}'".format(ret))
 	else:
@@ -202,7 +226,7 @@ dfl_wl_id = 'electrum'
 class MMGenToolCmdBase(object):
 
 	@classmethod
-	def user_commands(cls):
+	def _user_commands(cls):
 		return [e for e in dir(cls) if e[0] != '_' and getattr(cls,e).__doc__]
 
 
@@ -220,7 +244,7 @@ class MMGenToolCmdUtil(MMGenToolCmdBase):
 
 	def bytespec(self,dd_style_byte_specifier:str):
 		"convert a byte specifier such as '1GB' into an integer"
-		return str(parse_bytespec(dd_style_byte_specifier))
+		return parse_bytespec(dd_style_byte_specifier)
 
 	def randhex(self,nbytes='32'):
 		"print 'n' bytes (default 32) of random data in hex format"
@@ -230,18 +254,22 @@ class MMGenToolCmdUtil(MMGenToolCmdBase):
 		"reverse bytes of a hexadecimal string"
 		return hexlify(unhexlify(hexstr.strip())[::-1])
 
-	def hexlify(self,hexstr:'sstr'):
-		"display string in hexadecimal format"
-		return hexlify(hexstr.encode())
+	def hexlify(self,infile:str):
+		"convert bytes in file to hexadecimal (use '-' for stdin)"
+		data = get_data_from_file(infile,dash=True,silent=True,binary=True)
+		return hexlify(data)
+
+	def unhexlify(self,hexstr:'sstr'):
+		"convert hexadecimal value to bytes (warning: outputs binary data)"
+		return unhexlify(hexstr.encode())
 
 	def hexdump(self,infile:str,cols=8,line_nums=True):
-		"encode data into formatted hexadecimal form (file or stdin)"
-		return pretty_hexdump(
-				get_data_from_file(infile,dash=True,silent=True,binary=True),
-					cols=cols,line_nums=line_nums)
+		"create hexdump of data from file (use '-' for stdin)"
+		data = get_data_from_file(infile,dash=True,silent=True,binary=True)
+		return pretty_hexdump(data,cols=cols,line_nums=line_nums).rstrip()
 
 	def unhexdump(self,infile:str):
-		"decode formatted hexadecimal data (file or stdin)"
+		"decode hexdump from file (use '-' for stdin) (warning: outputs binary data)"
 		if g.platform == 'win':
 			import msvcrt
 			msvcrt.setmode(sys.stdout.fileno(),os.O_BINARY)
@@ -258,10 +286,10 @@ class MMGenToolCmdUtil(MMGenToolCmdBase):
 		if file_input:  b = get_data_from_file(string_or_bytes,binary=True)
 		elif hex_input: b = decode_pretty_hexdump(string_or_bytes)
 		else:           b = string_or_bytes
-		return sha256(sha256(b.encode()).digest()).hexdigest()
+		return sha256(sha256(b.encode()).digest()).hexdigest().encode()
 
 	def id6(self,infile:str):
-		"generate 6-character MMGen ID for a file (or stdin)"
+		"generate 6-character MMGen ID for a file (use '-' for stdin)"
 		return make_chksum_6(
 			get_data_from_file(infile,dash=True,silent=True,binary=True))
 
@@ -270,30 +298,30 @@ class MMGenToolCmdUtil(MMGenToolCmdBase):
 		return make_chksum_6(''.join(string.split()))
 
 	def id8(self,infile:str):
-		"generate 8-character MMGen ID for a file (or stdin)"
+		"generate 8-character MMGen ID for a file (use '-' for stdin)"
 		return make_chksum_8(
 			get_data_from_file(infile,dash=True,silent=True,binary=True))
 
-	def b58randenc(self):
-		"generate a random 32-byte number and convert it to base 58"
-		r = get_random(32)
-		return baseconv.b58encode(r,pad=True)
+	def randb58(self,nbytes=32,pad=True):
+		"generate random data (default: 32 bytes) and convert it to base 58"
+		return baseconv.b58encode(get_random(nbytes),pad=pad)
 
-	def strtob58(self,string:'sstr',pad=0):
-		"convert a string to base 58"
-		return baseconv.fromhex(hexlify(string.encode()),'b58',pad,tostr=True)
+	def bytestob58(self,infile:str,pad=0):
+		"convert bytes to base 58 (supply data via STDIN)"
+		data = get_data_from_file(infile,dash=True,silent=True,binary=True)
+		return baseconv.fromhex(hexlify(data),'b58',pad=pad,tostr=True)
 
-	def b58tostr(self,b58num:'sstr'):
-		"convert a base 58 number to a string"
-		return unhexlify(baseconv.tohex(b58num,'b58'))
+	def b58tobytes(self,b58num:'sstr',pad=0):
+		"convert a base 58 number to bytes (warning: outputs binary data)"
+		return unhexlify(baseconv.tohex(b58num,'b58',pad=pad))
 
 	def hextob58(self,hexstr:'sstr',pad=0):
 		"convert a hexadecimal number to base 58"
-		return baseconv.fromhex(hexstr.encode(),'b58',pad,tostr=True)
+		return baseconv.fromhex(hexstr.encode(),'b58',pad=pad,tostr=True)
 
 	def b58tohex(self,b58num:'sstr',pad=0):
 		"convert a base 58 number to hexadecimal"
-		return baseconv.tohex(b58num,'b58',pad)
+		return baseconv.tohex(b58num,'b58',pad=pad)
 
 	def hextob58chk(self,hexstr:'sstr'):
 		"convert a hexadecimal number to base58-check encoding"
@@ -306,11 +334,11 @@ class MMGenToolCmdUtil(MMGenToolCmdBase):
 		return _b58chk_decode(b58chk_num)
 
 	def hextob32(self,hexstr:'sstr',pad=0):
-		"convert a hexadecimal number to base 32"
+		"convert a hexadecimal number to MMGen's flavor of base 32"
 		return baseconv.fromhex(hexstr.encode(),'b32',pad,tostr=True)
 
 	def b32tohex(self,b32num:'sstr',pad=0):
-		"convert a base 32 number to hexadecimal"
+		"convert an MMGen-flavor base 32 number to hexadecimal"
 		return baseconv.tohex(b32num.upper(),'b32',pad)
 
 class MMGenToolCmdCoin(MMGenToolCmdBase):
@@ -353,12 +381,14 @@ class MMGenToolCmdCoin(MMGenToolCmdBase):
 
 	def wif2redeem_script(self,wifkey:'sstr'): # new
 		"convert a WIF private key to a Segwit P2SH-P2WPKH redeem script"
+		assert opt.type == 'segwit','This command is meaningful only for --type=segwit'
 		init_generators()
 		privhex = PrivKey(wif=wifkey)
 		return ag.to_segwit_redeem_script(kg.to_pubhex(privhex))
 
 	def wif2segwit_pair(self,wifkey:'sstr'):
 		"generate both a Segwit P2SH-P2WPKH redeem script and address from WIF"
+		assert opt.type == 'segwit','This command is meaningful only for --type=segwit'
 		init_generators()
 		pubhex = kg.to_pubhex(PrivKey(wif=wifkey))
 		addr = ag.to_addr(pubhex)
@@ -378,10 +408,14 @@ class MMGenToolCmdCoin(MMGenToolCmdBase):
 
 	def pubhex2addr(self,pubkeyhex:'sstr'):
 		"convert a hex pubkey to an address"
-		return self.pubhash2addr(hash160(pubkeyhex.encode()).decode())
+		if opt.type == 'segwit':
+			return g.proto.pubhex2segwitaddr(pubkeyhex.encode())
+		else:
+			return self.pubhash2addr(hash160(pubkeyhex.encode()).decode())
 
 	def pubhex2redeem_script(self,pubkeyhex:'sstr'): # new
 		"convert a hex pubkey to a Segwit P2SH-P2WPKH redeem script"
+		assert opt.type == 'segwit','This command is meaningful only for --type=segwit'
 		return g.proto.pubhex2redeem_script(pubkeyhex)
 
 	def pubhash2addr(self,pubhashhex:'sstr'):
@@ -392,8 +426,8 @@ class MMGenToolCmdCoin(MMGenToolCmdBase):
 			init_generators('at')
 			return g.proto.pubhash2addr(pubhashhex.encode(),at.addr_fmt=='p2sh')
 
-	def addr2hexaddr(self,addr:'sstr'):
-		"convert coin address from base58 to hex format"
+	def addr2pubhash(self,addr:'sstr'):
+		"convert coin address to public key hash"
 		return g.proto.verify_addr(addr,CoinAddr.hex_width,return_dict=True)['hex']
 
 class MMGenToolCmdMnemonic(MMGenToolCmdBase):
@@ -403,14 +437,11 @@ class MMGenToolCmdMnemonic(MMGenToolCmdBase):
 		IMPORTANT NOTE: Though MMGen mnemonics use the Electrum wordlist, they're
 		computed using a different algorithm and are NOT Electrum-compatible!
 	"""
-	def _do_random_mn(self,nbytes:int,wordlist:str):
+	def _do_random_mn(self,nbytes:int,wordlist_id:str):
+		assert nbytes in (16,24,32), 'nbytes must be 16, 24 or 32'
 		hexrand = hexlify(get_random(nbytes))
 		Vmsg('Seed: {}'.format(hexrand))
-		for wl_id in ([wordlist],wordlists)[wordlist=='all']:
-			if wordlist == 'all': # TODO
-				Msg('{} mnemonic:'.format(capfirst(wl_id)))
-			mn = baseconv.fromhex(hexrand,wl_id)
-			return ' '.join(mn)
+		return self.hex2mn(hexrand,wordlist_id=wordlist_id)
 
 	def mn_rand128(self,wordlist=dfl_wl_id):
 		"generate random 128-bit mnemonic"
@@ -424,13 +455,19 @@ class MMGenToolCmdMnemonic(MMGenToolCmdBase):
 		"generate random 256-bit mnemonic"
 		return self._do_random_mn(32,wordlist)
 
-	def hex2mn(self,hexstr:'sstr',wordlist=dfl_wl_id):
+	def hex2mn(self,hexstr:'sstr',wordlist_id=dfl_wl_id):
 		"convert a 16, 24 or 32-byte hexadecimal number to a mnemonic"
-		return ' '.join(baseconv.fromhex(hexstr.encode(),wordlist))
+		opt.out_fmt = 'words'
+		from mmgen.seed import SeedSource
+		s = SeedSource(seed=unhexlify(hexstr))
+		s._format()
+		return ' '.join(s.ssdata.mnemonic)
 
 	def mn2hex(self,seed_mnemonic:'sstr',wordlist=dfl_wl_id):
 		"convert a 12, 18 or 24-word mnemonic to a hexadecimal number"
-		return baseconv.tohex(seed_mnemonic.split(),wordlist)
+		opt.quiet = True
+		from mmgen.seed import SeedSource
+		return SeedSource(in_data=seed_mnemonic,in_fmt='words').seed.hexdata
 
 	def mn_stats(self,wordlist=dfl_wl_id):
 		"show stats for mnemonic wordlist"
@@ -448,11 +485,15 @@ class MMGenToolCmdFile(MMGenToolCmdBase):
 
 	def addrfile_chksum(self,mmgen_addrfile:str):
 		"compute checksum for MMGen address file"
+		opt.yes = True
+		opt.quiet = True
 		from mmgen.addr import AddrList
 		return AddrList(mmgen_addrfile).chksum
 
 	def keyaddrfile_chksum(self,mmgen_keyaddrfile:str):
 		"compute checksum for MMGen key-address file"
+		opt.yes = True
+		opt.quiet = True
 		from mmgen.addr import KeyAddrList
 		return KeyAddrList(mmgen_keyaddrfile).chksum
 

+ 18 - 15
mmgen/util.py

@@ -309,7 +309,8 @@ class baseconv(object):
 		if pad:
 			assert type(pad) == bool, "'pad' must be boolean type"
 			d = dict(pad_map)
-			assert len(s) in d, 'Invalid data length for b58{}code(pad=True)'.format(op)
+			m = 'Invalid data length for b58{}code(pad=True) (must be one of {})'
+			assert len(s) in d, m.format(op,repr([e[0] for e in pad_map]))
 			return d[len(s)]
 		else:
 			return None
@@ -349,7 +350,7 @@ class baseconv(object):
 
 		deconv =  [wl.index(words[::-1][i])*(base**i) for i in range(len(words))]
 		ret = ('{:0{w}x}'.format(sum(deconv),w=pad or 0))
-		return ('','0')[len(ret) % 2] + ret
+		return (('','0')[len(ret) % 2] + ret).encode() # return bytes, for consistency with hexlify()
 
 	@classmethod
 	def fromhex(cls,hexnum,wl_id,pad=None,tostr=False):
@@ -465,11 +466,10 @@ def compare_or_die(val1, desc1, val2, desc2, e='Error'):
 
 def open_file_or_exit(filename,mode,silent=False):
 	try:
-		f = open(filename, mode)
+		return open(filename, mode)
 	except:
 		op = ('writing','reading')['r' in mode]
 		die(2,("Unable to open file '{}' for {}".format(filename,op),'')[silent])
-	return f
 
 def check_file_type_and_access(fname,ftype,blkdev_ok=False):
 
@@ -681,7 +681,7 @@ def mmgen_decrypt_file_maybe(fn,desc='',silent=False):
 	have_enc_ext = get_extension(fn) == g.mmenc_ext
 	if have_enc_ext or not is_utf8(d):
 		m = ('Attempting to decrypt','Decrypting')[have_enc_ext]
-		msg("{} {} '{}'".format(m,desc,fn))
+		qmsg("{} {} '{}'".format(m,desc,fn))
 		from mmgen.crypto import mmgen_decrypt_retry
 		d = mmgen_decrypt_retry(d,desc)
 	return d
@@ -699,18 +699,21 @@ def get_data_from_user(desc='data',silent=False): # user input MUST be UTF-8
 	dmsg('User input: [{}]'.format(data))
 	return data
 
-def get_data_from_file(infile,desc='data',dash=False,silent=False,binary=False,require_utf8=False):
-	if dash and infile == '-': return sys.stdin.read()
+def get_data_from_file(infile,desc='data',dash=False,silent=False,binary=False):
+
 	if not opt.quiet and not silent and desc:
 		qmsg("Getting {} from file '{}'".format(desc,infile))
-	f = open_file_or_exit(infile,('r','rb')[bool(binary)],silent=silent)
-	data = f.read()
-	f.close()
-	if require_utf8:
-		try:
-			if binary: data = data.decode()
-			else: data.encode()
-		except: die(1,'{} data must be UTF-8 encoded.'.format(capfirst(desc)))
+
+	mode = ('r','rb')[bool(binary)]
+
+	if dash and infile == '-':
+		data = os.fdopen(0,mode).read(g.max_input_size+1)
+	else:
+		data = open_file_or_exit(infile,mode,silent=silent).read(g.max_input_size+1)
+
+	if len(data) == g.max_input_size + 1:
+		raise MaxInputSizeExceeded('Too much input data!  Max input data size: {} bytes'.format(g.max_input_size))
+
 	return data
 
 def pwfile_reuse_warning():

+ 16 - 17
scripts/test-release.sh

@@ -331,28 +331,27 @@ f_ltc_rt='Regtest (Bob and Alice) mode tests for LTC completed'
 i_tool2='Tooltest2'
 s_tool2="The following tests will run '$tooltest2_py' for all supported coins"
 t_tool2=(
-	"$tooltest2_py --quiet --non-coin-dependent"
-	"$tooltest2_py --quiet --coin=btc --coin-dependent"
-	"$tooltest2_py --quiet --coin=btc --testnet=1 --coin-dependent"
-	"$tooltest2_py --quiet --coin=ltc --coin-dependent"
-	"$tooltest2_py --quiet --coin=ltc --testnet=1 --coin-dependent"
-	"$tooltest2_py --quiet --coin=bch --coin-dependent"
-	"$tooltest2_py --quiet --coin=bch --testnet=1 --coin-dependent"
-	"$tooltest2_py --quiet --coin=zec --coin-dependent"
-	"$tooltest2_py --quiet --coin=zec --type=zcash_z --coin-dependent"
-	"$tooltest2_py --quiet --coin=xmr --coin-dependent"
-	"$tooltest2_py --quiet --coin=dash --coin-dependent"
-	"$tooltest2_py --quiet --coin=eth --coin-dependent"
-	"$tooltest2_py --quiet --coin=eth --testnet=1 --coin-dependent"
-	"$tooltest2_py --quiet --coin=eth --token=mm1 --coin-dependent"
-	"$tooltest2_py --quiet --coin=eth --token=mm1 --testnet=1 --coin-dependent"
-	"$tooltest2_py --quiet --coin=etc --coin-dependent")
+	"$tooltest2_py --quiet"
+	"$tooltest2_py --quiet --coin=btc"
+	"$tooltest2_py --quiet --coin=btc --testnet=1"
+	"$tooltest2_py --quiet --coin=ltc"
+	"$tooltest2_py --quiet --coin=ltc --testnet=1"
+	"$tooltest2_py --quiet --coin=bch"
+	"$tooltest2_py --quiet --coin=bch --testnet=1"
+	"$tooltest2_py --quiet --coin=zec"
+	"$tooltest2_py --quiet --coin=zec --type=zcash_z"
+	"$tooltest2_py --quiet --coin=xmr"
+	"$tooltest2_py --quiet --coin=dash"
+	"$tooltest2_py --quiet --coin=eth"
+	"$tooltest2_py --quiet --coin=eth --testnet=1"
+	"$tooltest2_py --quiet --coin=eth --token=mm1"
+	"$tooltest2_py --quiet --coin=eth --token=mm1 --testnet=1"
+	"$tooltest2_py --quiet --coin=etc")
 f_tool2='tooltest2 tests completed'
 
 i_tool='Tooltest'
 s_tool="The following tests will run '$tooltest_py' for all supported coins"
 t_tool=(
-	"$tooltest_py --coin=btc util"
 	"$tooltest_py --coin=btc cryptocoin"
 	"$tooltest_py --coin=btc mnemonic"
 	"$tooltest_py --coin=ltc cryptocoin"

+ 6 - 1
test/common.py

@@ -17,9 +17,14 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 """
-common.py: Shared routines for the test suites
+common.py: Shared routines and data for the MMGen test suites
 """
 
+sample_text = 'The Times 03/Jan/2009 Chancellor on brink of second bailout for banks'
+
+ref_kafile_pass = 'kafile password'
+ref_kafile_hash_preset = '1'
+
 class TestSuiteException(Exception): pass
 class TestSuiteFatalException(Exception): pass
 

+ 4 - 3
test/pexpect.py

@@ -68,10 +68,11 @@ class MMGenPexpect(object):
 		self.req_exit_val = 0
 		self.skip_ok = False
 
-	def do_decrypt_ka_data(self,hp,pw,desc='key-address data',check=True):
-		self.hash_preset(desc,hp)
+	def do_decrypt_ka_data(self,hp,pw,desc='key-address data',check=True,have_yes_opt=False):
+#		self.hash_preset(desc,hp)
 		self.passphrase(desc,pw)
-		self.expect('Check key-to-address validity? (y/N): ',('n','y')[check])
+		if not have_yes_opt:
+			self.expect('Check key-to-address validity? (y/N): ',('n','y')[check])
 
 	def view_tx(self,view):
 		self.expect('View.* transaction.*\? .*: ',view,regex=True)

+ 1 - 0
test/ref/keyaddrfile_password

@@ -0,0 +1 @@
+kafile password

+ 0 - 4
test/test_py_d/common.py

@@ -62,12 +62,8 @@ ref_bw_hash_preset = '1'
 ref_bw_file = 'wallet.mmbrain'
 ref_bw_file_spc = 'wallet-spaced.mmbrain'
 
-ref_kafile_pass = 'kafile password'
-ref_kafile_hash_preset = '1'
-
 ref_enc_fn = 'sample-text.mmenc'
 tool_enc_passwd = "Scrypt it, don't hash it!"
-sample_text = 'The Times 03/Jan/2009 Chancellor on brink of second bailout for banks\n'
 chksum_pat = r'\b[A-F0-9]{4} [A-F0-9]{4} [A-F0-9]{4} [A-F0-9]{4}\b'
 
 def ok_msg():

+ 1 - 1
test/test_py_d/ts_ethdev.py

@@ -362,7 +362,7 @@ class TestSuiteEthdev(TestSuiteBase,TestSuiteShared):
 		return t
 
 	def txcreate1(self):
-		# valid_keypresses = 'adrMmeqpvwl'
+		# valid_keypresses = EthereumTwUnspentOutputs.key_mappings.keys()
 		menu = ['a','d','r','M','D','e','m','m'] # include one invalid keypress, 'D'
 		args = ['98831F3A:E:1,123.456']
 		return self.txcreate(args=args,menu=menu,acct='1',non_mmgen_inputs=1)

+ 2 - 2
test/test_py_d/ts_main.py

@@ -277,7 +277,7 @@ class TestSuiteMain(TestSuiteBase,TestSuiteShared):
 
 		out = []
 		for d in tx_data.values():
-			al = adata.addrlist(d['al_id'])
+			al = adata.addrlist(al_id=d['al_id'])
 			for n,(idx,coinaddr) in enumerate(al.addrpairs()):
 				lbl = get_label(do_shuffle=True)
 				out.append(self._create_fake_unspent_entry(coinaddr,d['al_id'],idx,lbl,segwit=d['segwit']))
@@ -556,7 +556,7 @@ class TestSuiteMain(TestSuiteBase,TestSuiteShared):
 			args=['-H','{},{}'.format(rf,hincog_offset),'-l',str(hincog_seedlen)])
 
 	def txsign_keyaddr(self,keyaddr_file,txfile):
-		t = self.spawn('mmgen-txsign', ['-d',self.tmpdir,'-M',keyaddr_file,txfile])
+		t = self.spawn('mmgen-txsign', ['-d',self.tmpdir,'-p1','-M',keyaddr_file,txfile])
 		t.license()
 		t.do_decrypt_ka_data(hp='1',pw=self.kapasswd)
 		t.view_tx('n')

+ 3 - 3
test/test_py_d/ts_ref.py

@@ -108,9 +108,9 @@ class TestSuiteRef(TestSuiteBase,TestSuiteShared):
 		af = joinpath(ref_dir,(subdir or self.ref_subdir,'')[ftype=='passwd'],af_fn)
 		coin_arg = [] if coin == None else ['--coin='+coin]
 		tool_cmd = ftype.replace('segwit','').replace('bech32','')+'file_chksum'
-		t = self.spawn('mmgen-tool',coin_arg+[tool_cmd,af]+add_args)
+		t = self.spawn('mmgen-tool',coin_arg+['-p1',tool_cmd,af]+add_args)
 		if ftype == 'keyaddr':
-			t.do_decrypt_ka_data(hp=ref_kafile_hash_preset,pw=ref_kafile_pass)
+			t.do_decrypt_ka_data(hp=ref_kafile_hash_preset,pw=ref_kafile_pass,have_yes_opt=True)
 		rc = self.chk_data[   'ref_' + ftype + 'file_chksum' +
 					('_'+coin.lower() if coin else '') +
 					('_'+mmtype if mmtype else '')]
@@ -161,5 +161,5 @@ class TestSuiteRef(TestSuiteBase,TestSuiteShared):
 		t.written_to_file('Decrypted data')
 		dec_txt = read_from_file(dec_file)
 		imsg_r(dec_txt)
-		cmp_or_die(sample_text,dec_txt)
+		cmp_or_die(sample_text+'\n',dec_txt) # file adds a newline to sample_text
 		return t

+ 17 - 77
test/tooltest.py

@@ -58,30 +58,6 @@ cmd_args = opts.init(opts_data,add_opts=['exact_output','profile'])
 
 from collections import OrderedDict
 cmd_data = OrderedDict([
-	('util', {
-			'desc': 'base conversion, hashing and file utilities',
-			'cmd_data': OrderedDict([
-				('strtob58',     ()),
-				('b58tostr',     ('strtob58','io')),
-				('hextob58',     ()),
-				('b58tohex',     ('hextob58','io')),
-				('b58randenc',   ()),
-				('hextob32',     ()),
-				('b32tohex',     ('hextob32','io')),
-				('randhex',      ()),
-				('id6',          ()),
-				('id8',          ()),
-				('str2id6',      ()),
-				('hash160',      ()),
-				('hash256',      ()),
-				('hexreverse',   ()),
-				('hexlify',      ()),
-				('hexdump',      ()),
-				('unhexdump',    ('hexdump','io')),
-				('rand2file',    ()),
-			])
-		}
-	),
 	('cryptocoin', {
 			'desc': 'Cryptocoin address/key commands',
 			'cmd_data': OrderedDict([
@@ -93,9 +69,9 @@ cmd_data = OrderedDict([
 				('privhex2pubhex', ('wif2hex','o3')),        # segwit only
 				('pubhex2addr',    ('privhex2pubhex','o3')), # segwit only
 				('hex2wif',        ('wif2hex','io2')),       # uncomp, comp
-				('addr2hexaddr',   ('randpair','o4'))] +     # uncomp, comp, bech32
+				('addr2pubhash',   ('randpair','o4'))] +     # uncomp, comp, bech32
 			([],[
-				('pubhash2addr',   ('addr2hexaddr','io4'))   # uncomp, comp, bech32
+				('pubhash2addr',   ('addr2pubhash','io4'))   # uncomp, comp, bech32
 			])[opt.type != 'zcash_z'] +
 			([],[
 				('pubhex2redeem_script', ('privhex2pubhex','o3')),
@@ -196,7 +172,7 @@ if opt.list_names:
 	ignore = ()
 	from mmgen.tool import MMGenToolCmd
 	uc = sorted(
-		set(MMGenToolCmd.user_commands()) -
+		set(MMGenToolCmd._user_commands()) -
 		set(ignore) -
 		set(tested_in['tooltest.py']) -
 		set(tested_in['tooltest2.py']) -
@@ -313,47 +289,6 @@ def ok_or_die(val,chk_func,s,skip_ok=False):
 
 class MMGenToolTestCmds(object):
 
-	# Util
-	def strtob58(self,name):       tu.run_cmd_out(name,getrandstr(16))
-	def b58tostr(self,name,f1,f2): tu.run_cmd_chk(name,f1,f2)
-	def hextob58(self,name):       tu.run_cmd_out(name,getrandhex(32))
-	def b58tohex(self,name,f1,f2): tu.run_cmd_chk(name,f1,f2,strip_hex=True)
-	def b58randenc(self,name):
-		ret = tu.run_cmd_out(name,Return=True)
-		ok_or_die(ret,is_b58_str,'base 58 string')
-	def hextob32(self,name):       tu.run_cmd_out(name,getrandhex(24))
-	def b32tohex(self,name,f1,f2): tu.run_cmd_chk(name,f1,f2,strip_hex=True)
-	def randhex(self,name):
-		ret = tu.run_cmd_out(name,Return=True)
-		ok_or_die(ret,binascii.unhexlify,'hex string')
-	def id6(self,name):     tu.run_cmd_randinput(name)
-	def id8(self,name):     tu.run_cmd_randinput(name)
-	def str2id6(self,name):
-		s = getrandstr(120,no_space=True)
-		s2 = ' {} {} {} {} {} '.format(s[:3],s[3:9],s[9:29],s[29:50],s[50:120])
-		ret1 = tu.run_cmd(name,[s],extra_msg='unspaced input'); ok()
-		ret2 = tu.run_cmd(name,[s2],extra_msg='spaced input')
-		cmp_or_die(ret1,ret2)
-		vmsg('Returned: {}'.format(ret1))
-		ok()
-	def hash160(self,name):        tu.run_cmd_out(name,getrandhex(16))
-	def hash256(self,name):        tu.run_cmd_out(name,getrandstr(16))
-	def hexreverse(self,name):     tu.run_cmd_out(name,getrandhex(24))
-	def hexlify(self,name):        tu.run_cmd_out(name,getrandstr(24))
-	def hexdump(self,name): tu.run_cmd_randinput(name,strip=False)
-	def unhexdump(self,name,fn1,fn2):
-		ret = tu.run_cmd(name,[fn2],strip=False,binary=True)
-		orig = read_from_file(fn1,binary=True)
-		cmp_or_die(orig,ret)
-		ok()
-	def rand2file(self,name):
-		of = name + '.out'
-		dlen = 1024
-		tu.run_cmd(name,[of,str(1024),'threads=4','silent=1'],strip=False)
-		d = read_from_tmpfile(cfg,of,binary=True)
-		cmp_or_die(dlen,len(d))
-		ok()
-
 	# Cryptocoin
 	def randwif(self,name):
 		for n,k in enumerate(['',maybe_compressed]):
@@ -398,7 +333,7 @@ class MMGenToolTestCmds(object):
 		for n,fi,fo,k in ((1,f1,f2,''),(2,f3,f4,maybe_compressed)):
 			ao = ['--type='+k] if k else []
 			ret = tu.run_cmd_chk(name,fi,fo,add_opts=ao)
-	def addr2hexaddr(self,name,f1,f2,f3,f4):
+	def addr2pubhash(self,name,f1,f2,f3,f4):
 		for n,f,m,ao in (
 			(1,f1,'',[]),
 			(2,f2,'from {}'.format(maybe_compressed),[]),
@@ -418,9 +353,12 @@ class MMGenToolTestCmds(object):
 		tu.run_cmd_out(name,addr,add_opts=maybe_type_compressed,fn_idx=3) # what about uncompressed?
 	def pubhex2redeem_script(self,name,f1,f2,f3): # from above
 		addr = read_from_file(f3).strip()
-		tu.run_cmd_out(name,addr,fn_idx=3)
-		rs = read_from_tmpfile(cfg,name+'3.out').strip()
+		tu.run_cmd_out(name,addr,add_opts=maybe_type_segwit,fn_idx=3)
+		type_save = opt.type
+		opt.type = 'segwit'
+		rs = read_from_tmpfile(cfg,'privhex2pubhex3.out').strip()
 		tu.run_cmd_out('pubhex2addr',rs,add_opts=maybe_type_segwit,fn_idx=3,hush=True)
+		opt.type = type_save
 		addr1 = read_from_tmpfile(cfg,'pubhex2addr3.out').strip()
 		addr2 = read_from_tmpfile(cfg,'randpair3.out').split()[1]
 		cmp_or_die(addr1,addr2)
@@ -440,20 +378,22 @@ class MMGenToolTestCmds(object):
 
 	def pipetest(self,name,f1,f2,f3):
 		wif = read_from_file(f3).split()[0]
-		cmd = ( '{c} {a} wif2hex {wif} | ' +
-				'{c} {a} --type=compressed privhex2pubhex - | ' +
-				'{c} {a} pubhex2redeem_script - | ' +
-				'{c} {a} --type=segwit pubhex2addr -').format(
+		cmd = ( '{c} {a} wif2hex {wif}' +
+				' | {c} {a} --type=compressed privhex2pubhex -' +
+				' | {c} {a} --type=segwit pubhex2redeem_script -' +
+				' | {c} {a} hash160 -' +
+				' | {c} {a} --type=segwit pubhash2addr -').format(
 					c=' '.join(spawn_cmd),
 					a=' '.join(add_spawn_args),
 					wif=wif)
 		test_msg('command piping')
 		if opt.verbose:
 			sys.stderr.write(green('Executing ') + cyan(cmd) + '\n')
-		p = subprocess.Popen(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE,shell=True)
+		p = subprocess.Popen(cmd,stdout=subprocess.PIPE,shell=True)
 		res = p.stdout.read().decode().strip()
+		p.wait()
 		addr = read_from_tmpfile(cfg,'wif2addr3.out').strip()
-		cmp_or_die(res,addr)
+		cmp_or_die(addr,res)
 		ok()
 
 	# Mnemonic

+ 592 - 218
test/tooltest2.py

@@ -36,6 +36,9 @@ os.environ['MMGEN_TEST_SUITE'] = '1'
 from mmgen.common import *
 from test.common import *
 from mmgen.obj import is_wif,is_coin_addr
+from mmgen.seed import is_mnemonic
+
+def is_str(s): return type(s) == str
 
 opts_data = lambda: {
 	'desc': "Simple test suite for the 'mmgen-tool' utility",
@@ -43,10 +46,9 @@ opts_data = lambda: {
 	'options': """
 -h, --help           Print this help message
 -C, --coverage       Produce code coverage info using trace module
--d, --coin-dependent Run only coin-dependent tests
--D, --non-coin-dependent Run only non-coin-dependent tests
+-d, --die-on-missing Abort if no test data found for given command
 --, --longhelp       Print help message for long options (common options)
--l, --list-tests     List the tests in this test suite
+-l, --list-tests     List the test groups in this test suite
 -L, --list-tested-cmds Output the 'mmgen-tool' commands that are tested by this test suite
 -n, --names          Print command names instead of descriptions
 -q, --quiet          Produce quieter output
@@ -63,170 +65,480 @@ If no command is given, the whole suite of tests is run.
 """
 }
 
-tests = (
-	('util', 'base conversion, hashing and file utilities',
-		(
-			('b58chktohex','conversion from base58chk to hex', [
-				( ['eFGDJPketnz'], 'deadbeef' ),
-				( ['5CizhNNRPYpBjrbYX'], 'deadbeefdeadbeef' ),
-				( ['5qCHTcgbQwprzjWrb'], 'ffffffffffffffff' ),
-				( ['111111114FCKVB'], '0000000000000000' ),
-				( ['3QJmnh'], '' ),
-				( ['1111111111111111111114oLvT2'], '000000000000000000000000000000000000000000' ),
-			]),
-			('hextob58chk','conversion from hex to base58chk', [
-				( ['deadbeef'], 'eFGDJPketnz' ),
-				( ['deadbeefdeadbeef'], '5CizhNNRPYpBjrbYX' ),
-				( ['ffffffffffffffff'], '5qCHTcgbQwprzjWrb' ),
-				( ['0000000000000000'], '111111114FCKVB' ),
-				( [''], '3QJmnh' ),
-				( ['000000000000000000000000000000000000000000'], '1111111111111111111114oLvT2' ),
-			]),
-			('bytespec',"conversion of 'dd'-style byte specifier to bytes", [
-				( ['1G'], str(1024*1024*1024) ),
-				( ['1234G'], str(1234*1024*1024*1024) ),
-				( ['1GB'], str(1000*1000*1000) ),
-				( ['1234GB'], str(1234*1000*1000*1000) ),
-				( ['1.234MB'], str(1234*1000) ),
-				( ['1.234567M'], str(int(Decimal('1.234567')*1024*1024)) ),
-				( ['1234'], str(1234) ),
-			]),
-		),
-	),
-	('wallet', 'MMGen wallet operations',
-		(
-			('gen_key','generation of single key from wallet', [
-				(   ['98831F3A:11','wallet=test/ref/98831F3A.mmwords'],
-					'5JKLcdYbhP6QQ4BXc9HtjfqJ79FFRXP2SZTKUyEuyXJo9QSFUkv'
-				),
-				(   ['98831F3A:C:11','wallet=test/ref/98831F3A.mmwords'],
-					'L2LwXv94XTU2HjCbJPXCFuaHjrjucGipWPWUi1hkM5EykgektyqR'
-				),
-				(   ['98831F3A:B:11','wallet=test/ref/98831F3A.mmwords'],
-					'L2K4Y9MWb5oUfKKZtwdgCm6FLZdUiWJDHjh9BYxpEvtfcXt4iM5g'
-				),
-				(   ['98831F3A:S:11','wallet=test/ref/98831F3A.mmwords'],
-					'KwmkkfC9GghnJhnKoRXRn5KwGCgXrCmDw6Uv83NzE4kJS5axCR9A'
-				),
-			]),
-			('gen_addr','generation of single address from wallet', [
-				(   ['98831F3A:11','wallet=test/ref/98831F3A.mmwords'],
-					'12bYUGXS8SRArZneQDN9YEEYAtEa59Rykm'
-				),
-				(   ['98831F3A:L:11','wallet=test/ref/98831F3A.mmwords'],
-					'12bYUGXS8SRArZneQDN9YEEYAtEa59Rykm'
-				),
-				(   ['98831F3A:C:11','wallet=test/ref/98831F3A.mmwords'],
-					'1MPsZ7BY9qikqfPxqmrovE8gLDX2rYArZk'
-				),
-				(   ['98831F3A:B:11','wallet=test/ref/98831F3A.mmwords'],
-					'bc1qxptlvmwaymaxa7pxkr2u5pn7c0508stcncv7ms'
-				),
-				(   ['98831F3A:S:11','wallet=test/ref/98831F3A.mmwords'],
-					'3Eevao3DRVXnYym3tdrJDqS3Wc39PQzahn'
-				),
-			]),
-		),
-	),
-	('cryptocoin', 'coin-dependent utilities',
-		(
-			('randwif','random WIF key', {
-				'btc_mainnet': [ ( [], is_wif, ['-r0'] ) ],
-				'btc_testnet': [ ( [], is_wif, ['-r0'] ) ],
-			}),
-			('randpair','random key/address pair', {
-				'btc_mainnet': [ ( [], [is_wif,is_coin_addr], ['-r0'] ) ],
-				'btc_testnet': [ ( [], [is_wif,is_coin_addr], ['-r0'] ) ],
-			}),
-			('wif2addr','WIF-to-address conversion', {
-				'btc_mainnet': [
-					( ['5JKLcdYbhP6QQ4BXc9HtjfqJ79FFRXP2SZTKUyEuyXJo9QSFUkv'],
-						'12bYUGXS8SRArZneQDN9YEEYAtEa59Rykm', ['--type=legacy'], 'opt.type="legacy"' ),
-					( ['L2LwXv94XTU2HjCbJPXCFuaHjrjucGipWPWUi1hkM5EykgektyqR'],
-						'1MPsZ7BY9qikqfPxqmrovE8gLDX2rYArZk', ['--type=compressed'], 'opt.type="compressed"' ),
-					( ['KwmkkfC9GghnJhnKoRXRn5KwGCgXrCmDw6Uv83NzE4kJS5axCR9A'],
-						'3Eevao3DRVXnYym3tdrJDqS3Wc39PQzahn', ['--type=segwit'], 'opt.type="segwit"' ),
-					( ['L2K4Y9MWb5oUfKKZtwdgCm6FLZdUiWJDHjh9BYxpEvtfcXt4iM5g'],
-						'bc1qxptlvmwaymaxa7pxkr2u5pn7c0508stcncv7ms', ['--type=bech32'], 'opt.type="bech32"' ),
-				],
-			}),
-		),
-	),
-# TODO: compressed address files are missing
-# 		'addrfile_compressed_chk': {
-# 			'btc': ('A33C 4FDE F515 F5BC','6C48 AA57 2056 C8C8'),
-# 			'ltc': ('3FC0 8F03 C2D6 BD19','4C0A 49B6 2DD1 1BE0'),
-	('file', 'Operations with MMGen files',
-		(
-			('addrfile_chksum','address file checksums', {
-				'btc_mainnet': [
-					( ['test/ref/98831F3A[1,31-33,500-501,1010-1011].addrs'],
-						'6FEF 6FB9 7B13 5D91'),
-					( ['test/ref/98831F3A-S[1,31-33,500-501,1010-1011].addrs'],
-						'06C1 9C87 F25C 4EE6'),
-					( ['test/ref/98831F3A-B[1,31-33,500-501,1010-1011].addrs'],
-						'9D2A D4B6 5117 F02E'),
-				],
-				'btc_testnet': [
-					( ['test/ref/98831F3A[1,31-33,500-501,1010-1011].testnet.addrs'],
-						'424E 4326 CFFE 5F51'),
-					( ['test/ref/98831F3A-S[1,31-33,500-501,1010-1011].testnet.addrs'],
-						'072C 8B07 2730 CB7A'),
-					( ['test/ref/98831F3A-B[1,31-33,500-501,1010-1011].testnet.addrs'],
-						'0527 9C39 6C1B E39A'),
-				],
-				'ltc_mainnet': [
-					( ['test/ref/litecoin/98831F3A-LTC[1,31-33,500-501,1010-1011].addrs'],
-						'AD52 C3FE 8924 AAF0'),
-					( ['test/ref/litecoin/98831F3A-LTC-S[1,31-33,500-501,1010-1011].addrs'],
-						'63DF E42A 0827 21C3'),
-					( ['test/ref/litecoin/98831F3A-LTC-B[1,31-33,500-501,1010-1011].addrs'],
-						'FF1C 7939 5967 AB82'),
-				],
-				'ltc_testnet': [
-					( ['test/ref/litecoin/98831F3A-LTC[1,31-33,500-501,1010-1011].testnet.addrs'],
-						'4EBE 2E85 E969 1B30'),
-					( ['test/ref/litecoin/98831F3A-LTC-S[1,31-33,500-501,1010-1011].testnet.addrs'],
-						'5DD1 D186 DBE1 59F2'),
-					( ['test/ref/litecoin/98831F3A-LTC-B[1,31-33,500-501,1010-1011].testnet.addrs'],
-						'ED3D 8AA4 BED4 0B40'),
-				],
-				'zec_mainnet': [
-					( ['test/ref/zcash/98831F3A-ZEC-C[1,31-33,500-501,1010-1011].addrs'],'903E 7225 DD86 6E01'), ],
-				'zec_z_mainnet': [
-					( ['test/ref/zcash/98831F3A-ZEC-Z[1,31-33,500-501,1010-1011].addrs'],'9C7A 72DC 3D4A B3AF'), ],
-				'xmr_mainnet': [
-					( ['test/ref/monero/98831F3A-XMR-M[1,31-33,500-501,1010-1011].addrs'],'4369 0253 AC2C 0E38'), ],
-				'dash_mainnet': [
-					( ['test/ref/dash/98831F3A-DASH-C[1,31-33,500-501,1010-1011].addrs'],'FBC1 6B6A 0988 4403'), ],
-				'eth_mainnet': [
-					( ['test/ref/ethereum/98831F3A-ETH[1,31-33,500-501,1010-1011].addrs'],'E554 076E 7AF6 66A3'), ],
-				'etc_mainnet': [
-					( ['test/ref/ethereum_classic/98831F3A-ETC[1,31-33,500-501,1010-1011].addrs'],
-						'E97A D796 B495 E8BC'), ],
-			}),
-			('txview','transaction file view', {
-				'btc_mainnet': [ ( ['test/ref/0B8D5A[15.31789,14,tl=1320969600].rawtx'], None ), ],
-				'btc_testnet': [ ( ['test/ref/0C7115[15.86255,14,tl=1320969600].testnet.rawtx'], None ), ],
-				'bch_mainnet': [ ( ['test/ref/460D4D-BCH[10.19764,tl=1320969600].rawtx'], None ), ],
-				'bch_testnet': [ ( ['test/ref/359FD5-BCH[6.68868,tl=1320969600].testnet.rawtx'], None ), ],
-				'ltc_mainnet': [ ( ['test/ref/litecoin/AF3CDF-LTC[620.76194,1453,tl=1320969600].rawtx'], None ), ],
-				'ltc_testnet': [ ( ['test/ref/litecoin/A5A1E0-LTC[1454.64322,1453,tl=1320969600].testnet.rawtx'],
-										None ), ],
-				'eth_mainnet': [ ( ['test/ref/ethereum/88FEFD-ETH[23.45495,40000].rawtx'], None ), ],
-				'eth_testnet': [ ( ['test/ref/ethereum/B472BD-ETH[23.45495,40000].testnet.rawtx'], None ), ],
-				'mm1_mainnet': [ ( ['test/ref/ethereum/5881D2-MM1[1.23456,50000].rawtx'], None ), ],
-				'mm1_testnet': [ ( ['test/ref/ethereum/6BDB25-MM1[1.23456,50000].testnet.rawtx'], None ), ],
-				'etc_mainnet': [ ( ['test/ref/ethereum_classic/ED3848-ETC[1.2345,40000].rawtx'], None ), ],
-			}),
-		),
-	),
-)
-
-def do_cmd(cdata):
-	cmd_name,desc,data = cdata
-	if type(data) == dict:
-		if opt.non_coin_dependent: return
+sample_text_hexdump = (
+	'000000: 5468 6520 5469 6d65 7320 3033 2f4a 616e\n' +
+	'000010: 2f32 3030 3920 4368 616e 6365 6c6c 6f72\n' +
+	'000020: 206f 6e20 6272 696e 6b20 6f66 2073 6563\n' +
+	'000030: 6f6e 6420 6261 696c 6f75 7420 666f 7220\n' +
+	'000040: 6261 6e6b 73' )
+
+kafile_opts = ['-p1','-Ptest/ref/keyaddrfile_password']
+kafile_code = (
+	"\nopt.hash_preset = '1'" +
+	"\nopt.set_by_user = ['hash_preset']" +
+	"\nopt.use_old_ed25519 = None" +
+	"\nopt.passwd_file = 'test/ref/keyaddrfile_password'" )
+
+tests = {
+	'Mnemonic': {
+		'hex2mn': [
+			( ['deadbeefdeadbeefdeadbeefdeadbeef'],
+			'table cast forgive master funny gaze sadness ripple million paint moral match' ),
+			( ['deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'],
+			('swirl maybe anymore mix scale stray fog use approach page crime rhyme ' +
+			'class former strange window snap soon') ),
+			( ['deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'],
+			('swell type milk figure cheese phone fill black test bloom heard comfort ' +
+			'image terrible radio lesson own reply battle goal goodbye need laugh stream') ),
+			( ['ffffffffffffffffffffffffffffffff'],
+			'yellow yeah show bowl season spider cling defeat poison law shelter reflect' ),
+			( ['ffffffffffffffffffffffffffffffffffffffffffffffff'],
+			('yeah youth quit fail perhaps drum out person young click skin ' +
+			'weird inside silently perfectly together anyone memory') ),
+			( ['ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'],
+			('wrote affection object cell opinion here laughter stare honest north cost begin ' +
+			'murder something yourself effort acid dot doubt game broke tell guilt innocent') ),
+			( ['0000000000000000000000000000000000000000000000000000000000000001'],
+			('able able able able able able able able able able able able ' +
+			'able able able able able able able able able able able about') ),
+		],
+		'mn2hex': [
+			( ['table cast forgive master funny gaze sadness ripple million paint moral match'],
+				'deadbeefdeadbeefdeadbeefdeadbeef' ),
+			( ['swirl maybe anymore mix scale stray fog use approach page crime rhyme ' +
+				'class former strange window snap soon'],
+				'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'),
+			( ['swell type milk figure cheese phone fill black test bloom heard comfort ' +
+				'image terrible radio lesson own reply battle goal goodbye need laugh stream'],
+				'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' ),
+			( ['yellow yeah show bowl season spider cling defeat poison law shelter reflect'],
+				'ffffffffffffffffffffffffffffffff' ),
+			( ['yeah youth quit fail perhaps drum out person young click skin ' +
+				'weird inside silently perfectly together anyone memory'],
+				'ffffffffffffffffffffffffffffffffffffffffffffffff' ) ,
+			( ['wrote affection object cell opinion here laughter stare honest north cost begin ' +
+				'murder something yourself effort acid dot doubt game broke tell guilt innocent'],
+				'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'),
+			( ['able able able able able able able able able able able able ' +
+				'able able able able able able able able able able able about'],
+				'0000000000000000000000000000000000000000000000000000000000000001'),
+		],
+		'mn_rand128':   [ ( [], is_mnemonic, ['-r0']), ( ['wordlist=tirosh'], is_mnemonic, ['-r0']), ],
+		'mn_rand192':   [ ( [], is_mnemonic, ['-r0']), ( ['wordlist=tirosh'], is_mnemonic, ['-r0']), ],
+		'mn_rand256':   [ ( [], is_mnemonic, ['-r0']), ( ['wordlist=tirosh'], is_mnemonic, ['-r0']), ],
+		'mn_stats':     [ ( [], is_str ), ( ['wordlist=tirosh'], is_str ), ],
+		'mn_printlist': [ ( [], is_str ), ( ['wordlist=tirosh'], is_str ), ],
+	},
+	'Util': {
+		'hextob32': [
+			( ['deadbeef'], 'DPK3PXP' ),
+			( ['deadbeefdeadbeef'], 'N5LN657PK3PXP' ),
+			( ['ffffffffffffffff'], 'P777777777777' ),
+			( ['0000000000000000'], '' ),
+			( ['0000000000000000','pad=10'], 'AAAAAAAAAA' ),
+			( ['ff','pad=10'], 'AAAAAAAAH7' ),
+		],
+		'b32tohex': [
+			( ['DPK3PXP'], 'deadbeef' ),
+			( ['N5LN657PK3PXP'], 'deadbeefdeadbeef' ),
+			( ['P777777777777'], 'ffffffffffffffff' ),
+			( ['','pad=16'], '0000000000000000' ),
+			( ['AAAAAAAAAA','pad=16'], '0000000000000000' ),
+			( ['AAAAAAAAH7','pad=2'], 'ff' ),
+		],
+		'hextob58chk': [
+			( ['deadbeef'], 'eFGDJPketnz' ),
+			( ['deadbeefdeadbeef'], '5CizhNNRPYpBjrbYX' ),
+			( ['ffffffffffffffff'], '5qCHTcgbQwprzjWrb' ),
+			( ['0000000000000000'], '111111114FCKVB' ),
+			( [''], '3QJmnh' ),
+			( ['000000000000000000000000000000000000000000'], '1111111111111111111114oLvT2' ),
+		],
+		'b58chktohex': [
+			( ['eFGDJPketnz'], 'deadbeef' ),
+			( ['5CizhNNRPYpBjrbYX'], 'deadbeefdeadbeef' ),
+			( ['5qCHTcgbQwprzjWrb'], 'ffffffffffffffff' ),
+			( ['111111114FCKVB'], '0000000000000000' ),
+			( ['3QJmnh'], '' ),
+			( ['1111111111111111111114oLvT2'], '000000000000000000000000000000000000000000' ),
+		],
+		'bytestob58': [
+			( [b'\xde\xad\xbe\xef'], '6h8cQN' ),
+			( [b'\xde\xad\xbe\xef\xde\xad\xbe\xef'], 'eFGDJURJykA' ),
+			( [b'\xff\xff\xff\xff\xff\xff\xff\xff'], 'jpXCZedGfVQ' ),
+			( [b'\x00\x00\x00\x00\x00\x00\x00\x00'], '' ),
+			( [b'\x00\x00\x00\x00\x00\x00\x00\x00','pad=10'], '1111111111' ),
+			( [b'\xff','pad=10'], '111111115Q' ),
+		],
+		'b58tobytes': [
+			( ['6h8cQN'], b'\xde\xad\xbe\xef' ),
+			( ['eFGDJURJykA'], b'\xde\xad\xbe\xef\xde\xad\xbe\xef' ),
+			( ['jpXCZedGfVQ'], b'\xff\xff\xff\xff\xff\xff\xff\xff' ),
+			( ['','pad=16'], b'\x00\x00\x00\x00\x00\x00\x00\x00' ),
+			( ['1111111111','pad=16'], b'\x00\x00\x00\x00\x00\x00\x00\x00' ),
+			( ['111111115Q','pad=2'], b'\xff' ),
+		],
+		'hextob58': [
+			( ['deadbeef'], '6h8cQN' ),
+			( ['deadbeefdeadbeef'], 'eFGDJURJykA' ),
+			( ['ffffffffffffffff'], 'jpXCZedGfVQ' ),
+			( ['0000000000000000'], '' ),
+			( ['0000000000000000','pad=10'], '1111111111' ),
+			( ['ff','pad=10'], '111111115Q' ),
+		],
+		'b58tohex': [
+			( ['6h8cQN'], 'deadbeef' ),
+			( ['eFGDJURJykA'], 'deadbeefdeadbeef' ),
+			( ['jpXCZedGfVQ'], 'ffffffffffffffff' ),
+			( ['','pad=16'], '0000000000000000' ),
+			( ['1111111111','pad=16'], '0000000000000000' ),
+			( ['111111115Q','pad=2'], 'ff' ),
+		],
+		'bytespec': [
+			( ['1G'], str(1024*1024*1024) ),
+			( ['1234G'], str(1234*1024*1024*1024) ),
+			( ['1GB'], str(1000*1000*1000) ),
+			( ['1234GB'], str(1234*1000*1000*1000) ),
+			( ['1.234MB'], str(1234*1000) ),
+			( ['1.234567M'], str(int(Decimal('1.234567')*1024*1024)) ),
+			( ['1234'], str(1234) ),
+		],
+		'hash160': [ # TODO: check that hextob58chk(hash160) = pubhex2addr
+			( ['deadbeef'], 'f04df4c4b30d2b7ac6e1ed2445aeb12a9cb4d2ec' ),
+			( ['000000000000000000000000000000000000000000'], '2db95e704e2d9b0474acf76182f3f985b7064a8a' ),
+			( [''], 'b472a266d0bd89c13706a4132ccfb16f7c3b9fcb' ),
+			( ['ffffffffffffffff'], 'f86221f5a1fca059a865c0b7d374dfa9d5f3aeb4' ),
+		],
+		'hash256': [
+			( ['deadbeef'], 'e107944e77a688feae4c2d4db5951923812dd0f72026a11168104ee1b248f8a9' ),
+			( ['000000000000000000000000000000000000000000'], 'fd5181fcd097a334ab340569e5edcd09f702fef7994abab01f4b66e86b32ebbe' ),
+			( [''], '5df6e0e2761359d30a8275058e299fcc0381534545f55cf43e41983f5d4c9456' ),
+			( ['ffffffffffffffff'], '57b2d2c3455e0f76c61c5237ff04fc9fc0f3fe691e587ea9c951949e1a5e0fed' ),
+		],
+		'hexdump': [
+			( [sample_text.encode()], sample_text_hexdump ),
+		],
+		'unhexdump': [
+			( [sample_text_hexdump.encode()], sample_text.encode() ),
+		],
+		'hexlify': [
+			( [b'foobar'], '666f6f626172' ),
+		],
+		'unhexlify': [
+			( ['666f6f626172'], 'foobar' ),
+		],
+		'hexreverse': [
+			( ['deadbeefcafe'], 'fecaefbeadde' ),
+		],
+		'id6': [
+			( [sample_text.encode()], 'a6d72b' ),
+		],
+		'id8': [
+			( [sample_text.encode()], '687C09C2' ),
+		],
+		'str2id6': [
+			( ['74ev zjeq Zw2g DspF RKpE 7H'], '70413d' ), # checked
+		],
+		'randhex': [
+			( [], {'boolfunc':is_hex_str,'len':64}, ['-r0'] ),
+			( ['nbytes=16'], {'boolfunc':is_hex_str,'len':32}, ['-r0'] ),
+			( ['nbytes=6'], {'boolfunc':is_hex_str,'len':12}, ['-r0'] ),
+		],
+		'randb58': [
+			( [], {'boolfunc':is_b58_str,'len':44}, ['-r0'] ),
+			( ['nbytes=16'], {'boolfunc':is_b58_str,'len':22}, ['-r0'] ),
+			( ['nbytes=12','pad=false'], is_b58_str, ['-r0'] ),
+		],
+	},
+	'Wallet': {
+		'gen_key': [
+			(   ['98831F3A:11','wallet=test/ref/98831F3A.mmwords'],
+				'5JKLcdYbhP6QQ4BXc9HtjfqJ79FFRXP2SZTKUyEuyXJo9QSFUkv'
+			),
+			(   ['98831F3A:C:11','wallet=test/ref/98831F3A.mmwords'],
+				'L2LwXv94XTU2HjCbJPXCFuaHjrjucGipWPWUi1hkM5EykgektyqR'
+			),
+			(   ['98831F3A:B:11','wallet=test/ref/98831F3A.mmwords'],
+				'L2K4Y9MWb5oUfKKZtwdgCm6FLZdUiWJDHjh9BYxpEvtfcXt4iM5g'
+			),
+			(   ['98831F3A:S:11','wallet=test/ref/98831F3A.mmwords'],
+				'KwmkkfC9GghnJhnKoRXRn5KwGCgXrCmDw6Uv83NzE4kJS5axCR9A'
+			),
+		],
+		'gen_addr': [
+			(   ['98831F3A:11','wallet=test/ref/98831F3A.mmwords'],
+				'12bYUGXS8SRArZneQDN9YEEYAtEa59Rykm'
+			),
+			(   ['98831F3A:L:11','wallet=test/ref/98831F3A.mmwords'],
+				'12bYUGXS8SRArZneQDN9YEEYAtEa59Rykm'
+			),
+			(   ['98831F3A:C:11','wallet=test/ref/98831F3A.mmwords'],
+				'1MPsZ7BY9qikqfPxqmrovE8gLDX2rYArZk'
+			),
+			(   ['98831F3A:B:11','wallet=test/ref/98831F3A.mmwords'],
+				'bc1qxptlvmwaymaxa7pxkr2u5pn7c0508stcncv7ms'
+			),
+			(   ['98831F3A:S:11','wallet=test/ref/98831F3A.mmwords'],
+				'3Eevao3DRVXnYym3tdrJDqS3Wc39PQzahn'
+			),
+		],
+	},
+	'Coin': {
+		'addr2pubhash': {
+			'btc_mainnet': [
+				( ['12bYUGXS8SRArZneQDN9YEEYAtEa59Rykm'], '118089d66b4a5853765e94923abdd5de4616c6e5' ),
+				( ['3Eevao3DRVXnYym3tdrJDqS3Wc39PQzahn'], '8e34586186551f6320fa3eb2d238a9c61ab8264b' ),
+				( ['bc1qxptlvmwaymaxa7pxkr2u5pn7c0508stcncv7ms'], '3057f66ddd26fa6ef826b0d5ca067ec3e8f3c178' ),
+			],
+		},
+		'pubhash2addr': {
+			'btc_mainnet': [
+				( ['118089d66b4a5853765e94923abdd5de4616c6e5'], '12bYUGXS8SRArZneQDN9YEEYAtEa59Rykm',
+					None, 'opt.type="legacy"' ),
+				( ['8e34586186551f6320fa3eb2d238a9c61ab8264b'], '3Eevao3DRVXnYym3tdrJDqS3Wc39PQzahn',
+					['--type=segwit'], 'opt.type="segwit"' ),
+				( ['3057f66ddd26fa6ef826b0d5ca067ec3e8f3c178'], 'bc1qxptlvmwaymaxa7pxkr2u5pn7c0508stcncv7ms',
+					['--type=bech32'], 'opt.type="bech32"' ),
+			],
+		},
+		'hex2wif': {
+			'btc_mainnet': [
+				( ['118089d66b4a5853765e94923abdd5de4616c6e5118089d66b4a5853765e9492'],
+					'5HwzecKMWD82ppJK3qMKpC7ohXXAwcyAN5VgdJ9PLFaAzpBG4sX',
+					None, 'opt.type="legacy"' ),
+				( ['118089d66b4a5853765e94923abdd5de4616c6e5118089d66b4a5853765e9492'],
+					'KwojSzt1VvW343mQfWQi3J537siAt5ktL2qbuCg1ZyKR8BLQ6UJm',
+					['--type=compressed'], 'opt.type="compressed"' ),
+				( ['118089d66b4a5853765e94923abdd5de4616c6e5118089d66b4a5853765e9492'],
+					'KwojSzt1VvW343mQfWQi3J537siAt5ktL2qbuCg1ZyKR8BLQ6UJm',
+					['--type=segwit'], 'opt.type="segwit"' ),
+				( ['118089d66b4a5853765e94923abdd5de4616c6e5118089d66b4a5853765e9492'],
+					'KwojSzt1VvW343mQfWQi3J537siAt5ktL2qbuCg1ZyKR8BLQ6UJm',
+					['--type=bech32'], 'opt.type="bech32"' ),
+			],
+		},
+		'privhex2addr': {
+			'btc_mainnet': [
+				( ['118089d66b4a5853765e94923abdd5de4616c6e5118089d66b4a5853765e9492'],
+					'1C5VPtgq9xQ6AcTgMAR3J6GDrs72HC4pS1',
+					None, 'opt.type="legacy"' ),
+				( ['118089d66b4a5853765e94923abdd5de4616c6e5118089d66b4a5853765e9492'],
+					'1Kz9fVSUMshzPejpzW9D95kScgA3rY6QxF',
+					['--type=compressed'], 'opt.type="compressed"' ),
+				( ['118089d66b4a5853765e94923abdd5de4616c6e5118089d66b4a5853765e9492'],
+					'3AhjTiWHhVJAi1s5CfKMcLzYps12x3gZhg',
+					['--type=segwit'], 'opt.type="segwit"' ),
+				( ['118089d66b4a5853765e94923abdd5de4616c6e5118089d66b4a5853765e9492'],
+					'bc1q6pqnfwwakuuejpm9w52ds342f9d5u36v0qnz7c',
+					['--type=bech32'], 'opt.type="bech32"' ),
+			],
+		},
+		'privhex2pubhex': {
+			'btc_mainnet': [
+				( ['118089d66b4a5853765e94923abdd5de4616c6e5118089d66b4a5853765e9492'],
+					'044281a85c9ce87279e028410b851410d65136304cfbbbeaaa8e2e3931cf4e972757f3254c322eeaa3cb6bf97cc5ecf8d4387b0df2c0b1e6ee18fe3a6977a7d57a',
+					None, 'opt.type="legacy"' ),
+				( ['118089d66b4a5853765e94923abdd5de4616c6e5118089d66b4a5853765e9492'],
+					'024281a85c9ce87279e028410b851410d65136304cfbbbeaaa8e2e3931cf4e9727',
+					['--type=compressed'], 'opt.type="compressed"' ),
+				( ['118089d66b4a5853765e94923abdd5de4616c6e5118089d66b4a5853765e9492'],
+					'024281a85c9ce87279e028410b851410d65136304cfbbbeaaa8e2e3931cf4e9727',
+					['--type=segwit'], 'opt.type="segwit"' ),
+				( ['118089d66b4a5853765e94923abdd5de4616c6e5118089d66b4a5853765e9492'],
+					'024281a85c9ce87279e028410b851410d65136304cfbbbeaaa8e2e3931cf4e9727',
+					['--type=bech32'], 'opt.type="bech32"' ),
+			],
+		},
+		'pubhex2addr': {
+			'btc_mainnet': [
+				( ['044281a85c9ce87279e028410b851410d65136304cfbbbeaaa8e2e3931cf4e972757f3254c322eeaa3cb6bf97cc5ecf8d4387b0df2c0b1e6ee18fe3a6977a7d57a'],
+					'1C5VPtgq9xQ6AcTgMAR3J6GDrs72HC4pS1',
+					None, 'opt.type="legacy"' ),
+				( ['024281a85c9ce87279e028410b851410d65136304cfbbbeaaa8e2e3931cf4e9727'],
+					'1Kz9fVSUMshzPejpzW9D95kScgA3rY6QxF',
+					['--type=compressed'], 'opt.type="compressed"' ),
+				( ['024281a85c9ce87279e028410b851410d65136304cfbbbeaaa8e2e3931cf4e9727'],
+					'3AhjTiWHhVJAi1s5CfKMcLzYps12x3gZhg',
+					['--type=segwit'], 'opt.type="segwit"' ),
+				( ['024281a85c9ce87279e028410b851410d65136304cfbbbeaaa8e2e3931cf4e9727'],
+					'bc1q6pqnfwwakuuejpm9w52ds342f9d5u36v0qnz7c',
+					['--type=bech32'], 'opt.type="bech32"' ),
+			],
+		},
+		'pubhex2redeem_script': {
+			'btc_mainnet': [
+				( ['024281a85c9ce87279e028410b851410d65136304cfbbbeaaa8e2e3931cf4e9727'],
+					'0014d04134b9ddb7399907657514d846aa495b4e474c',
+					['--type=segwit'], 'opt.type="segwit"' ),
+			],
+		},
+		'randpair': {
+			'btc_mainnet': [ ( [], [is_wif,is_coin_addr], ['-r0'] ) ],
+			'btc_testnet': [ ( [], [is_wif,is_coin_addr], ['-r0'] ) ],
+		},
+		'randwif': {
+			'btc_mainnet': [ ( [], is_wif, ['-r0'] ) ],
+			'btc_testnet': [ ( [], is_wif, ['-r0'] ) ],
+		},
+		'wif2addr': {
+			'btc_mainnet': [
+				( ['5HwzecKMWD82ppJK3qMKpC7ohXXAwcyAN5VgdJ9PLFaAzpBG4sX'],
+					'1C5VPtgq9xQ6AcTgMAR3J6GDrs72HC4pS1', ['--type=legacy'], 'opt.type="legacy"' ),
+				( ['KwojSzt1VvW343mQfWQi3J537siAt5ktL2qbuCg1ZyKR8BLQ6UJm'],
+					'1Kz9fVSUMshzPejpzW9D95kScgA3rY6QxF', ['--type=compressed'], 'opt.type="compressed"' ),
+				( ['KwojSzt1VvW343mQfWQi3J537siAt5ktL2qbuCg1ZyKR8BLQ6UJm'],
+					'3AhjTiWHhVJAi1s5CfKMcLzYps12x3gZhg', ['--type=segwit'], 'opt.type="segwit"' ),
+				( ['KwojSzt1VvW343mQfWQi3J537siAt5ktL2qbuCg1ZyKR8BLQ6UJm'],
+					'bc1q6pqnfwwakuuejpm9w52ds342f9d5u36v0qnz7c', ['--type=bech32'], 'opt.type="bech32"' ),
+			],
+		},
+		'wif2hex': {
+			'btc_mainnet': [
+				( ['5HwzecKMWD82ppJK3qMKpC7ohXXAwcyAN5VgdJ9PLFaAzpBG4sX'],
+					'118089d66b4a5853765e94923abdd5de4616c6e5118089d66b4a5853765e9492',
+					None, 'opt.type="legacy"' ),
+				( ['KwojSzt1VvW343mQfWQi3J537siAt5ktL2qbuCg1ZyKR8BLQ6UJm'],
+					'118089d66b4a5853765e94923abdd5de4616c6e5118089d66b4a5853765e9492',
+					['--type=compressed'], 'opt.type="compressed"' ),
+				( ['KwojSzt1VvW343mQfWQi3J537siAt5ktL2qbuCg1ZyKR8BLQ6UJm'],
+					'118089d66b4a5853765e94923abdd5de4616c6e5118089d66b4a5853765e9492',
+					['--type=segwit'], 'opt.type="segwit"' ),
+				( ['KwojSzt1VvW343mQfWQi3J537siAt5ktL2qbuCg1ZyKR8BLQ6UJm'],
+					'118089d66b4a5853765e94923abdd5de4616c6e5118089d66b4a5853765e9492',
+					['--type=bech32'], 'opt.type="bech32"' ),
+			],
+		},
+		'wif2redeem_script': {
+			'btc_mainnet': [
+				( ['KwojSzt1VvW343mQfWQi3J537siAt5ktL2qbuCg1ZyKR8BLQ6UJm'],
+					'0014d04134b9ddb7399907657514d846aa495b4e474c',
+					['--type=segwit'], 'opt.type="segwit"' ),
+			],
+		},
+		'wif2segwit_pair': {
+			'btc_mainnet': [
+				( ['KwojSzt1VvW343mQfWQi3J537siAt5ktL2qbuCg1ZyKR8BLQ6UJm'],
+					('0014d04134b9ddb7399907657514d846aa495b4e474c','3AhjTiWHhVJAi1s5CfKMcLzYps12x3gZhg'),
+					['--type=segwit'], 'opt.type="segwit"' ),
+			],
+		},
+	},
+	# TODO: compressed address files are missing
+	# 		'addrfile_compressed_chk':
+	# 			'btc': ('A33C 4FDE F515 F5BC','6C48 AA57 2056 C8C8'),
+	# 			'ltc': ('3FC0 8F03 C2D6 BD19','4C0A 49B6 2DD1 1BE0'),
+	'File': {
+		'addrfile_chksum': {
+			'btc_mainnet': [
+				( ['test/ref/98831F3A[1,31-33,500-501,1010-1011].addrs'],
+					'6FEF 6FB9 7B13 5D91'),
+				( ['test/ref/98831F3A-S[1,31-33,500-501,1010-1011].addrs'],
+					'06C1 9C87 F25C 4EE6'),
+				( ['test/ref/98831F3A-B[1,31-33,500-501,1010-1011].addrs'],
+					'9D2A D4B6 5117 F02E'),
+			],
+			'btc_testnet': [
+				( ['test/ref/98831F3A[1,31-33,500-501,1010-1011].testnet.addrs'],
+					'424E 4326 CFFE 5F51'),
+				( ['test/ref/98831F3A-S[1,31-33,500-501,1010-1011].testnet.addrs'],
+					'072C 8B07 2730 CB7A'),
+				( ['test/ref/98831F3A-B[1,31-33,500-501,1010-1011].testnet.addrs'],
+					'0527 9C39 6C1B E39A'),
+			],
+			'ltc_mainnet': [
+				( ['test/ref/litecoin/98831F3A-LTC[1,31-33,500-501,1010-1011].addrs'],
+					'AD52 C3FE 8924 AAF0'),
+				( ['test/ref/litecoin/98831F3A-LTC-S[1,31-33,500-501,1010-1011].addrs'],
+					'63DF E42A 0827 21C3'),
+				( ['test/ref/litecoin/98831F3A-LTC-B[1,31-33,500-501,1010-1011].addrs'],
+					'FF1C 7939 5967 AB82'),
+			],
+			'ltc_testnet': [
+				( ['test/ref/litecoin/98831F3A-LTC[1,31-33,500-501,1010-1011].testnet.addrs'],
+					'4EBE 2E85 E969 1B30'),
+				( ['test/ref/litecoin/98831F3A-LTC-S[1,31-33,500-501,1010-1011].testnet.addrs'],
+					'5DD1 D186 DBE1 59F2'),
+				( ['test/ref/litecoin/98831F3A-LTC-B[1,31-33,500-501,1010-1011].testnet.addrs'],
+					'ED3D 8AA4 BED4 0B40'),
+			],
+			'zec_mainnet': [
+				( ['test/ref/zcash/98831F3A-ZEC-C[1,31-33,500-501,1010-1011].addrs'],'903E 7225 DD86 6E01'), ],
+			'zec_z_mainnet': [
+				( ['test/ref/zcash/98831F3A-ZEC-Z[1,31-33,500-501,1010-1011].addrs'],'9C7A 72DC 3D4A B3AF'), ],
+			'xmr_mainnet': [
+				( ['test/ref/monero/98831F3A-XMR-M[1,31-33,500-501,1010-1011].addrs'],'4369 0253 AC2C 0E38'), ],
+			'dash_mainnet': [
+				( ['test/ref/dash/98831F3A-DASH-C[1,31-33,500-501,1010-1011].addrs'],'FBC1 6B6A 0988 4403'), ],
+			'eth_mainnet': [
+				( ['test/ref/ethereum/98831F3A-ETH[1,31-33,500-501,1010-1011].addrs'],'E554 076E 7AF6 66A3'), ],
+			'etc_mainnet': [
+				( ['test/ref/ethereum_classic/98831F3A-ETC[1,31-33,500-501,1010-1011].addrs'],
+					'E97A D796 B495 E8BC'), ],
+		},
+		'keyaddrfile_chksum': {
+			'btc_mainnet': [
+				( ['test/ref/98831F3A[1,31-33,500-501,1010-1011].akeys.mmenc'],
+					'9F2D D781 1812 8BAD', kafile_opts, kafile_code ),
+			],
+			'btc_testnet': [
+				( ['test/ref/98831F3A[1,31-33,500-501,1010-1011].testnet.akeys.mmenc'],
+					'88CC 5120 9A91 22C2', kafile_opts, kafile_code ),
+			],
+			'ltc_mainnet': [
+				( ['test/ref/litecoin/98831F3A-LTC[1,31-33,500-501,1010-1011].akeys.mmenc'],
+					'B804 978A 8796 3ED4', kafile_opts, kafile_code ),
+			],
+			'ltc_testnet': [
+				( ['test/ref/litecoin/98831F3A-LTC[1,31-33,500-501,1010-1011].testnet.akeys.mmenc'],
+					'98B5 AC35 F334 0398', kafile_opts, kafile_code ),
+			],
+			'zec_mainnet': [
+				( ['test/ref/zcash/98831F3A-ZEC-C[1,31-33,500-501,1010-1011].akeys.mmenc'],
+				'F05A 5A5C 0C8E 2617', kafile_opts, kafile_code ), ],
+			'zec_z_mainnet': [
+				( ['test/ref/zcash/98831F3A-ZEC-Z[1,31-33,500-501,1010-1011].akeys.mmenc'],
+				'6B87 9B2D 0D8D 8D1E', kafile_opts, kafile_code ), ],
+			'xmr_mainnet': [
+				( ['test/ref/monero/98831F3A-XMR-M[1,31-33,500-501,1010-1011].akeys.mmenc'],
+				'E0D7 9612 3D67 404A', kafile_opts, kafile_code ), ],
+			'dash_mainnet': [
+				( ['test/ref/dash/98831F3A-DASH-C[1,31-33,500-501,1010-1011].akeys.mmenc'],
+				'E83D 2C63 FEA2 4142', kafile_opts, kafile_code ), ],
+			'eth_mainnet': [
+				( ['test/ref/ethereum/98831F3A-ETH[1,31-33,500-501,1010-1011].akeys.mmenc'],
+				'E400 70D9 0AE3 C7C2', kafile_opts, kafile_code ), ],
+			'etc_mainnet': [
+				( ['test/ref/ethereum_classic/98831F3A-ETC[1,31-33,500-501,1010-1011].akeys.mmenc'],
+				'EF49 967D BD6C FE45', kafile_opts, kafile_code ), ],
+		},
+		'passwdfile_chksum': {
+			'btc_mainnet': [
+				( ['test/ref/98831F3A-фубар@crypto.org-b58-20[1,4,9-11,1100].pws'],
+					'A983 DAB9 5514 27FB', kafile_opts, kafile_code ), ],
+		},
+		'txview': {
+			'btc_mainnet': [ ( ['test/ref/0B8D5A[15.31789,14,tl=1320969600].rawtx'], None ), ],
+			'btc_testnet': [ ( ['test/ref/0C7115[15.86255,14,tl=1320969600].testnet.rawtx'], None ), ],
+			'bch_mainnet': [ ( ['test/ref/460D4D-BCH[10.19764,tl=1320969600].rawtx'], None ), ],
+			'bch_testnet': [ ( ['test/ref/359FD5-BCH[6.68868,tl=1320969600].testnet.rawtx'], None ), ],
+			'ltc_mainnet': [ ( ['test/ref/litecoin/AF3CDF-LTC[620.76194,1453,tl=1320969600].rawtx'], None ), ],
+			'ltc_testnet': [ ( ['test/ref/litecoin/A5A1E0-LTC[1454.64322,1453,tl=1320969600].testnet.rawtx'],
+									None ), ],
+			'eth_mainnet': [ ( ['test/ref/ethereum/88FEFD-ETH[23.45495,40000].rawtx'], None ), ],
+			'eth_testnet': [ ( ['test/ref/ethereum/B472BD-ETH[23.45495,40000].testnet.rawtx'], None ), ],
+			'mm1_mainnet': [ ( ['test/ref/ethereum/5881D2-MM1[1.23456,50000].rawtx'], None ), ],
+			'mm1_testnet': [ ( ['test/ref/ethereum/6BDB25-MM1[1.23456,50000].testnet.rawtx'], None ), ],
+			'etc_mainnet': [ ( ['test/ref/ethereum_classic/ED3848-ETC[1.2345,40000].rawtx'], None ), ],
+		},
+	},
+}
+
+coin_dependent_groups = ('Coin','File') # TODO: do this as attr of each group in tool.py
+
+def run_test(gid,cmd_name):
+	data = tests[gid][cmd_name]
+	# behavior is like test.py: run coin-dependent tests only if g.testnet or g.coin != BTC
+	if gid in coin_dependent_groups:
 		k = '{}_{}net'.format((g.token.lower() if g.token else g.coin.lower()),('main','test')[g.testnet])
 		if k in data:
 			data = data[k]
@@ -235,78 +547,142 @@ def do_cmd(cdata):
 			msg("-- no data for {} ({}) - skipping".format(cmd_name,k))
 			return
 	else:
-		if opt.coin_dependent: return
+		if g.coin != 'BTC' or g.testnet: return
 		m2 = ''
-	m = '{} {}{}'.format(cyan('Testing'),cmd_name if opt.names else desc,m2)
+	m = '{} {}{}'.format(purple('Testing'), cmd_name if opt.names else
+			extract_docstring(getattr(getattr(tool,'MMGenToolCmd'+gid),cmd_name)),m2)
+
 	msg_r(green(m)+'\n' if opt.verbose else m)
+
+	def fork_cmd(cmd_name,args,out,opts,exec_code):
+		cmd = list(tool_cmd) + (opts or []) + [cmd_name] + args
+		vmsg('{} {}'.format(green('Executing'),cyan(' '.join(cmd))))
+		p = Popen(cmd,stdin=(PIPE if stdin_input else None),stdout=PIPE,stderr=PIPE)
+		if stdin_input:
+			p.stdin.write(stdin_input)
+			p.stdin.close()
+		cmd_out = p.stdout.read()
+		try:
+			cmd_out = cmd_out.decode().strip()
+		except:
+			pass
+		cmd_err = p.stderr.read()
+		if cmd_err: vmsg(cmd_err.strip().decode())
+		if p.wait() != 0:
+			die(1,'Spawned program exited with error')
+
+		return cmd_out
+
+	def run_func(cmd_name,args,out,opts,exec_code):
+		vmsg('{}: {}{}'.format(purple('Running'),
+				' '.join([cmd_name]+[repr(e) for e in args]),
+				' '+exec_code if exec_code else '' ))
+		if exec_code: exec(exec_code)
+		aargs,kwargs = tool._process_args(cmd_name,args)
+		oq_save = opt.quiet
+		if not opt.verbose: opt.quiet = True
+		if stdin_input:
+			fd0,fd1 = os.pipe()
+			if os.fork(): # parent
+				os.close(fd1)
+				stdin_save = os.dup(0)
+				os.dup2(fd0,0)
+				cmd_out = getattr(tc,cmd_name)(*aargs,**kwargs)
+				os.dup2(stdin_save,0)
+				os.wait()
+				opt.quiet = oq_save
+				return cmd_out
+			else: # child
+				os.close(fd0)
+				os.write(fd1,stdin_input)
+				vmsg('Input: {!r}'.format(stdin_input))
+				sys.exit(0)
+		else:
+			ret = getattr(tc,cmd_name)(*aargs,**kwargs)
+			opt.quiet = oq_save
+			return ret
+
 	for d in data:
 		args,out,opts,exec_code = d + tuple([None] * (4-len(d)))
+		stdin_input = None
+		if args and type(args[0]) == bytes:
+			stdin_input = args[0]
+			args[0] = '-'
 		if opt.fork:
-			cmd = list(tool_cmd) + (opts or []) + [cmd_name] + args
-			vmsg('{} {}'.format(green('Executing'),cyan(' '.join(cmd))))
-			p = Popen(cmd,stdout=PIPE,stderr=PIPE)
-			cmd_out = p.stdout.read()
-			if type(out) != bytes:
-				cmd_out = cmd_out.strip().decode()
-			cmd_err = p.stderr.read()
-			if cmd_err: vmsg(cmd_err.strip().decode())
-			if p.wait() != 0:
-				die(1,'Spawned program exited with error')
+			cmd_out = fork_cmd(cmd_name,args,out,opts,exec_code)
 		else:
-			vmsg('{}: {}'.format(purple('Running'),' '.join([cmd_name]+args)))
-			if exec_code: exec(exec_code)
-			aargs,kwargs = tool._process_args(cmd_name,args)
-			oq_save = opt.quiet
-			if not opt.verbose: opt.quiet = True
-			cmd_out = tool._process_result(getattr(tc,cmd_name)(*aargs,**kwargs))
-			opt.quiet = oq_save
+			cmd_out = run_func(cmd_name,args,out,opts,exec_code)
 
-		if type(out) != bytes:
-			cmd_out = cmd_out.strip()
-			vmsg('Output: {}\n'.format(cmd_out))
-		else:
-			vmsg('Output: {}\n'.format(repr(cmd_out)))
+		vmsg('Output: {}\n'.format(cmd_out if issubclass(type(out),str) else repr(cmd_out)))
+
+		def check_output(cmd_out,out):
+			if issubclass(type(out),str): out = out.encode()
+			if issubclass(type(cmd_out),int): cmd_out = str(cmd_out).encode()
+			if issubclass(type(cmd_out),str): cmd_out = cmd_out.encode()
 
-		if type(out).__name__ == 'function':
-			assert out(cmd_out),"{}({}) failed!".format(out.__name__,cmd_out)
-		elif type(out) == list and type(out[0]).__name__ == 'function':
-			for i in range(len(out)):
-				s = cmd_out.split('\n')[i]
-				assert out[i](s),"{}({}) failed!".format(out[i].__name__,s)
-		elif out is not None:
-			assert cmd_out == out,"Output ({}) doesn't match expected output ({})".format(cmd_out,out)
+			if type(out).__name__ == 'function':
+				assert out(cmd_out.decode()),"{}({}) failed!".format(out.__name__,cmd_out.decode())
+			elif type(out) == dict:
+				for k in out:
+					if k == 'boolfunc':
+						assert out[k](cmd_out.decode()),"{}({}) failed!".format(out[k].__name__,cmd_out.decod())
+					else:
+						if not getattr(__builtins__,k)(cmd_out) == out[k]:
+							die(1,"{}({}) did not return {}!".format(k,cmd_out,out[k]))
+			elif out is not None:
+				assert cmd_out == out,"Output ({!r}) doesn't match expected output ({!r})".format(cmd_out,out)
+
+		if type(out) in (list,tuple):
+			for co,o in zip(cmd_out.split('\n') if opt.fork else cmd_out,out):
+				check_output(co,o)
+		else:
+			check_output(cmd_out,out)
 
 		if not opt.verbose: msg_r('.')
 	if not opt.verbose:
 		msg('OK')
 
-def do_group(garg):
-	gid,gdesc,gdata = garg
-	qmsg(blue("Testing {}".format("command group '{}'".format(gid) if opt.names else gdesc)))
-	for cdata in gdata:
-		do_cmd(cdata)
+def extract_docstring(obj):
+	return obj.__doc__.strip().split('\n')[0]
+
+def do_group(gid):
+	qmsg(blue("Testing {}".format(
+		"command group '{}'".format(gid) if opt.names
+			else extract_docstring(getattr(tool,'MMGenToolCmd'+gid)))))
+
+	for cname in [e for e in dir(getattr(tool,'MMGenToolCmd'+gid)) if e[0] != '_']:
+		if cname not in tests[gid]:
+			m = 'No test for command {!r} in group {!r}!'.format(cname,gid)
+			if opt.die_on_missing:
+				die(1,m+'  Aborting')
+			else:
+				msg(m)
+				continue
+		run_test(gid,cname)
 
 def do_cmd_in_group(cmd):
-	for g in tests:
-		for cdata in g[2]:
-			if cdata[0] == cmd:
-				do_cmd(cdata)
+	for gid in tests:
+		for cname in tests[gid]:
+			if cname == cmd:
+				run_test(gid,cname)
 				return True
 	return False
 
 def list_tested_cmds():
-	for g in tests:
-		for cdata in g[2]:
-			Msg(cdata[0])
+	for gid in tests:
+		for cname in [e for e in dir(getattr(tool,'MMGenToolCmd'+gid)) if e[0] != '_']:
+			Msg(cname)
 
 sys.argv = [sys.argv[0]] + ['--skip-cfg-file'] + sys.argv[1:]
 
 cmd_args = opts.init(opts_data)
 
+import mmgen.tool as tool
+
 if opt.list_tests:
-	Msg('Available commands:')
-	for gid,gdesc,gdata in tests:
-		Msg('  {:12} - {}'.format(gid,gdesc))
+	Msg('Available tests:')
+	for gid in tests:
+		Msg('  {:6} - {}'.format(gid,extract_docstring(getattr(tool,'MMGenToolCmd'+gid))))
 	sys.exit(0)
 
 if opt.list_tested_cmds:
@@ -338,7 +714,6 @@ if opt.fork:
 		tool_cmd = ('python3') + tool_cmd
 else:
 	opt.usr_randchars = 0
-	import mmgen.tool as tool
 	tc = tool.MMGenToolCmd()
 
 start_time = int(time.time())
@@ -348,9 +723,8 @@ try:
 		if len(cmd_args) != 1:
 			die(1,'Only one command may be specified')
 		cmd = cmd_args[0]
-		group = [e for e in tests if e[0] == cmd]
-		if group:
-			do_group(group[0])
+		if cmd in tests:
+			do_group(cmd)
 		else:
 			if not do_cmd_in_group(cmd):
 				die(1,"'{}': not a recognized test or test group".format(cmd))