Browse Source

tool.py, main_tool.py: major rewrite, cleanup

- group tool commands into classes
- add docstrings to command classes and methods
- annotate command method args
- generate call signatures + help and usage screens on the fly

- API changes:
  - listaddresses,twview: new 'age_fmt' option replaces 'show_days' and 'show_age'
  - addrfile_chksum and friends: 'mmtype' option removed

- tooltest.py, tooltest2.py and test.py have been updated accordingly
MMGen 6 years ago
parent
commit
729a547c7d
11 changed files with 890 additions and 661 deletions
  1. 2 0
      mmgen/exception.py
  2. 1 1
      mmgen/globalvars.py
  3. 38 101
      mmgen/main_tool.py
  4. 490 377
      mmgen/tool.py
  5. 21 7
      mmgen/tw.py
  6. 7 5
      mmgen/tx.py
  7. 12 1
      mmgen/util.py
  8. 29 11
      scripts/test-release.sh
  9. 50 36
      test/test.py
  10. 31 48
      test/tooltest.py
  11. 209 74
      test/tooltest2.py

+ 2 - 0
mmgen/exception.py

@@ -22,6 +22,8 @@ mmgen.exception: Exception classes for the MMGen suite
 
 # 1: no hl, message only
 class UserNonConfirmation(Exception):     mmcode = 1
+class BadAgeFormat(Exception):            mmcode = 1
+class BadFilename(Exception):             mmcode = 1
 
 # 2: yellow hl, message only
 class UnrecognizedTokenSymbol(Exception): mmcode = 2

+ 1 - 1
mmgen/globalvars.py

@@ -50,7 +50,7 @@ class g(object):
 	keywords  = 'Bitcoin, BTC, cryptocurrency, wallet, cold storage, offline, online, spending, open-source, command-line, Python, Linux, Bitcoin Core, bitcoind, hd, deterministic, hierarchical, secure, anonymous, Electrum, seed, mnemonic, brainwallet, Scrypt, utility, script, scriptable, blockchain, raw, transaction, permissionless, console, terminal, curses, ansi, color, tmux, remote, client, daemon, RPC, json, entropy, xterm, rxvt, PowerShell, MSYS, MinGW, mswin, Armbian, Raspbian, Raspberry Pi, Orange Pi, BCash, BCH, Litecoin, LTC, altcoin, ZEC, Zcash, DASH, Dashpay, ETH, Ethereum, Classic, SHA256Compress, XMR, Monero, monerod, EMC, Emercoin, ERC20, token, deploy, contract, gas, fee, smart contract, solidity, Parity, testnet, devmode, Kovan'
 	max_int   = 0xffffffff
 
-	stdin_tty = bool(sys.stdin.isatty() or os.getenv('MMGEN_TEST_SUITE'))
+	stdin_tty = bool(sys.stdin.isatty() or os.getenv('MMGEN_TEST_SUITE_POPEN_SPAWN'))
 	stdout_fileno = sys.stdout.fileno()
 	stderr_fileno = sys.stderr.fileno()
 

+ 38 - 101
mmgen/main_tool.py

@@ -23,96 +23,35 @@ mmgen-tool:  Perform various MMGen- and cryptocoin-related operations.
 
 from mmgen.common import *
 
-stdin_msg = """
-To force a command to read from STDIN in place of its first argument (for
-supported commands), use '-' as the first argument.
-""".strip()
-
-cmd_help = """
-Cryptocoin address/key operations (compressed public keys supported):
-  addr2hexaddr   - convert coin address from base58 to hex format
-  hex2wif        - convert a private key from hex to WIF format (use '--type=zcash_z' for zcash-z key)
-  pubhash2addr   - convert public key hash to address
-  privhex2addr   - generate coin address from private key in hex format
-  privhex2pubhex - generate a hex public key from a hex private key
-  pubhex2addr    - convert a hex pubkey to an address
-  pubhex2redeem_script - convert a hex pubkey to a Segwit P2SH-P2WPKH redeem script
-  wif2redeem_script - convert a WIF private key to a Segwit P2SH-P2WPKH redeem script
-  wif2segwit_pair - generate both a Segwit P2SH-P2WPKH redeem script and address from WIF
-  pubkey2addr    - convert coin public key to address
-  randpair       - generate a random private key/address pair
-  randwif        - generate a random private key in WIF format
-  wif2addr       - generate a coin address from a key in WIF format
-  wif2hex        - convert a private key from WIF to hex format
-
-Wallet/TX operations (coin daemon must be running):
-  gen_addr      - generate a single MMGen address from default or specified wallet
-  gen_key       - generate a single MMGen WIF key from default or specified wallet
-  getbalance    - like '{pn}-cli getbalance' but shows confirmed/unconfirmed,
-                  spendable/unspendable balances for individual {pnm} wallets
-  listaddress   - list the specified {pnm} address and its balance
-  listaddresses - list {pnm} addresses and their balances
-  txview        - show raw/signed {pnm} transaction in human-readable form
-  twview        - view tracking wallet
-
-  keyaddrlist2monerowallets - create Monero wallets from key-address list
-  syncmonerowallets         - sync Monero wallets from key-address list
-
-General utilities:
-  hexdump      - encode data into formatted hexadecimal form (file or stdin)
-  unhexdump    - decode formatted hexadecimal data (file or stdin)
-  bytespec     - convert a byte specifier such as '1GB' into an integer
-  hexlify      - display string in hexadecimal format
-  hexreverse   - reverse bytes of a hexadecimal string
-  rand2file    - write 'n' bytes of random data to specified file
-  randhex      - print 'n' bytes (default 32) of random data in hex format
-  hash256      - compute sha256(sha256(data)) (double sha256)
-  hash160      - compute ripemd160(sha256(data)) (converts hexpubkey to hexaddr)
-  b58randenc   - generate a random 32-byte number and convert it to base 58
-  b58tostr     - convert a base 58 number to a string
-  strtob58     - convert a string to base 58
-  b58tohex     - convert a base 58 number to hexadecimal
-  b58chktohex  - convert a base58-check encoded number to hexadecimal
-  hextob58     - convert a hexadecimal number to base 58
-  hextob58chk  - convert a hexadecimal number to base58-check encoding
-  b32tohex     - convert a base 32 number to hexadecimal
-  hextob32     - convert a hexadecimal number to base 32
-
-File encryption:
-  encrypt      - encrypt a file
-  decrypt      - decrypt a file
-    {pnm} encryption suite:
-      * Key: Scrypt (user-configurable hash parameters, 32-byte salt)
-      * Enc: AES256_CTR, 16-byte rand IV, sha256 hash + 32-byte nonce + data
-      * The encrypted file is indistinguishable from random data
-
-{pnm}-specific operations:
-  remove_address - remove an address from tracking wallet
-  add_label      - add descriptive label for {pnm} address in tracking wallet
-  remove_label   - remove descriptive label for {pnm} address in tracking wallet
-  addrfile_chksum    - compute checksum for {pnm} address file
-  keyaddrfile_chksum - compute checksum for {pnm} key-address file
-  passwdfile_chksum  - compute checksum for {pnm} password file
-  find_incog_data    - Use an Incog ID to find hidden incognito wallet data
-  id6          - generate 6-character {pnm} ID for a file (or stdin)
-  id8          - generate 8-character {pnm} ID for a file (or stdin)
-  str2id6      - generate 6-character {pnm} ID for a string, ignoring spaces
-
-Mnemonic operations (choose 'electrum' (default), 'tirosh' or 'all'
-  wordlists):
-  mn_rand128   - generate random 128-bit mnemonic
-  mn_rand192   - generate random 192-bit mnemonic
-  mn_rand256   - generate random 256-bit mnemonic
-  mn_stats     - show stats for mnemonic wordlist
-  mn_printlist - print mnemonic wordlist
-  hex2mn       - convert a 16, 24 or 32-byte number in hex format to a mnemonic
-  mn2hex       - convert a 12, 18 or 24-word mnemonic to a number in hex format
-
-  IMPORTANT NOTE: Though {pnm} mnemonics use the Electrum wordlist, they're
-  computed using a different algorithm and are NOT Electrum-compatible!
-
-  {sm}
-"""
+def make_cmd_help():
+	import mmgen.tool
+	out = []
+	for bc in mmgen.tool.MMGenToolCmd.__bases__:
+		cls_doc = bc.__doc__.strip().split('\n')
+		for l in cls_doc:
+			if l is cls_doc[0]: l += ':'
+			l = l.replace('\t','',1)
+			if l:
+				l = l.replace('\t','  ')
+				out.append(l[0].upper() + l[1:])
+			else:
+				out.append('')
+		out.append('')
+
+		cls_funcs = bc.user_commands()
+		max_w = max(map(len,cls_funcs))
+		fs = '  {{:{}}} - {{}}'.format(max_w)
+		for func in cls_funcs:
+			m = getattr(bc,func)
+			if m.__doc__:
+				out.append(fs.format(func,
+					pretty_format(  m.__doc__.strip().replace('\n\t\t',' '),
+									width=79-(max_w+7),
+									pfx=' '*(max_w+5)).lstrip()
+				))
+		out.append('')
+
+	return '\n'.join(out)
 
 opts_data = lambda: {
 	'desc':    'Perform various {pnm}- and cryptocoin-related operations'.format(pnm=g.proj_name),
@@ -125,20 +64,17 @@ opts_data = lambda: {
 -q, --quiet           Produce quieter output
 -r, --usr-randchars=n Get 'n' characters of additional randomness from
                       user (min={g.min_urandchars}, max={g.max_urandchars})
--t, --type=t          Specify address type (valid options: 'compressed','segwit','bech32','zcash_z')
+-t, --type=t          Specify address type (valid options: 'legacy',
+                      'compressed', 'segwit', 'bech32', 'zcash_z')
 -v, --verbose         Produce more verbose output
 """.format(g=g),
 	'notes': """
 
                                COMMANDS
+
 {ch}
-Type '{pn} help <command> for help on a particular command
-""".format( pn=g.prog_name,
-			ch=cmd_help.format(
-				pn=g.proto.name,
-				pnm=g.proj_name,
-				sm='\n  '.join(stdin_msg.split('\n')))
-	)
+Type '{pn} help <command>' for help on a particular command
+""".format(pn=g.prog_name,ch=make_cmd_help())
 }
 
 cmd_args = opts.init(opts_data,add_opts=['hidden_incog_input_params','in_fmt','use_old_ed25519'])
@@ -149,8 +85,9 @@ cmd = cmd_args.pop(0)
 import mmgen.tool as tool
 tc = tool.MMGenToolCmd()
 
-if cmd == 'help' and not cmd_args:
-	tool._usage(exit_val=0)
+if cmd in ('help','usage') and cmd_args:
+	cmd_args[0] = 'command_name=' + cmd_args[0]
+
 if cmd not in dir(tc):
 	die(1,"'{}': no such command".format(cmd))
 
@@ -158,4 +95,4 @@ args,kwargs = tool._process_args(cmd,cmd_args)
 
 ret = getattr(tc,cmd)(*args,**kwargs)
 
-tool._print_result(ret,'pager' in kwargs and kwargs['pager'])
+tool._process_result(ret,to_screen=True,pager='pager' in kwargs and kwargs['pager'])

+ 490 - 377
mmgen/tool.py

@@ -17,172 +17,127 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 """
-tool.py:  Routines and data for the 'mmgen-tool' utility
+tool.py:  Routines for the 'mmgen-tool' utility
 """
 
-import binascii
-from collections import OrderedDict
+from binascii import hexlify,unhexlify
 
 from mmgen.protocol import hash160
 from mmgen.common import *
 from mmgen.crypto import *
-from mmgen.tx import *
 from mmgen.addr import *
 
-pnm = g.proj_name
-cmd_data = OrderedDict([
-	('help',         ['<tool command> [str]']),
-	('usage',        ['<tool command> [str]']),
-	('strtob58',     ['<string> [str-]','pad [int=0]']),
-	('b58tostr',     ['<b58 number> [str-]']),
-	('hextob58',     ['<hex number> [str-]','pad [int=0]']),
-	('hextob58chk',  ['<hex number> [str-]']),
-	('b58tohex',     ['<b58 number> [str-]','pad [int=0]']),
-	('b58chktohex',  ['<b58 number> [str-]']),
-	('b58randenc',   []),
-	('b32tohex',     ['<b32 num> [str-]','pad [int=0]']),
-	('hextob32',     ['<hex num> [str-]','pad [int=0]']),
-	('randhex',      ['nbytes [int=32]']),
-	('id8',          ['<infile> [str]']),
-	('id6',          ['<infile> [str]']),
-	('hash160',      ['<hexadecimal string> [str-]']),
-	('hash256',      ['<str, hexstr or filename> [str]', # TODO handle stdin
-							'hex_input [bool=False]','file_input [bool=False]']),
-	('str2id6',      ['<string (spaces are ignored)> [str-]']),
-	('hexdump',      ['<infile> [str]', 'cols [int=8]', 'line_nums [bool=True]']),
-	('unhexdump',    ['<infile> [str]']),
-	('hexreverse',   ['<hexadecimal string> [str-]']),
-	('hexlify',      ['<string> [str-]']),
-	('rand2file',    ['<outfile> [str]','<nbytes> [str]','threads [int=4]','silent [bool=False]']),
-
-	('randwif',    []),
-	('randpair',   []),
-	('hex2wif',    ['<private key in hex format> [str-]']),
-	('wif2hex',    ['<wif> [str-]']),
-	('wif2addr',   ['<wif> [str-]']),
-	('wif2segwit_pair',['<wif> [str-]']),
-	('pubhash2addr', ['<coin address in hex format> [str-]']),
-	('addr2hexaddr', ['<coin address> [str-]']),
-	('privhex2addr', ['<private key in hex format> [str-]']),
-	('privhex2pubhex',['<private key in hex format> [str-]']),
-	('pubhex2addr',  ['<public key in hex format> [str-]']), # new
-	('pubhex2redeem_script',['<public key in hex format> [str-]']), # new
-	('wif2redeem_script', ['<private key in WIF format> [str-]']), # new
-
-	('hex2mn',       ['<hexadecimal string> [str-]',"wordlist [str='electrum']"]),
-	('mn2hex',       ['<mnemonic> [str-]', "wordlist [str='electrum']"]),
-	('mn_rand128',   ["wordlist [str='electrum']"]),
-	('mn_rand192',   ["wordlist [str='electrum']"]),
-	('mn_rand256',   ["wordlist [str='electrum']"]),
-	('mn_stats',     ["wordlist [str='electrum']"]),
-	('mn_printlist', ["wordlist [str='electrum']"]),
-
-	('gen_addr',     ['<{} ID> [str]'.format(pnm),"wallet [str='']"]),
-	('gen_key',      ['<{} ID> [str]'.format(pnm),"wallet [str='']"]),
-
-	('listaddress',['<{} address> [str]'.format(pnm),'minconf [int=1]','pager [bool=False]','showempty [bool=True]','showbtcaddr [bool=True]','show_age [bool=False]','show_days [bool=True]']),
-	('listaddresses',["addrs [str='']",'minconf [int=1]','showempty [bool=False]','pager [bool=False]','showbtcaddrs [bool=True]','all_labels [bool=False]',"sort [str=''] (options: reverse, age)",'show_age [bool=False]','show_days [bool=True]']),
-	('getbalance',   ['minconf [int=1]','quiet [bool=False]','pager [bool=False]']),
-	('txview',       ['<{} TX file(s)> [str]'.format(pnm),'pager [bool=False]','terse [bool=False]',"sort [str='mtime'] (options: ctime, atime)",'MARGS']),
-	('twview',       ["sort [str='age']",'reverse [bool=False]','show_days [bool=True]','show_mmid [bool=True]','minconf [int=1]','wide [bool=False]','pager [bool=False]']),
-
-	('add_label',       ['<{} or coin address> [str]'.format(pnm),'<label> [str]']),
-	('remove_label',    ['<{} or coin address> [str]'.format(pnm)]),
-	('remove_address',  ['<{} or coin address> [str]'.format(pnm)]),
-	('addrfile_chksum', ['<{} addr file> [str]'.format(pnm),"mmtype [str='']"]),
-	('keyaddrfile_chksum', ['<{} addr file> [str]'.format(pnm),"mmtype [str='']"]),
-	('passwdfile_chksum', ['<{} password file> [str]'.format(pnm)]),
-	('find_incog_data', ['<file or device name> [str]','<Incog ID> [str]','keep_searching [bool=False]']),
-
-	('encrypt',      ['<infile> [str]',"outfile [str='']","hash_preset [str='']"]),
-	('decrypt',      ['<infile> [str]',"outfile [str='']","hash_preset [str='']"]),
-	('bytespec',     ['<bytespec> [str]']),
-
-	('keyaddrlist2monerowallets',['<{} XMR key-address file> [str]'.format(pnm),'blockheight [int=(current height)]',"addrs [str=''] (addr idx list or range)"]),
-	('syncmonerowallets',        ['<{} XMR key-address file> [str]'.format(pnm),"addrs [str=''] (addr idx list or range)"]),
-])
+def _create_call_sig(cmd,parsed=False):
+
+	m = getattr(MMGenToolCmd,cmd)
+
+	if 'varargs_call_sig' in m.__code__.co_varnames: # hack
+		flag = 'VAR_ARGS'
+		va = m.__defaults__[0]
+		args,dfls,ann = va['args'],va['dfls'],va['annots']
+	else:
+		flag = None
+		args = m.__code__.co_varnames[1:m.__code__.co_argcount]
+		dfls = m.__defaults__ or ()
+		ann  = m.__annotations__
+
+	nargs = len(args) - len(dfls)
+
+	if parsed:
+		c_args = [(a,'str' if ann[a] == 'sstr' else ann[a].__name__) 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_kwargs = ['"{}" [{}={!r}{}]'.format(
+					a, type(dfls[n]).__name__, dfls[n],
+					(' ' + ann[a] if a in ann else ''))
+						for n,a in enumerate(args[nargs:])]
+		return ' '.join(c_args + c_kwargs)
 
 def _usage(cmd=None,exit_val=1):
 
-	for v in cmd_data.values():
-		if v and v[0][-2:] == '-]':
-			v[0] = v[0][:-2] + ' or STDIN]'
-		if 'MARGS' in v: v.remove('MARGS')
+	m1=('USAGE INFORMATION FOR MMGEN-TOOL COMMANDS:\n\n'
+		'  Unquoted arguments are mandatory\n'
+		'  Quoted arguments are optional, default values will be used\n'
+		'  Argument types and default values are shown in square brackets\n')
+
+	m2=('  To force a command to read from STDIN instead of file (for commands taking\n'
+		'  a filename as their first argument), substitute "-" for the filename.\n\n'
+		'EXAMPLES:\n\n'
+		'  Generate a random Bech32 public/private keypair for LTC:\n'
+		'  $ mmgen-tool -r0 --coin=ltc --type=bech32 randpair\n\n'
+		'  Generate a well-known burn address:\n'
+		'  $ mmgen-tool hextob58chk 000000000000000000000000000000000000000000\n\n'
+		'  Generate a random 12-word seed phrase:\n'
+		'  $ mmgen-tool -r0 mn_rand128\n\n'
+		'  Same as above, but get additional entropy from user:\n'
+		'  $ mmgen-tool mn_rand128\n\n'
+		'  Convert a string to base 58:\n'
+		'  $ mmgen-tool strtob58 "foobarbaz" pad=20\n\n'
+		'  Reverse a hex string:\n'
+		'  $ mmgen-tool hexreverse "deadbeefcafe"\n\n'
+		'  Same as above, but use a pipe:\n'
+		'  $ echo "deadbeefcafe" | mmgen-tool hexreverse -')
 
 	if not cmd:
-		Msg('Usage information for mmgen-tool commands:')
-		for k,v in list(cmd_data.items()):
-			Msg('  {:18} {}'.format(k,' '.join(v)))
-		from mmgen.main_tool import stdin_msg
-		Msg('\n  '+'\n  '.join(stdin_msg.split('\n')))
-		sys.exit(0)
-
-	if cmd in cmd_data:
-		import re
-		from mmgen.main_tool import cmd_help
-		for line in cmd_help.split('\n'):
-			if re.match(r'\s+{}\s+'.format(cmd),line):
-				c,h = line.split('-',1)
-				Msg('MMGEN-TOOL {}: {}'.format(c.strip().upper(),h.strip()))
-		cd = cmd_data[cmd]
-		msg('USAGE: {} {} {}'.format(g.prog_name,cmd,' '.join(cd)))
+		Msg(m1)
+		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()
+			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():
+		msg('USAGE: {} {} {}'.format(g.prog_name,cmd,_create_call_sig(cmd)))
 	else:
 		die(1,"'{}': no such tool command".format(cmd))
 
 	sys.exit(exit_val)
 
 def _process_args(cmd,cmd_args):
-	if 'MARGS' in cmd_data[cmd]:
-		cmd_data[cmd].remove('MARGS')
-		margs = True
-	else:
-		margs = False
-
-	c_args = [[i.split(' [')[0],i.split(' [')[1][:-1]]
-		for i in cmd_data[cmd] if '=' not in i]
-	c_kwargs = dict([[
-			i.split(' [')[0],
-			[i.split(' [')[1].split('=')[0],i.split(' [')[1].split('=')[1][:-1]]
-		] for i in cmd_data[cmd] if '=' in i])
-
-	if not margs:
-		u_args = [a for a in cmd_args[:len(c_args)]]
-
-		if c_args and c_args[0][1][-1] == '-':
-			c_args[0][1] = c_args[0][1][:-1] # [str-] -> [str]
-			# If we're reading from a pipe, replace '-' with output of previous command
-			if u_args and u_args[0] == '-':
-				if not sys.stdin.isatty():
-					u_args[0] = sys.stdin.read().strip()
-					if not u_args[0]:
-						die(2,'{}: ERROR: no output from previous command in pipe'.format(cmd))
-
-		if not margs and len(u_args) < len(c_args):
+	c_args,c_kwargs,flag = _create_call_sig(cmd,parsed=True)
+
+	if flag != 'VAR_ARGS':
+		if len(cmd_args) < len(c_args):
 			m1 = 'Command requires exactly {} non-keyword argument{}'
 			msg(m1.format(len(c_args),suf(c_args,'s')))
 			_usage(cmd)
 
-	extra_args = len(cmd_args) - len(c_args)
+		u_args = cmd_args[:len(c_args)]
+
+		# If we're reading from a pipe, replace '-' with output of previous command
+		if flag == 'STDIN_OK' and u_args and u_args[0] == '-':
+			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()
+				if not u_args[0]:
+					die(2,'{}: ERROR: no output from previous command in pipe'.format(cmd))
+
+	u_nkwargs = len(cmd_args) - len(c_args)
 	u_kwargs = {}
-	if margs:
-		t = [a.split('=') for a in cmd_args if '=' in a]
+	if flag == 'VAR_ARGS':
+		t = [a.split('=',1) for a in cmd_args if '=' in a]
 		tk = [a[0] for a in t]
 		tk_bad = [a for a in tk if a not in c_kwargs]
-		if set(tk_bad) != set(tk[:len(tk_bad)]):
+		if set(tk_bad) != set(tk[:len(tk_bad)]): # permit non-kw args to contain '='
 			die(1,"'{}': illegal keyword argument".format(tk_bad[-1]))
 		u_kwargs = dict(t[len(tk_bad):])
 		u_args = cmd_args[:-len(u_kwargs) or None]
-	elif extra_args > 0:
-		u_kwargs = dict([a.split('=') for a in cmd_args[len(c_args):] if '=' in a])
-		if len(u_kwargs) != extra_args:
-			msg('Command requires exactly {} non-keyword argument{}'.format(len(c_args),suf(c_args,'s')))
+	elif u_nkwargs > 0:
+		u_kwargs = dict([a.split('=',1) for a in cmd_args[len(c_args):] if '=' in a])
+		if len(u_kwargs) != u_nkwargs:
+			msg('Command requires exactly {} non-keyword argument{}'.format(len(c_args),suf(c_args)))
 			_usage(cmd)
 		if len(u_kwargs) > len(c_kwargs):
-			msg('Command requires exactly {} keyword argument{}'.format(len(c_kwargs),suf(c_kwargs,'s')))
+			msg('Command accepts no more than {} keyword argument{}'.format(len(c_kwargs),suf(c_kwargs)))
 			_usage(cmd)
 
-	# mdie(c_args,c_kwargs,u_args,u_kwargs)
-
 	for k in u_kwargs:
 		if k not in c_kwargs:
 			msg("'{}': invalid keyword argument".format(k))
@@ -201,138 +156,255 @@ def _process_args(cmd,cmd_args):
 		except:
 			die(1,"'{}': Invalid argument for argument {} ('{}' required)".format(arg,arg_name,arg_type))
 
-	if margs:
+	if flag == 'VAR_ARGS':
 		args = [conv_type(u_args[i],c_args[0][0],c_args[0][1]) for i in range(len(u_args))]
 	else:
 		args = [conv_type(u_args[i],c_args[i][0],c_args[i][1]) for i in range(len(c_args))]
-	kwargs = dict([(k,conv_type(u_kwargs[k],k,c_kwargs[k][0])) for k in u_kwargs])
+	kwargs = dict([(k,conv_type(u_kwargs[k],k,type(c_kwargs[k]).__name__)) for k in u_kwargs])
 
 	return args,kwargs
 
-def _get_result(ret): # returns a string or string subclass
+def _process_result(ret,to_screen=False,pager=False): # returns a string or string subclass
+	do_ret = not to_screen
 	if issubclass(type(ret),str):
-		return ret
-	elif type(ret) == tuple:
-		return '\n'.join([r.decode() if issubclass(type(r),bytes) else r for r in ret])
-	elif issubclass(type(ret),bytes):
-		try: return ret.decode()
-		except: return repr(ret)
-	elif ret == True:
-		return ''
-	elif ret in (False,None):
-		ydie(1,"tool command returned '{}'".format(ret))
-	else:
-		ydie(1,"tool.py: can't handle return value of type '{}'".format(type(ret).__name__))
-
-def _print_result(ret,pager):
-	if issubclass(type(ret),str):
-		do_pager(ret) if pager else Msg(ret)
+		return ret if do_ret else do_pager(ret) if pager else Msg(ret)
 	elif type(ret) == tuple:
 		o = '\n'.join([r.decode() if issubclass(type(r),bytes) else r for r in ret])
-		do_pager(o) if pager else Msg(o)
+		return o if do_ret else do_pager(o) if pager else Msg(o)
 	elif issubclass(type(ret),bytes):
-		try:
-			o = ret.decode()
-			do_pager(o) if pager else Msg(o)
-		except: os.write(1,ret)
+		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)
 	elif ret == True:
-		pass
+		if do_ret: return ''
 	elif ret in (False,None):
 		ydie(1,"tool command returned '{}'".format(ret))
 	else:
 		ydie(1,"tool.py: can't handle return value of type '{}'".format(type(ret).__name__))
 
 from mmgen.obj import MMGenAddrType
-at = MMGenAddrType((hasattr(opt,'type') and opt.type) or g.proto.dfl_mmtype)
-kg = KeyGenerator(at)
-ag = AddrGenerator(at)
+
+def init_generators(arg=None):
+	global at,kg,ag
+	at = MMGenAddrType((hasattr(opt,'type') and opt.type) or g.proto.dfl_mmtype)
+	if arg != 'at':
+		kg = KeyGenerator(at)
+		ag = AddrGenerator(at)
+
 wordlists = 'electrum','tirosh'
 dfl_wl_id = 'electrum'
 
-class MMGenToolCmd(object):
+class MMGenToolCmdBase(object):
+
+	@classmethod
+	def user_commands(cls):
+		return [e for e in dir(cls) if e[0] != '_' and getattr(cls,e).__doc__]
+
 
-	def help(self,cmd=None):
-		_usage(cmd,exit_val=0)
+class MMGenToolCmdMisc(MMGenToolCmdBase):
+	"miscellaneous commands"
 
-	def usage(self,cmd=None):
-		_usage(cmd,exit_val=0)
+	def help(self,command_name=''):
+		"display usage information for a single command or all commands"
+		_usage(command_name,exit_val=0)
 
-	def hexdump(self,infile,cols=8,line_nums=True):
+	usage = help
+
+class MMGenToolCmdUtil(MMGenToolCmdBase):
+	"general string conversion and hashing utilities"
+
+	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))
+
+	def randhex(self,nbytes='32'):
+		"print 'n' bytes (default 32) of random data in hex format"
+		return hexlify(get_random(int(nbytes)))
+
+	def hexreverse(self,hexstr:'sstr'):
+		"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 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)
 
-	def unhexdump(self,infile):
+	def unhexdump(self,infile:str):
+		"decode formatted hexadecimal data (file or stdin)"
 		if g.platform == 'win':
 			import msvcrt
 			msvcrt.setmode(sys.stdout.fileno(),os.O_BINARY)
 		hexdata = get_data_from_file(infile,dash=True,silent=True)
 		return decode_pretty_hexdump(hexdata)
 
+	def hash160(self,hexstr:'sstr'):
+		"compute ripemd160(sha256(data)) (convert hex pubkey to hex addr)"
+		return hash160(hexstr)
+
+	def hash256(self,string_or_bytes:str,file_input=False,hex_input=False): # TODO: handle stdin
+		"compute sha256(sha256(data)) (double sha256)"
+		from hashlib import sha256
+		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()
+
+	def id6(self,infile:str):
+		"generate 6-character MMGen ID for a file (or stdin)"
+		return make_chksum_6(
+			get_data_from_file(infile,dash=True,silent=True,binary=True))
+
+	def str2id6(self,string:'sstr'): # retain ignoring of space for backwards compat
+		"generate 6-character MMGen ID for a string, ignoring spaces"
+		return make_chksum_6(''.join(string.split()))
+
+	def id8(self,infile:str):
+		"generate 8-character MMGen ID for a file (or 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 randhex(self,nbytes='32'):
-		return binascii.hexlify(get_random(int(nbytes)))
+	def strtob58(self,string:'sstr',pad=0):
+		"convert a string to base 58"
+		return baseconv.fromhex(hexlify(string.encode()),'b58',pad,tostr=True)
+
+	def b58tostr(self,b58num:'sstr'):
+		"convert a base 58 number to a string"
+		return unhexlify(baseconv.tohex(b58num,'b58'))
+
+	def hextob58(self,hexstr:'sstr',pad=0):
+		"convert a hexadecimal number to base 58"
+		return baseconv.fromhex(hexstr.encode(),'b58',pad,tostr=True)
+
+	def b58tohex(self,b58num:'sstr',pad=0):
+		"convert a base 58 number to hexadecimal"
+		return baseconv.tohex(b58num,'b58',pad)
+
+	def hextob58chk(self,hexstr:'sstr'):
+		"convert a hexadecimal number to base58-check encoding"
+		from mmgen.protocol import _b58chk_encode
+		return _b58chk_encode(hexstr.encode())
+
+	def b58chktohex(self,b58chk_num:'sstr'):
+		"convert a base58-check encoded number to hexadecimal"
+		from mmgen.protocol import _b58chk_decode
+		return _b58chk_decode(b58chk_num)
+
+	def hextob32(self,hexstr:'sstr',pad=0):
+		"convert a hexadecimal number to 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"
+		return baseconv.tohex(b32num.upper(),'b32',pad)
 
+class MMGenToolCmdCoin(MMGenToolCmdBase):
+	"""
+	cryptocoin key/address utilities
+
+		May require use of the '--coin' and '--type' options
+
+		Examples:
+			mmgen-tool --coin=ltc --type=bech32 wif2addr <wif key>
+			mmgen-tool --coin=zec --type=zcash_z randpair
+	"""
 	def randwif(self):
+		"generate a random private key in WIF format"
+		init_generators('at')
 		return PrivKey(get_random(32),pubkey_type=at.pubkey_type,compressed=at.compressed).wif
 
 	def randpair(self):
+		"generate a random private key/address pair"
+		init_generators()
 		privhex = PrivKey(get_random(32),pubkey_type=at.pubkey_type,compressed=at.compressed)
 		addr = ag.to_addr(kg.to_pubhex(privhex))
 		return (privhex.wif,addr)
 
-	def wif2addr(self,wif):
-		privhex = PrivKey(wif=wif)
+	def wif2hex(self,wifkey:'sstr'):
+		"convert a private key from WIF to hex format"
+		return PrivKey(wif=wifkey)
+
+	def hex2wif(self,privhex:'sstr'):
+		"convert a private key from hex to WIF format"
+		init_generators('at')
+		return g.proto.hex2wif(privhex.encode(),pubkey_type=at.pubkey_type,compressed=at.compressed)
+
+	def wif2addr(self,wifkey:'sstr'):
+		"generate a coin address from a key in WIF format"
+		init_generators()
+		privhex = PrivKey(wif=wifkey)
 		addr = ag.to_addr(kg.to_pubhex(privhex))
 		return addr
 
-	def wif2segwit_pair(self,wif):
-		pubhex = kg.to_pubhex(PrivKey(wif=wif))
+	def wif2redeem_script(self,wifkey:'sstr'): # new
+		"convert a WIF private key to a Segwit P2SH-P2WPKH redeem script"
+		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"
+		init_generators()
+		pubhex = kg.to_pubhex(PrivKey(wif=wifkey))
 		addr = ag.to_addr(pubhex)
 		rs = ag.to_segwit_redeem_script(pubhex)
 		return (rs,addr)
 
-	def pubhash2addr(self,pubhash):
-		if opt.type == 'bech32':
-			return g.proto.pubhash2bech32addr(pubhash.encode())
-		else:
-			return g.proto.pubhash2addr(pubhash.encode(),at.addr_fmt=='p2sh')
-
-	def addr2hexaddr(self,addr):
-		return g.proto.verify_addr(addr,CoinAddr.hex_width,return_dict=True)['hex']
+	def privhex2addr(self,privhex:'sstr',output_pubhex=False):
+		"generate coin address from private key in hex format"
+		init_generators()
+		pk = PrivKey(unhexlify(privhex),compressed=at.compressed,pubkey_type=at.pubkey_type)
+		ph = kg.to_pubhex(pk)
+		return ph if output_pubhex else ag.to_addr(ph)
 
-	def hash160(self,pubkeyhex):
-		return hash160(pubkeyhex)
+	def privhex2pubhex(self,privhex:'sstr'): # new
+		"generate a hex public key from a hex private key"
+		return self.privhex2addr(privhex,output_pubhex=True)
 
-	def pubhex2addr(self,pubkeyhex):
+	def pubhex2addr(self,pubkeyhex:'sstr'):
+		"convert a hex pubkey to an address"
 		return self.pubhash2addr(hash160(pubkeyhex.encode()).decode())
 
-	def wif2hex(self,wif):
-		return PrivKey(wif=wif)
-
-	def hex2wif(self,hexpriv):
-		return g.proto.hex2wif(hexpriv.encode(),pubkey_type=at.pubkey_type,compressed=at.compressed)
+	def pubhex2redeem_script(self,pubkeyhex:'sstr'): # new
+		"convert a hex pubkey to a Segwit P2SH-P2WPKH redeem script"
+		return g.proto.pubhex2redeem_script(pubkeyhex)
 
-	def privhex2addr(self,privhex,output_pubhex=False):
-		pk = PrivKey(binascii.unhexlify(privhex),compressed=at.compressed,pubkey_type=at.pubkey_type)
-		ph = kg.to_pubhex(pk)
-		return ph if output_pubhex else ag.to_addr(ph)
-
-	def privhex2pubhex(self,privhex): # new
-		return self.privhex2addr(privhex,output_pubhex=True)
+	def pubhash2addr(self,pubhashhex:'sstr'):
+		"convert public key hash to address"
+		if opt.type == 'bech32':
+			return g.proto.pubhash2bech32addr(pubhashhex.encode())
+		else:
+			init_generators('at')
+			return g.proto.pubhash2addr(pubhashhex.encode(),at.addr_fmt=='p2sh')
 
-	def pubhex2redeem_script(self,pubhex): # new
-		return g.proto.pubhex2redeem_script(pubhex)
+	def addr2hexaddr(self,addr:'sstr'):
+		"convert coin address from base58 to hex format"
+		return g.proto.verify_addr(addr,CoinAddr.hex_width,return_dict=True)['hex']
 
-	def wif2redeem_script(self,wif): # new
-		privhex = PrivKey(wif=wif)
-		return ag.to_segwit_redeem_script(kg.to_pubhex(privhex))
+class MMGenToolCmdMnemonic(MMGenToolCmdBase):
+	"""
+	seed mnemonic utilities (wordlist: choose 'electrum' (default) or 'tirosh')
 
-	def do_random_mn(self,nbytes,wordlist):
-		hexrand = binascii.hexlify(get_random(nbytes))
+		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):
+		hexrand = hexlify(get_random(nbytes))
 		Vmsg('Seed: {}'.format(hexrand))
 		for wl_id in ([wordlist],wordlists)[wordlist=='all']:
 			if wordlist == 'all': # TODO
@@ -341,94 +413,82 @@ class MMGenToolCmd(object):
 			return ' '.join(mn)
 
 	def mn_rand128(self,wordlist=dfl_wl_id):
-		return self.do_random_mn(16,wordlist)
+		"generate random 128-bit mnemonic"
+		return self._do_random_mn(16,wordlist)
 
 	def mn_rand192(self,wordlist=dfl_wl_id):
-		return self.do_random_mn(24,wordlist)
+		"generate random 192-bit mnemonic"
+		return self._do_random_mn(24,wordlist)
 
 	def mn_rand256(self,wordlist=dfl_wl_id):
-		return self.do_random_mn(32,wordlist)
-
-	def hex2mn(self,s,wordlist=dfl_wl_id):
-		return ' '.join(baseconv.fromhex(s.encode(),wordlist))
-
-	def mn2hex(self,s,wordlist=dfl_wl_id):
-		return baseconv.tohex(s.split(),wordlist)
-
-	def strtob58(self,s,pad=None):
-		return baseconv.fromhex(binascii.hexlify(s.encode()),'b58',pad,tostr=True)
-
-	def hextob58(self,s,pad=None):
-		return baseconv.fromhex(s.encode(),'b58',pad,tostr=True)
-
-	def hextob58chk(self,s):
-		from mmgen.protocol import _b58chk_encode
-		return _b58chk_encode(s.encode())
-
-	def hextob32(self,s,pad=None):
-		return baseconv.fromhex(s.encode(),'b32',pad,tostr=True)
+		"generate random 256-bit mnemonic"
+		return self._do_random_mn(32,wordlist)
 
-	def b58tostr(self,s):
-		return binascii.unhexlify(baseconv.tohex(s,'b58'))
+	def hex2mn(self,hexstr:'sstr',wordlist=dfl_wl_id):
+		"convert a 16, 24 or 32-byte hexadecimal number to a mnemonic"
+		return ' '.join(baseconv.fromhex(hexstr.encode(),wordlist))
 
-	def b58tohex(self,s,pad=None):
-		return baseconv.tohex(s,'b58',pad)
-
-	def b58chktohex(self,s):
-		from mmgen.protocol import _b58chk_decode
-		return _b58chk_decode(s)
-
-	def b32tohex(self,s,pad=None):
-		return baseconv.tohex(s.upper(),'b32',pad)
+	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)
 
 	def mn_stats(self,wordlist=dfl_wl_id):
+		"show stats for mnemonic wordlist"
 		wordlist in baseconv.digits or die(1,"'{}': not a valid wordlist".format(wordlist))
 		baseconv.check_wordlist(wordlist)
 		return True
 
 	def mn_printlist(self,wordlist=dfl_wl_id):
+		"print mnemonic wordlist"
 		wordlist in baseconv.digits or die(1,"'{}': not a valid wordlist".format(wordlist))
 		return '\n'.join(baseconv.digits[wordlist])
 
-	def id8(self,infile):
-		return make_chksum_8(
-			get_data_from_file(infile,dash=True,silent=True,binary=True))
-
-	def id6(self,infile):
-		return make_chksum_6(
-			get_data_from_file(infile,dash=True,silent=True,binary=True))
-
-	def str2id6(self,s): # retain ignoring of space for backwards compat
-		return make_chksum_6(''.join(s.split()))
+class MMGenToolCmdFile(MMGenToolCmdBase):
+	"utilities for viewing/checking MMGen address and transaction files"
 
-	def addrfile_chksum(self,infile,mmtype=''):
+	def addrfile_chksum(self,mmgen_addrfile:str):
+		"compute checksum for MMGen address file"
 		from mmgen.addr import AddrList
-		mmtype = None if not mmtype else MMGenAddrType(mmtype)
-		return AddrList(infile,mmtype=mmtype).chksum
+		return AddrList(mmgen_addrfile).chksum
 
-	def keyaddrfile_chksum(self,infile,mmtype=''):
+	def keyaddrfile_chksum(self,mmgen_keyaddrfile:str):
+		"compute checksum for MMGen key-address file"
 		from mmgen.addr import KeyAddrList
-		mmtype = None if not mmtype else MMGenAddrType(mmtype)
-		return KeyAddrList(infile,mmtype=mmtype).chksum
+		return KeyAddrList(mmgen_keyaddrfile).chksum
 
-	def passwdfile_chksum(self,infile):
+	def passwdfile_chksum(self,mmgen_passwdfile:str):
+		"compute checksum for MMGen password file"
 		from mmgen.addr import PasswordList
-		return PasswordList(infile=infile).chksum
-
-	def hexreverse(self,s):
-		return binascii.hexlify(binascii.unhexlify(s.strip())[::-1])
-
-	def hexlify(self,s):
-		return binascii.hexlify(s.encode())
-
-	def hash256(self,s,file_input=False,hex_input=False):
-		from hashlib import sha256
-		if file_input:  b = get_data_from_file(s,binary=True)
-		elif hex_input: b = decode_pretty_hexdump(s)
-		else:           b = s
-		return sha256(sha256(b.encode()).digest()).hexdigest()
+		return PasswordList(infile=mmgen_passwdfile).chksum
+
+	def txview( varargs_call_sig = { # hack to allow for multiple filenames
+					'args':   ( 'mmgen_tx_file(s)', 'pager', 'terse', 'sort' ),
+					'dfls':   ( False, False, 'mtime' ),
+					'annots': { 'mmgen_tx_file(s)': str, 'sort': '(valid options: mtime,ctime,atime)' } },
+				*infiles,**kwargs):
+		"show raw/signed MMGen transaction in human-readable form"
+		self = varargs_call_sig
+		from mmgen.filename import MMGenFileList
+		terse = 'terse' in kwargs and kwargs['terse']
+		sort_key = kwargs['sort'] if 'sort' in kwargs else 'mtime'
+		from mmgen.tx import MMGenTX
+		flist = MMGenFileList(infiles,ftype=MMGenTX)
+		flist.sort_by_age(key=sort_key) # in-place sort
+		from mmgen.term import get_terminal_size
+		sep = '—'*77+'\n'
+		return sep.join([MMGenTX(fn).format_view(terse=terse) for fn in flist.names()]).rstrip()
 
-	def encrypt(self,infile,outfile='',hash_preset=''):
+class MMGenToolCmdFileCrypt(MMGenToolCmdBase):
+	"""
+	file encryption and decryption
+
+		MMGen encryption suite:
+		* Key: Scrypt (user-configurable hash parameters, 32-byte salt)
+		* Enc: AES256_CTR, 16-byte rand IV, sha256 hash + 32-byte nonce + data
+		* The encrypted file is indistinguishable from random data
+	"""
+	def encrypt(self,infile:str,outfile='',hash_preset=''):
+		"encrypt a file"
 		data = get_data_from_file(infile,'data for encryption',binary=True)
 		enc_d = mmgen_encrypt(data,'user data',hash_preset)
 		if not outfile:
@@ -436,7 +496,8 @@ class MMGenToolCmd(object):
 		write_data_to_file(outfile,enc_d,'encrypted data',binary=True)
 		return True
 
-	def decrypt(self,infile,outfile='',hash_preset=''):
+	def decrypt(self,infile:str,outfile='',hash_preset=''):
+		"decrypt a file"
 		enc_d = get_data_from_file(infile,'encrypted data',binary=True)
 		while True:
 			dec_d = mmgen_decrypt(enc_d,'user data',hash_preset)
@@ -449,22 +510,26 @@ class MMGenToolCmd(object):
 		write_data_to_file(outfile,dec_d,'decrypted data',binary=True)
 		return True
 
-	def find_incog_data(self,filename,iv_id,keep_searching=False):
+class MMGenToolCmdFileUtil(MMGenToolCmdBase):
+	"file utilities"
+
+	def find_incog_data(self,filename:str,incog_id:str,keep_searching=False):
+		"Use an Incog ID to find hidden incognito wallet data"
 		ivsize,bsize,mod = g.aesctr_iv_len,4096,4096*8
 		n,carry = 0,b' '*ivsize
 		flgs = os.O_RDONLY|os.O_BINARY if g.platform == 'win' else os.O_RDONLY
 		f = os.open(filename,flgs)
-		for ch in iv_id:
+		for ch in incog_id:
 			if ch not in '0123456789ABCDEF':
-				die(2,"'{}': invalid Incog ID".format(iv_id))
+				die(2,"'{}': invalid Incog ID".format(incog_id))
 		while True:
 			d = os.read(f,bsize)
 			if not d: break
 			d = carry + d
 			for i in range(bsize):
-				if sha256(d[i:i+ivsize]).hexdigest()[:8].upper() == iv_id:
+				if sha256(d[i:i+ivsize]).hexdigest()[:8].upper() == incog_id:
 					if n+i < ivsize: continue
-					msg('\rIncog data for ID {} found at offset {}'.format(iv_id,n+i-ivsize))
+					msg('\rIncog data for ID {} found at offset {}'.format(incog_id,n+i-ivsize))
 					if not keep_searching: sys.exit(0)
 			carry = d[len(d)-ivsize:]
 			n += bsize
@@ -475,7 +540,8 @@ class MMGenToolCmd(object):
 		os.close(f)
 		return True
 
-	def rand2file(self,outfile,nbytes,threads=4,silent=False):
+	def rand2file(self,outfile:str,nbytes:str,threads=4,silent=False):
+		"write 'n' bytes of random data to specified file"
 		nbytes = parse_bytespec(nbytes)
 		from Crypto import Random
 		rh = Random.new()
@@ -533,16 +599,139 @@ class MMGenToolCmd(object):
 		f.close()
 		return True
 
-	def bytespec(self,s):
-		return str(parse_bytespec(s))
+class MMGenToolCmdWallet(MMGenToolCmdBase):
+	"key or address generation from an MMGen wallet"
 
-	def keyaddrlist2monerowallets(self,infile,blockheight=None,addrs=None):
-		return self.monero_wallet_ops(infile=infile,op='create',blockheight=blockheight,addrs=addrs)
+	def gen_key(self,mmgen_addr:str,wallet=''):
+		"generate a single MMGen WIF key from default or specified wallet"
+		return self.gen_addr(mmgen_addr,wallet,target='wif')
 
-	def syncmonerowallets(self,infile,addrs=None):
-		return self.monero_wallet_ops(infile=infile,op='sync',addrs=addrs)
+	def gen_addr(self,mmgen_addr:str,wallet='',target='addr'):
+		"generate a single MMGen address from default or specified wallet"
+		addr = MMGenID(mmgen_addr)
+		sf = get_seed_file([wallet] if wallet else [],1)
+		opt.quiet = True
+		from mmgen.seed import SeedSource
+		ss = SeedSource(sf)
+		if ss.seed.sid != addr.sid:
+			m = 'Seed ID of requested address ({}) does not match wallet ({})'
+			die(1,m.format(addr.sid,ss.seed.sid))
+		al = AddrList(seed=ss.seed,addr_idxs=AddrIdxList(str(addr.idx)),mmtype=addr.mmtype)
+		d = al.data[0]
+		ret = d.sec.wif if target=='wif' else d.addr
+		return ret
 
-	def monero_wallet_ops(self,infile,op,blockheight=None,addrs=None):
+class MMGenToolCmdRPC(MMGenToolCmdBase):
+	"tracking wallet commands using the JSON-RPC interface"
+
+	def getbalance(self,minconf=1,quiet=False,pager=False):
+		"list confirmed/unconfirmed, spendable/unspendable balances in tracking wallet"
+		from mmgen.tw import TwGetBalance
+		return TwGetBalance(minconf,quiet).format()
+
+	def listaddress(self,
+					mmgen_addr:str,
+					minconf = 1,
+					pager = False,
+					showempty = True,
+					showbtcaddr = True,
+					age_fmt:'(valid options: days,confs)' = ''):
+		"list the specified MMGen address and its balance"
+		return self.listaddresses(  mmgen_addrs = mmgen_addr,
+									minconf = minconf,
+									pager = pager,
+									showempty = showempty,
+									showbtcaddrs = showbtcaddr,
+									age_fmt = age_fmt)
+
+	def listaddresses(  self,
+						mmgen_addrs:'(range or list)' = '',
+						minconf = 1,
+						showempty = False,
+						pager = False,
+						showbtcaddrs = True,
+						all_labels = False,
+						sort:'(valid options: reverse,age)' = '',
+						age_fmt:'(valid options: days,confs)' = ''):
+		"list MMGen addresses and their balances"
+		show_age = bool(age_fmt)
+
+		if sort:
+			sort = set(sort.split(','))
+			sort_params = set(['reverse','age'])
+			if not sort.issubset(sort_params):
+				die(1,"The sort option takes the following parameters: '{}'".format("','".join(sort_params)))
+
+		usr_addr_list = []
+		if mmgen_addrs:
+			a = mmgen_addrs.rsplit(':',1)
+			if len(a) != 2:
+				m = "'{}': invalid address list argument (must be in form <seed ID>:[<type>:]<idx list>)"
+				die(1,m.format(mmgen_addrs))
+			usr_addr_list = [MMGenID('{}:{}'.format(a[0],i)) for i in AddrIdxList(a[1])]
+
+		from mmgen.tw import TwAddrList
+		al = TwAddrList(usr_addr_list,minconf,showempty,showbtcaddrs,all_labels)
+		if not al:
+			die(0,('No tracked addresses with balances!','No tracked addresses!')[showempty])
+		return al.format(showbtcaddrs,sort,show_age,age_fmt or 'days')
+
+	def twview( self,
+				pager = False,
+				reverse = False,
+				wide = False,
+				minconf = 1,
+				sort = 'age',
+				age_fmt:'(valid options: days,confs)' = 'days',
+				show_mmid = True):
+		"view tracking wallet"
+		rpc_init()
+		from mmgen.tw import TwUnspentOutputs
+		tw = TwUnspentOutputs(minconf=minconf)
+		tw.do_sort(sort,reverse=reverse)
+		tw.age_fmt = age_fmt
+		tw.show_mmid = show_mmid
+		return tw.format_for_printing(color=True) if wide else tw.format_for_display()
+
+	def add_label(self,mmgen_or_coin_addr:str,label:str):
+		"add descriptive label for address in tracking wallet"
+		rpc_init()
+		from mmgen.tw import TrackingWallet
+		TrackingWallet(mode='w').add_label(mmgen_or_coin_addr,label,on_fail='raise')
+		return True
+
+	def remove_label(self,mmgen_or_coin_addr:str):
+		"remove descriptive label for address in tracking wallet"
+		self.add_label(mmgen_or_coin_addr,'')
+		return True
+
+	def remove_address(self,mmgen_or_coin_addr:str):
+		"remove an address from tracking wallet"
+		from mmgen.tw import TrackingWallet
+		tw = TrackingWallet(mode='w')
+		ret = tw.remove_address(mmgen_or_coin_addr) # returns None on failure
+		if ret:
+			msg("Address '{}' deleted from tracking wallet".format(ret))
+		return ret
+
+class MMGenToolCmdMonero(MMGenToolCmdBase):
+	"Monero wallet utilities"
+
+	def keyaddrlist2monerowallets(  self,
+									xmr_keyaddrfile:str,
+									blockheight:'(default: current height)' = 0,
+									addrs:'(integer range or list)' = ''):
+		"create Monero wallets from key-address list"
+		return self.monero_wallet_ops(  infile = xmr_keyaddrfile,
+										op = 'create',
+										blockheight = blockheight,
+										addrs = addrs)
+
+	def syncmonerowallets(self,xmr_keyaddrfile:str,addrs:'(integer range or list)'=''):
+		"sync Monero wallets from key-address list"
+		return self.monero_wallet_ops(infile=xmr_keyaddrfile,op='sync',addrs=addrs)
+
+	def monero_wallet_ops(self,infile:str,op:str,blockheight=0,addrs=''):
 
 		def run_cmd(cmd):
 			import subprocess as sp
@@ -607,7 +796,7 @@ class MMGenToolCmd(object):
 				my_sendline(p,'','Y',2)
 				m = '  Warning: {}: blockheight argument is higher than current blockheight'
 				ymsg(m.format(blockheight))
-			elif blockheight != None:
+			elif blockheight:
 				p.logfile = sys.stderr
 			my_expect(p,'Syncing wallet','\[wallet.*$',regex=True)
 			p.logfile = None
@@ -653,7 +842,7 @@ class MMGenToolCmd(object):
 			init_coin('xmr')
 			from mmgen.addr import AddrList
 			al = KeyAddrList(infile)
-			data = [d for d in al.data if addrs == None or d.idx in AddrIdxList(addrs)]
+			data = [d for d in al.data if addrs == '' or d.idx in AddrIdxList(addrs)]
 			dl = len(data)
 			assert dl,"No addresses in addrfile within range '{}'".format(addrs)
 			gmsg('\n{}ing {} wallet{}'.format(m[op][0],dl,suf(dl)))
@@ -680,9 +869,10 @@ class MMGenToolCmd(object):
 
 		os.environ['LANG'] = 'C'
 		import pexpect
-		if blockheight != None and int(blockheight) < 0:
-			blockheight = 0 # TODO: non-zero coverage
-		cur_height = test_rpc()
+		if blockheight < 0:
+			blockheight = 0 # TODO: handle the non-zero case
+		cur_height = test_rpc() # empty blockchain returns 1
+		from collections import OrderedDict
 		bals = OrderedDict() # locked,unlocked
 
 		try:
@@ -699,92 +889,15 @@ class MMGenToolCmd(object):
 
 		return True
 
-	# ================ RPC commands ================== #
-
-	def gen_addr(self,addr,wallet='',target='addr'):
-		addr = MMGenID(addr)
-		sf = get_seed_file([wallet] if wallet else [],1)
-		opt.quiet = True
-		from mmgen.seed import SeedSource
-		ss = SeedSource(sf)
-		if ss.seed.sid != addr.sid:
-			m = 'Seed ID of requested address ({}) does not match wallet ({})'
-			die(1,m.format(addr.sid,ss.seed.sid))
-		al = AddrList(seed=ss.seed,addr_idxs=AddrIdxList(str(addr.idx)),mmtype=addr.mmtype)
-		d = al.data[0]
-		ret = d.sec.wif if target=='wif' else d.addr
-		return ret
-
-	def gen_key(self,addr,wallet=''):
-		return self.gen_addr(addr,wallet,target='wif')
-
-	def listaddress(self,addr,minconf=1,pager=False,showempty=True,showbtcaddr=True,show_age=False,show_days=None):
-		return self.listaddresses(addrs=addr,minconf=minconf,pager=pager,
-				showempty=showempty,showbtcaddrs=showbtcaddr,show_age=show_age,show_days=show_days)
-
-	def listaddresses(self,addrs='',minconf=1,
-		showempty=False,pager=False,showbtcaddrs=True,all_labels=False,sort=None,show_age=False,show_days=None):
-
-		if show_days == None: show_days = False # user-set show_days triggers show_age
-		else: show_age = True
-
-		if sort:
-			sort = set(sort.split(','))
-			sort_params = set(['reverse','age'])
-			if not sort.issubset(sort_params):
-				die(1,"The sort option takes the following parameters: '{}'".format("','".join(sort_params)))
-
-		usr_addr_list = []
-		if addrs:
-			a = addrs.rsplit(':',1)
-			if len(a) != 2:
-				m = "'{}': invalid address list argument (must be in form <seed ID>:[<type>:]<idx list>)"
-				die(1,m.format(addrs))
-			usr_addr_list = [MMGenID('{}:{}'.format(a[0],i)) for i in AddrIdxList(a[1])]
-
-		from mmgen.tw import TwAddrList
-		al = TwAddrList(usr_addr_list,minconf,showempty,showbtcaddrs,all_labels)
-		if not al:
-			die(0,('No tracked addresses with balances!','No tracked addresses!')[showempty])
-		return al.format(showbtcaddrs,sort,show_age,show_days)
-
-	def getbalance(self,minconf=1,quiet=False,pager=False):
-		from mmgen.tw import TwGetBalance
-		return TwGetBalance(minconf,quiet).format()
-
-	def txview(self,*infiles,**kwargs):
-		from mmgen.filename import MMGenFileList
-		terse = 'terse' in kwargs and kwargs['terse']
-		sort_key = kwargs['sort'] if 'sort' in kwargs else 'mtime'
-		flist = MMGenFileList(infiles,ftype=MMGenTX)
-		flist.sort_by_age(key=sort_key) # in-place sort
-		from mmgen.term import get_terminal_size
-		sep = '—'*77+'\n'
-		return sep.join([MMGenTX(fn).format_view(terse=terse) for fn in flist.names()]).rstrip()
-
-	def twview(self,pager=False,reverse=False,wide=False,minconf=1,sort='age',show_days=True,show_mmid=True):
-		rpc_init()
-		from mmgen.tw import TwUnspentOutputs
-		tw = TwUnspentOutputs(minconf=minconf)
-		tw.do_sort(sort,reverse=reverse)
-		tw.show_days = show_days
-		tw.show_mmid = show_mmid
-		return tw.format_for_printing(color=True) if wide else tw.format_for_display()
-
-	def add_label(self,mmaddr_or_coin_addr,label):
-		rpc_init()
-		from mmgen.tw import TrackingWallet
-		TrackingWallet(mode='w').add_label(mmaddr_or_coin_addr,label,on_fail='raise')
-		return True
-
-	def remove_label(self,mmaddr_or_coin_addr):
-		self.add_label(mmaddr_or_coin_addr,'')
-		return True
-
-	def remove_address(self,mmaddr_or_coin_addr):
-		from mmgen.tw import TrackingWallet
-		tw = TrackingWallet(mode='w')
-		ret = tw.remove_address(mmaddr_or_coin_addr) # returns None on failure
-		if ret:
-			msg("Address '{}' deleted from tracking wallet".format(ret))
-		return ret
+class MMGenToolCmd(
+		MMGenToolCmdMisc,
+		MMGenToolCmdUtil,
+		MMGenToolCmdCoin,
+		MMGenToolCmdMnemonic,
+		MMGenToolCmdFile,
+		MMGenToolCmdFileCrypt,
+		MMGenToolCmdFileUtil,
+		MMGenToolCmdWallet,
+		MMGenToolCmdRPC,
+		MMGenToolCmdMonero,
+	): pass

+ 21 - 7
mmgen/tw.py

@@ -81,15 +81,26 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 		self.cols         = None
 		self.reverse      = False
 		self.group        = False
-		self.show_days    = True
 		self.show_mmid    = True
 		self.minconf      = minconf
 		self.get_unspent_data()
+		self.age_fmt      = 'days'
 		self.sort_key     = 'age'
 		self.do_sort()
 		self.total        = self.get_total_coin()
 		self.disp_prec    = self.get_display_precision()
 
+	@property
+	def age_fmt(self):
+		return self._age_fmt
+
+	@age_fmt.setter
+	def age_fmt(self,val):
+		age_fmts = ('days','confs')
+		if val not in age_fmts:
+			raise BadAgeFormat("'{}': invalid age format (must be one of {!r})".format(val,age_fmts))
+		self._age_fmt = val
+
 	def get_display_precision(self):
 		return g.proto.coin_amt.max_prec
 
@@ -199,7 +210,7 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 							a='Address'.ljust(addr_w),
 							A='Amt({})'.format(g.dcoin).ljust(self.disp_prec+3),
 							A2=' Amt({})'.format(g.coin).ljust(self.disp_prec+4),
-							c=('Confs','Age(d)')[self.show_days]
+							c=('Confs','Age(d)')[self.age_fmt=='days']
 							).rstrip()]
 
 		for n,i in enumerate(unsp):
@@ -225,7 +236,7 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 									a=addr_out,
 									A=i.amt.fmt(color=True,prec=self.disp_prec),
 									A2=(i.amt2.fmt(color=True,prec=self.disp_prec) if i.amt2 is not None else ''),
-									c=i.days if self.show_days else i.confs
+									c=i.days if self.age_fmt == 'days' else i.confs
 									).rstrip())
 
 		self.fmt_display = '\n'.join(out) + '\n'
@@ -327,7 +338,7 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 			if action[:2] == 's_':
 				self.do_sort(action[2:])
 				if action == 's_twmmid': self.show_mmid = True
-			elif action == 'd_days': self.show_days = not self.show_days
+			elif action == 'd_days': self.age_fmt = ('days','confs')[self.age_fmt=='days']
 			elif action == 'd_mmid': self.show_mmid = not self.show_mmid
 			elif action == 'd_group':
 				if self.can_group:
@@ -453,7 +464,10 @@ class TwAddrList(MMGenDict):
 
 	def coinaddr_list(self): return [self[k]['addr'] for k in self]
 
-	def format(self,showbtcaddrs,sort,show_age,show_days):
+	def format(self,showbtcaddrs,sort,show_age,age_fmt):
+		age_fmts = ('days','confs')
+		if age_fmt not in age_fmts:
+			raise BadAgeFormat("'{}': invalid age format (must be one of {!r})".format(age_fmt,age_fmts))
 		out = ['Chain: '+green(g.chain.upper())] if g.chain != 'mainnet' else []
 		fs = '{{mid}}{} {{cmt}} {{amt}}{}'.format(('',' {addr}')[showbtcaddrs],('',' {age}')[show_age])
 		mmaddrs = [k for k in self.keys() if k.type == 'mmgen']
@@ -468,7 +482,7 @@ class TwAddrList(MMGenDict):
 				addr=(CoinAddr.fmtc('ADDRESS',width=addr_width) if showbtcaddrs else None),
 				cmt=TwComment.fmtc('COMMENT',width=max_cmt_len+1),
 				amt='BALANCE'.ljust(max_fp_len+4),
-				age=('CONFS','DAYS')[show_days],
+				age=('CONFS','DAYS')[age_fmt=='days'],
 				)]
 
 		def sort_algo(j):
@@ -500,7 +514,7 @@ class TwAddrList(MMGenDict):
 				addr=(e['addr'].fmt(color=True,width=addr_width) if showbtcaddrs else None),
 				cmt=e['lbl'].comment.fmt(width=max_cmt_len,color=True,nullrepl='-'),
 				amt=e['amt'].fmt('4.{}'.format(max(max_fp_len,3)),color=True),
-				age=mmid.confs // (1,confs_per_day)[show_days] if hasattr(mmid,'confs') and mmid.confs != None else '-'
+				age=mmid.confs // (1,confs_per_day)[age_fmt=='days'] if hasattr(mmid,'confs') and mmid.confs != None else '-'
 				))
 
 		return '\n'.join(out + ['\nTOTAL: {} {}'.format(self.total.hl(color=True),g.dcoin)])

+ 7 - 5
mmgen/tx.py

@@ -1069,11 +1069,13 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 		return out + '\n'
 
 	def format_view(self,terse=False):
-		try:
-			rpc_init()
-			blockcount = self.get_blockcount()
-		except:
-			blockcount = None
+		blockcount = None
+		if g.proto.base_coin != 'ETH':
+			try:
+				rpc_init()
+				blockcount = self.get_blockcount()
+			except:
+				pass
 
 		def get_max_mmwid(io):
 			if io == self.inputs:

+ 12 - 1
mmgen/util.py

@@ -389,6 +389,17 @@ def get_from_brain_opt_params():
 	l,p = opt.from_brain.split(',')
 	return(int(l),p)
 
+def pretty_format(s,width=80,pfx=''):
+	out = []
+	while(s):
+		if len(s) <= width:
+			out.append(s)
+			break
+		i = s[:width].rfind(' ')
+		out.append(s[:i])
+		s = s[i+1:]
+	return pfx + ('\n'+pfx).join(out)
+
 def pretty_hexdump(data,gw=2,cols=8,line_nums=False):
 	r = (0,1)[bool(len(data) % gw)]
 	return ''.join(
@@ -566,7 +577,7 @@ def write_data_to_file( outfile,data,desc='data',
 
 	def do_stdout():
 		qmsg('Output to STDOUT requested')
-		if sys.stdout.isatty():
+		if g.stdin_tty:
 			if no_tty:
 				die(2,'Printing {} to screen is not allowed'.format(desc))
 			if (ask_tty and not opt.quiet) or binary:

+ 29 - 11
scripts/test-release.sh

@@ -21,11 +21,11 @@ python='python3'
 rounds=100 rounds_low=20 rounds_spec=500 gen_rounds=10
 monero_addrs='3,99,2,22-24,101-104'
 
-dfl_tests='obj sha256 alts monero eth autosign btc btc_tn btc_rt bch bch_rt ltc ltc_tn ltc_rt tool gen'
+dfl_tests='obj sha256 alts monero eth autosign btc btc_tn btc_rt bch bch_rt ltc ltc_tn ltc_rt tool tool2 gen'
 add_tests='autosign_minimal autosign_live'
 
 PROGNAME=$(basename $0)
-while getopts hbCfilnPRtvV OPT
+while getopts hbCfilnOPRtvV OPT
 do
 	case "$OPT" in
 	h)  printf "  %-16s Test MMGen release\n" "${PROGNAME}:"
@@ -37,6 +37,7 @@ do
 		echo   "           '-i'  Install only; don't run tests"
 		echo   "           '-l'  List the test name symbols"
 		echo   "           '-n'  Don't install; test in place"
+		echo   "           '-O'  Use popen_spawn rather than popen for applicable tests"
 		echo   "           '-P'  Don't pause between tests"
 		echo   "           '-R'  Don't remove temporary files after program has exited"
 		echo   "           '-t'  Print the tests without running them"
@@ -58,6 +59,7 @@ do
 		echo   "     ltc_tn   - litecoin testnet"
 		echo   "     ltc_rt   - litecoin regtest"
 		echo   "     tool     - tooltest (all supported coins)"
+		echo   "     tool2    - tooltest2 (all supported coins)"
 		echo   "     gen      - gentest (all supported coins)"
 		echo   "  By default, all tests are run"
 		exit ;;
@@ -80,6 +82,7 @@ do
 		echo -e "Additional tests:\n  $add_tests"
 		exit ;;
 	n)  NO_INSTALL=1 ;;
+	O)  test_py+=" --popen-spawn" ;;
 	P)  NO_PAUSE=1 ;;
 	R)  NO_TMPFILE_REMOVAL=1 ;;
 	t)  TESTING=1 ;;
@@ -265,7 +268,6 @@ t_btc=(
 	"$test_py --segwit dfl_wallet main ref ref_files"
 	"$test_py --segwit-random dfl_wallet main"
 	"$test_py --bech32 dfl_wallet main ref ref_files"
-	"$tooltest_py rpc"
 	"$python scripts/compute-file-chksum.py $REFDIR/*testnet.rawtx >/dev/null 2>&1")
 f_btc='You may stop the bitcoin (mainnet) daemon if you wish'
 
@@ -275,8 +277,7 @@ t_btc_tn=(
 	"$test_py --testnet=1"
 	"$test_py --testnet=1 --segwit dfl_wallet main ref ref_files"
 	"$test_py --testnet=1 --segwit-random dfl_wallet main"
-	"$test_py --testnet=1 --bech32 dfl_wallet main ref ref_files"
-	"$tooltest_py --testnet=1 rpc")
+	"$test_py --testnet=1 --bech32 dfl_wallet main ref ref_files")
 f_btc_tn='You may stop the bitcoin testnet daemon if you wish'
 
 i_btc_rt='Bitcoin regtest'
@@ -313,9 +314,7 @@ t_ltc=(
 	"$test_py --coin=ltc dfl_wallet main ref ref_files"
 	"$test_py --coin=ltc --segwit dfl_wallet main ref ref_files"
 	"$test_py --coin=ltc --segwit-random dfl_wallet main"
-	"$test_py --coin=ltc --bech32 dfl_wallet main ref ref_files"
-	"$tooltest_py --coin=ltc rpc"
-)
+	"$test_py --coin=ltc --bech32 dfl_wallet main ref ref_files")
 f_ltc='You may stop the litecoin daemon if you wish'
 
 i_ltc_tn='Litecoin testnet'
@@ -324,8 +323,7 @@ t_ltc_tn=(
 	"$test_py --coin=ltc --testnet=1"
 	"$test_py --coin=ltc --testnet=1 --segwit dfl_wallet main ref ref_files"
 	"$test_py --coin=ltc --testnet=1 --segwit-random dfl_wallet main"
-	"$test_py --coin=ltc --testnet=1 --bech32 dfl_wallet main ref ref_files"
-	"$tooltest_py --coin=ltc --testnet=1 rpc")
+	"$test_py --coin=ltc --testnet=1 --bech32 dfl_wallet main ref ref_files")
 f_ltc_tn='You may stop the litecoin testnet daemon if you wish'
 
 i_ltc_rt='Litecoin regtest'
@@ -333,10 +331,30 @@ s_ltc_rt="The following tests will test MMGen's regtest (Bob and Alice) mode"
 t_ltc_rt=("$test_py --coin=ltc regtest")
 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")
+f_tool2='tooltest2 tests completed'
+
 i_tool='Tooltest'
 s_tool="The following tests will run '$tooltest_py' for all supported coins"
 t_tool=(
-	"$tooltest2_py"
 	"$tooltest_py --coin=btc util"
 	"$tooltest_py --coin=btc cryptocoin"
 	"$tooltest_py --coin=btc mnemonic"

+ 50 - 36
test/test.py

@@ -148,7 +148,8 @@ opts_data = lambda: {
 -L, --list-cmd-groups Output a list of command groups, with no descriptions
 -n, --names          Display command names instead of descriptions
 -o, --log            Log commands to file {lf}
--O, --popen-spawn    Use pexpect's popen_spawn instead of popen
+-O, --popen-spawn    Use pexpect's popen_spawn instead of popen (much faster,
+                     but doesn't emulate terminal)
 -p, --pause          Pause between tests, resuming on keypress
 -P, --profile        Record the execution time of each script
 -q, --quiet          Produce minimal output.  Suppress dependency info
@@ -173,9 +174,8 @@ If no command is given, the whole suite of tests is run.
 sys.argv = [sys.argv[0]] + ['--data-dir',data_dir] + sys.argv[1:]
 
 cmd_args = opts.init(opts_data)
-# Under python3, with PopenSpawn we can no longer imitate cbreak mode with sys.stdin.read(1)
-# opt.popen_spawn = True # popen has issues, so use popen_spawn always
 
+if opt.popen_spawn: os.environ['MMGEN_TEST_SUITE_POPEN_SPAWN'] = '1'
 if not opt.system: os.environ['PYTHONPATH'] = repo_root
 
 lbl_id = ('account','label')[g.coin=='BTC'] # update as other coins adopt Core's label API
@@ -198,11 +198,14 @@ rtFee = {
 }[coin_sel]
 rtBals = {
 	'btc': ('499.9999488','399.9998282','399.9998147','399.9996877',
-			'52.99990000','946.99933647','999.99923647','52.9999','946.99933647'),
+			'52.99990000','946.99933647','999.99923647','52.9999',
+			'946.99933647'),
 	'bch': ('499.9999484','399.9999194','399.9998972','399.9997692',
-			'46.78900000','953.20966920','999.99866920','46.789','953.2096692'),
+			'46.78900000','953.20966920','999.99866920','46.789',
+			'953.2096692'),
 	'ltc': ('5499.99744','5399.994425','5399.993885','5399.987535',
-			'52.99000000','10946.93753500','10999.92753500','52.99','10946.937535'),
+			'52.99000000','10946.93753500','10999.92753500','52.99',
+			'10946.937535'),
 }[coin_sel]
 rtBals_gb = {
 	'btc': ('116.77629233','283.22339537'),
@@ -210,6 +213,11 @@ rtBals_gb = {
 	'ltc': ('5116.77036263','283.21717237')
 }[coin_sel]
 rtBobOp3 = {'btc':'S:2','bch':'L:3','ltc':'S:2'}[coin_sel]
+rtAmts = {
+	'btc': ('500',),
+	'bch': ('500',),
+	'ltc': ('5500',)
+}[coin_sel]
 
 if opt.segwit and 'S' not in g.proto.mmtypes:
 	die(1,'--segwit option incompatible with {}'.format(g.proto.__name__))
@@ -841,14 +849,14 @@ cmd_group['ref'] = (
 
 # reference files
 cmd_group['ref_files'] = (
-	('ref_addrfile_chk',   'saved reference address file'),
-	('ref_segwitaddrfile_chk','saved reference address file (segwit)'),
-	('ref_bech32addrfile_chk','saved reference address file (bech32)'),
+	('ref_addrfile_chk',   'saved reference address file'), # TODO: move to tooltest2
+	('ref_segwitaddrfile_chk','saved reference address file (segwit)'), # TODO: move to tooltest2
+	('ref_bech32addrfile_chk','saved reference address file (bech32)'), # TODO: move to tooltest2
 	('ref_keyaddrfile_chk','saved reference key-address file'),
 	('ref_passwdfile_chk', 'saved reference password file'),
 #	Create the fake inputs:
 #	('txcreate8',          'transaction creation (8)'),
-	('ref_tx_chk',         'saved reference tx file'),
+	('ref_tx_chk',         'saved reference tx file'), # TODO: move to tooltest2
 	('ref_brain_chk_spc3', 'saved brainwallet (non-standard spacing)'),
 	('ref_tool_decrypt',   'decryption of saved MMGen-encrypted file'),
 )
@@ -889,15 +897,16 @@ cmd_group['regtest'] = (
 	('regtest_fund_alice',         "funding Alice's wallet"),
 	('regtest_bob_bal1',           "Bob's balance"),
 	('regtest_bob_add_label',      "adding a 40-character UTF-8 encoded label"),
+	('regtest_bob_twview',         "viewing Bob's tracking wallet"),
 	('regtest_bob_split1',         "splitting Bob's funds"),
 	('regtest_generate',           'mining a block'),
 	('regtest_bob_bal2',           "Bob's balance"),
-	('regtest_bob_bal2a',          "Bob's balance (show_days=1)"),
-	('regtest_bob_bal2b',          "Bob's balance (show_age=1)"),
-	('regtest_bob_bal2c',          "Bob's balance (showempty=1 show_age=1 minconf=2)"),
-	('regtest_bob_bal2d',          "Bob's balance (show_age=1 minconf=2)"),
-	('regtest_bob_bal2e',          "Bob's balance (showempty=1 show_age=1 sort=age)"),
-	('regtest_bob_bal2f',          "Bob's balance (showempty=1 show_age=1 sort=age,reverse)"),
+	('regtest_bob_bal2a',          "Bob's balance (age_fmt=confs)"),
+	('regtest_bob_bal2b',          "Bob's balance (showempty=1)"),
+	('regtest_bob_bal2c',          "Bob's balance (showempty=1 minconf=2 age_fmt=days)"),
+	('regtest_bob_bal2d',          "Bob's balance (minconf=2)"),
+	('regtest_bob_bal2e',          "Bob's balance (showempty=1 sort=age)"),
+	('regtest_bob_bal2f',          "Bob's balance (showempty=1 sort=age,reverse)"),
 	('regtest_bob_rbf_send',       'sending funds to Alice (RBF)'),
 	('regtest_get_mempool1',       'mempool (before RBF bump)'),
 	('regtest_bob_rbf_bump',       'bumping RBF transaction'),
@@ -1079,7 +1088,7 @@ cmd_group['ethdev'] = (
 	('ethdev_twview3','twview wide=1 sort=age (ignored)'),
 	('ethdev_twview4','twview wide=1 minconf=999999999 (ignored)'),
 	('ethdev_twview5','twview wide=1 minconf=0 (ignored)'),
-	('ethdev_twview6','twview show_days=0 (ignored)'),
+	('ethdev_twview6','twview age_fmt=days (ignored)'),
 
 	('ethdev_token_twview1','twview --token=mm1'),
 	('ethdev_token_twview2','twview --token=mm1 wide=1'),
@@ -2861,8 +2870,7 @@ class MMGenTestSuite(object):
 
 	def ref_addrfile_chk_zec_z(self,name):
 		if skip_for_win(): return
-		self.ref_addrfile_chk(name,ftype='addr',coin='ZEC',subdir='zcash',pfx='-ZEC-Z',
-								mmtype='z',add_args=['mmtype=zcash_z'])
+		self.ref_addrfile_chk(name,ftype='addr',coin='ZEC',subdir='zcash',pfx='-ZEC-Z',mmtype='z')
 
 	def ref_addrfile_chk_xmr(self,name):
 		self.ref_addrfile_chk(name,ftype='addr',coin='XMR',subdir='monero',pfx='-XMR-M')
@@ -2882,8 +2890,7 @@ class MMGenTestSuite(object):
 
 	def ref_keyaddrfile_chk_zec_z(self,name):
 		if skip_for_win(): return
-		self.ref_addrfile_chk(name,ftype='keyaddr',coin='ZEC',subdir='zcash',pfx='-ZEC-Z',
-								mmtype='z',add_args=['mmtype=zcash_z'])
+		self.ref_addrfile_chk(name,ftype='keyaddr',coin='ZEC',subdir='zcash',pfx='-ZEC-Z',mmtype='z')
 
 	def ref_keyaddrfile_chk_xmr(self,name):
 		self.ref_addrfile_chk(name,ftype='keyaddr',coin='XMR',subdir='monero',pfx='-XMR-M')
@@ -2922,16 +2929,14 @@ class MMGenTestSuite(object):
 	def ref_tool_decrypt(self,name):
 		f = os.path.join(ref_dir,ref_enc_fn)
 		disable_debug()
-		t = MMGenExpect(name,'mmgen-tool', ['-q','decrypt',f,'outfile=-','hash_preset=1'])
+		dec_fn = get_tmpfile_fn(cfg,'famous.txt')
+		t = MMGenExpect(name,'mmgen-tool', ['-q','decrypt',f,'outfile='+dec_fn,'hash_preset=1'])
 		restore_debug()
 		t.passphrase('user data',tool_enc_passwd)
-		t.expect(NL,nonl=True)
-		t.expect('to confirm: ','YES\n')
-		import re
-		o = t.read()
-		o = re.sub('YES\r\n','',o).split('\n')[0]
-		o = re.sub('\r','\n',o)
-		cmp_or_die(sample_text,o)
+		t.written_to_file('Decrypted data')
+		dec_txt = read_from_file(dec_fn)
+		imsg_r(dec_txt)
+		cmp_or_die(sample_text,dec_txt)
 
 	# wallet conversion tests
 	def walletconv_in(self,name,infile,desc,uopts=[],pw=False,oo=False):
@@ -3075,6 +3080,15 @@ class MMGenTestSuite(object):
 	def regtest_fund_bob(self,name):   self.regtest_fund_wallet(name,'bob','C',rtFundAmt)
 	def regtest_fund_alice(self,name): self.regtest_fund_wallet(name,'alice',('L','S')[g.proto.cap('segwit')],rtFundAmt)
 
+	def regtest_user_twview(self,name,user):
+		t = MMGenExpect(name,'mmgen-tool',['--'+user,'twview'])
+		t.expect(r'1\).*\b{}\b'.format(rtAmts[0]),regex=True)
+		t.read()
+		t.ok()
+
+	def regtest_bob_twview(self,name):
+		return self.regtest_user_twview(name,'bob')
+
 	def regtest_user_bal(self,name,user,bal,args=['showempty=1'],skip_check=False,exit_val=0):
 		t = MMGenExpect(name,'mmgen-tool',['--'+user,'listaddresses'] + args)
 		if skip_check:
@@ -3097,22 +3111,22 @@ class MMGenTestSuite(object):
 		return self.regtest_user_bal(name,'bob',rtBals[0])
 
 	def regtest_bob_bal2a(self,name):
-		return self.regtest_user_bal(name,'bob',rtBals[0],args=['showempty=1','show_days=1'])
+		return self.regtest_user_bal(name,'bob',rtBals[0],args=['showempty=1','age_fmt="confs"'])
 
 	def regtest_bob_bal2b(self,name):
-		return self.regtest_user_bal(name,'bob',rtBals[0],args=['showempty=1','show_age=1'])
+		return self.regtest_user_bal(name,'bob',rtBals[0],args=['showempty=1'])
 
 	def regtest_bob_bal2c(self,name):
-		return self.regtest_user_bal(name,'bob',rtBals[0],args=['showempty=1','show_age=1','minconf=2'],skip_check=True)
+		return self.regtest_user_bal(name,'bob',rtBals[0],args=['showempty=1','minconf=2','age_fmt="days"'],skip_check=True)
 
 	def regtest_bob_bal2d(self,name):
-		return self.regtest_user_bal(name,'bob',rtBals[0],args=['show_age=1','minconf=2'],skip_check=True)
+		return self.regtest_user_bal(name,'bob',rtBals[0],args=['minconf=2'],skip_check=True)
 
 	def regtest_bob_bal2e(self,name):
-		return self.regtest_user_bal(name,'bob',rtBals[0],args=['showempty=1','show_age=1','sort=age'])
+		return self.regtest_user_bal(name,'bob',rtBals[0],args=['showempty=1','sort=age'])
 
 	def regtest_bob_bal2f(self,name):
-		return self.regtest_user_bal(name,'bob',rtBals[0],args=['showempty=1','show_age=1','sort=age,reverse'])
+		return self.regtest_user_bal(name,'bob',rtBals[0],args=['showempty=1','sort=age,reverse'])
 
 	def regtest_bob_bal3(self,name):
 		return self.regtest_user_bal(name,'bob',rtBals[1])
@@ -3952,7 +3966,7 @@ class MMGenTestSuite(object):
 	def ethdev_twview5(self,name):
 		return self.ethdev_twview(name,tool_args=['wide=1','minconf=0'])
 	def ethdev_twview6(self,name):
-		return self.ethdev_twview(name,tool_args=['show_days=0'])
+		return self.ethdev_twview(name,tool_args=['age_fmt=days'])
 
 	def ethdev_token_twview1(self,name):
 		return self.ethdev_twview(name,args=['--token=mm1'])

+ 31 - 48
test/tooltest.py

@@ -37,6 +37,7 @@ opts_data = lambda: {
 	'options': """
 -h, --help          Print this help message
 -C, --coverage      Produce code coverage info using trace module
+-d, --debug         Produce debugging output (stderr from spawned script)
 --, --longhelp      Print help message for long options (common options)
 -l, --list-cmds     List and describe the tests and commands in this test suite
 -L, --list-names    List the names of all tested 'mmgen-tool' commands
@@ -119,18 +120,6 @@ cmd_data = OrderedDict([
 			])
 		}
 	),
-	('rpc', {
-			'desc': 'Coin daemon RPC commands',
-			'cmd_data': OrderedDict([
-#				('keyaddrfile_chksum', ()), # interactive
-				('addrfile_chksum', ()),
-				('getbalance',      ()),
-				('listaddresses',   ()),
-				('twview',          ()),
-				('txview',          ()),
-			])
-		}
-	),
 ])
 
 cfg = {
@@ -186,30 +175,35 @@ if opt.list_cmds:
 	Msg(fs.format('clean','Clean the tmp directory',w=w))
 	sys.exit(0)
 if opt.list_names:
-	tested_here = []
+	tested_in = {
+		'tooltest.py': [],
+		'test.py': (
+			'encrypt','decrypt','find_incog_data',
+			'addrfile_chksum','keyaddrfile_chksum','passwdfile_chksum',
+			'add_label','remove_label','remove_address','twview',
+			'getbalance','listaddresses','listaddress'),
+		'test-release.sh': ('keyaddrlist2monerowallets','syncmonerowallets'),
+		'tooltest2.py': subprocess.check_output(['test/tooltest2.py','--list-tested-cmds']).decode().split()
+	}
 	for v in cmd_data.values():
-		tested_here += list(v['cmd_data'].keys())
-	msg('{}\n{}'.format(green("Tested 'mmgen-tool' commands:"),'\n'.join(sorted(c.lower() for c in tested_here))))
-	import mmgen.tool
-	tested_in_test_py = (
-		'encrypt','decrypt','find_incog_data',
-		'addrfile_chksum','keyaddrfile_chksum','passwdfile_chksum',
-		'add_label','remove_label','remove_address',
-		'getbalance','listaddresses','listaddress','twview')
-	tested_in_test_release = ('keyaddrlist2monerowallets','syncmonerowallets')
-	tested_in_tooltest2 = subprocess.check_output(['test/tooltest2.py','--list-tested-cmds']).decode().split()
-
-	ignore = ('help','usage')
-	uc = sorted(c.lower() for c in (
-		set(mmgen.tool.cmd_data.keys()) -
+		tested_in['tooltest.py'] += list(v['cmd_data'].keys())
+
+	msg(green("TESTED 'MMGEN-TOOL' COMMANDS"))
+	for l in ('tooltest.py','tooltest2.py','test.py','test-release.sh'):
+		msg(blue(l+':'))
+		msg('  '+'\n  '.join(sorted(tested_in[l])))
+
+	ignore = ()
+	from mmgen.tool import MMGenToolCmd
+	uc = sorted(
+		set(MMGenToolCmd.user_commands()) -
 		set(ignore) -
-		set(tested_here) -
-		set(tested_in_tooltest2) -
-		set(tested_in_test_py) -
-		set(tested_in_test_release)
-	))
-	msg('\n{}\n{}'.format(yellow('Untested commands:'),'\n'.join(uc)))
-	die()
+		set(tested_in['tooltest.py']) -
+		set(tested_in['tooltest2.py']) -
+		set(tested_in['test.py']) -
+		set(tested_in['test-release.sh'])
+	)
+	die(0,'\n{}\n  {}'.format(yellow('Untested commands:'),'\n  '.join(uc)))
 
 from mmgen.tx import is_wif,is_coin_addr
 
@@ -248,6 +242,9 @@ class MMGenToolTestUtils(object):
 
 		p = subprocess.Popen(sys_cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
 		a,b = p.communicate()
+		if opt.debug:
+			try: dmsg(b.decode())
+			except: dmsg(repr(b))
 		if not binary: a = a.decode()
 		retcode = p.wait()
 		if retcode != 0:
@@ -460,20 +457,6 @@ class MMGenToolTestCmds(object):
 		tu.run_cmd(name,[])
 		ok()
 
-	# RPC
-	def addrfile_chksum(self,name):
-		fn = os.path.join(cfg['refdir'],ref_subdir,cfg['addrfile'].format(altcoin_pfx,tn_ext))
-		tu.run_cmd_out(name,fn,literal=True,chkdata=cfg['addrfile_chk'][g.coin.lower()][g.testnet])
-	def getbalance(self,name):
-		tu.run_cmd_out(name,literal=True)
-	def listaddresses(self,name):
-		tu.run_cmd_out(name,literal=True)
-	def twview(self,name):
-		tu.run_cmd_out(name,literal=True)
-	def txview(self,name):
-		fn = os.path.join(cfg['refdir'],ref_subdir,cfg['txfile'][g.coin.lower()][bool(tn_ext)])
-		tu.run_cmd_out(name,fn,literal=True)
-
 # main()
 import time
 start_time = int(time.time())

+ 209 - 74
test/tooltest2.py

@@ -20,6 +20,9 @@
 test/tooltest2.py:  Simple tests for the 'mmgen-tool' utility
 """
 
+# TODO: move all non-interactive 'mmgen-tool' tests in 'test.py' here
+# TODO: move all(?) tests in 'tooltest.py' here (or duplicate them?)
+
 import sys,os,time
 from subprocess import Popen,PIPE
 from decimal import Decimal
@@ -32,22 +35,27 @@ os.environ['MMGEN_TEST_SUITE'] = '1'
 # Import these _after_ prepending repo_root to sys.path
 from mmgen.common import *
 from mmgen.test import *
+from mmgen.obj import is_wif,is_coin_addr
 
 opts_data = lambda: {
 	'desc': "Simple test suite for the 'mmgen-tool' utility",
 	'usage':'[options] [command]',
 	'options': """
--h, --help          Print this help message
--C, --coverage      Produce code coverage info using trace module
---, --longhelp      Print help message for long options (common options)
--l, --list-tests    List the tests in this test suite
+-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
+--, --longhelp       Print help message for long options (common options)
+-l, --list-tests     List the tests 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
--s, --system        Test scripts and modules installed on system rather than
-                    those in the repo root
--f, --fork          Run commands via tool executable instead of importing tool module
--t, --traceback     Run tool inside traceback script
--v, --verbose       Produce more verbose output
+-n, --names          Print command names instead of descriptions
+-q, --quiet          Produce quieter output
+-s, --system         Test scripts and modules installed on system rather than
+                     those in the repo root
+-t, --type=          Specify coin type
+-f, --fork           Run commands via tool executable instead of importing tool module
+-t, --traceback      Run tool inside traceback script
+-v, --verbose        Produce more verbose output
 """,
 	'notes': """
 
@@ -58,101 +66,223 @@ 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) ),
-				]),
+			('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'
-					),]),
+			('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
-	m = 'Testing {}'.format(cmd_name if opt.names else desc)
+	if type(data) == dict:
+		if opt.non_coin_dependent: return
+		k = '{}_{}net'.format((g.token.lower() if g.token else g.coin.lower()),('main','test')[g.testnet])
+		if k in data:
+			data = data[k]
+			m2 = ' ({})'.format(k)
+		else:
+			msg("-- no data for {} ({}) - skipping".format(cmd_name,k))
+			return
+	else:
+		if opt.coin_dependent: return
+		m2 = ''
+	m = '{} {}{}'.format(cyan('Testing'),cmd_name if opt.names else desc,m2)
 	msg_r(green(m)+'\n' if opt.verbose else m)
-	for args,out in data:
+	for d in data:
+		args,out,opts,exec_code = d + tuple([None] * (4-len(d)))
 		if opt.fork:
-			cmd = list(tool_cmd) + [cmd_name] + args
+			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')
 		else:
 			vmsg('{}: {}'.format(purple('Running'),' '.join([cmd_name]+args)))
+			if exec_code: exec(exec_code)
 			aargs,kwargs = tool._process_args(cmd_name,args)
-			cmd_out = tool._get_result(getattr(tc,cmd_name)(*aargs,**kwargs))
-		if type(out) == str:
+			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
+
+		if type(out) != bytes:
 			cmd_out = cmd_out.strip()
-			if opt.fork:
-				cmd_out = cmd_out.decode()
 			vmsg('Output: {}\n'.format(cmd_out))
 		else:
 			vmsg('Output: {}\n'.format(repr(cmd_out)))
-		assert cmd_out == out,"Output ({}) doesn't match expected output ({})".format(cmd_out,out)
+
+		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 not opt.verbose: msg_r('.')
 	if not opt.verbose:
 		msg('OK')
 
 def do_group(garg):
 	gid,gdesc,gdata = garg
-	msg(blue("Testing {}".format("command group '{}'".format(gid) if opt.names else gdesc)))
+	qmsg(blue("Testing {}".format("command group '{}'".format(gid) if opt.names else gdesc)))
 	for cdata in gdata:
 		do_cmd(cdata)
 
@@ -193,6 +323,11 @@ else:
 if opt.fork:
 	tool_cmd = (tool_exec,'--skip-cfg-file')
 
+	passthru_args = ['coin','type','testnet','token']
+	tool_cmd += tuple(['--{}{}'.format(k.replace('_','-'),
+		'='+getattr(opt,k) if getattr(opt,k) != True else ''
+		) for k in passthru_args if getattr(opt,k)])
+
 	if opt.traceback:
 		tool_cmd = (os.path.join('scripts','traceback_run.py'),) + tool_cmd
 
@@ -202,7 +337,7 @@ if opt.fork:
 	elif g.platform == 'win':
 		tool_cmd = ('python3') + tool_cmd
 else:
-	opt.quiet = True
+	opt.usr_randchars = 0
 	import mmgen.tool as tool
 	tc = tool.MMGenToolCmd()