12 Commits a323a2f02a ... 8edc7da5a2

Author SHA1 Message Date
  The MMGen Project 8edc7da5a2 BCH cashaddr: full support 2 months ago
  The MMGen Project 15b94038a4 self.proto.pubhash2addr(): return `CoinAddr` instance 2 months ago
  The MMGen Project eeb98869d3 CoinAddr: add `views`, `view_pref` attrs 2 months ago
  The MMGen Project 15e01df145 cleanups, whitespace 2 months ago
  The MMGen Project cb113ffc82 test suite: cleanups 2 months ago
  The MMGen Project 9a70a04b01 scrambletest.py: cleanups 2 months ago
  The MMGen Project 044c2a0b91 whitespace throughout 2 months ago
  The MMGen Project 3c726f9091 BCH cashaddr: low-level support 2 months ago
  The MMGen Project 3f1c0b2a93 unit_tests.py: cleanups, add `desc` param to subtest methods 2 months ago
  The MMGen Project 5c433bd3f5 tx.info: cleanups 2 months ago
  The MMGen Project 037bbda14a whitespace, minor cleanups 2 months ago
  The MMGen Project 9a859bac70 objtest.py: add `CoinAddr` test for ETH 2 months ago
72 changed files with 1230 additions and 598 deletions
  1. 20 10
      mmgen/addr.py
  2. 28 24
      mmgen/addrfile.py
  3. 9 8
      mmgen/addrlist.py
  4. 1 1
      mmgen/autosign.py
  5. 1 1
      mmgen/bip_hd/__init__.py
  6. 6 2
      mmgen/cfg.py
  7. 3 0
      mmgen/data/mmgen.cfg
  8. 1 1
      mmgen/data/version
  9. 1 1
      mmgen/main_addrimport.py
  10. 8 7
      mmgen/opts.py
  11. 99 0
      mmgen/proto/bch/cashaddr.py
  12. 51 1
      mmgen/proto/bch/params.py
  13. 6 13
      mmgen/proto/btc/addrgen.py
  14. 0 1
      mmgen/proto/btc/daemon.py
  15. 21 18
      mmgen/proto/btc/params.py
  16. 9 7
      mmgen/proto/btc/tw/addresses.py
  17. 1 1
      mmgen/proto/btc/tw/ctl.py
  18. 9 7
      mmgen/proto/btc/tw/prune.py
  19. 13 10
      mmgen/proto/btc/tw/txhistory.py
  20. 8 6
      mmgen/proto/btc/tw/unspent.py
  21. 13 14
      mmgen/proto/btc/tx/base.py
  22. 42 24
      mmgen/proto/btc/tx/info.py
  23. 3 6
      mmgen/proto/eth/addrgen.py
  24. 7 5
      mmgen/proto/eth/params.py
  25. 5 6
      mmgen/proto/eth/tw/addresses.py
  26. 5 6
      mmgen/proto/eth/tw/unspent.py
  27. 22 18
      mmgen/proto/eth/tx/info.py
  28. 6 6
      mmgen/proto/eth/tx/new.py
  29. 1 1
      mmgen/proto/eth/tx/online.py
  30. 4 4
      mmgen/proto/eth/tx/signed.py
  31. 9 9
      mmgen/proto/eth/tx/unsigned.py
  32. 4 4
      mmgen/proto/zec/params.py
  33. 4 3
      mmgen/protocol.py
  34. 2 3
      mmgen/term.py
  35. 5 7
      mmgen/tool/coin.py
  36. 2 2
      mmgen/tw/addresses.py
  37. 4 3
      mmgen/tw/ctl.py
  38. 0 1
      mmgen/tw/shared.py
  39. 8 6
      mmgen/tw/txhistory.py
  40. 2 2
      mmgen/tw/unspent.py
  41. 15 2
      mmgen/tw/view.py
  42. 1 1
      mmgen/tx/base.py
  43. 12 8
      mmgen/tx/info.py
  44. 1 1
      mmgen/tx/new.py
  45. 6 6
      mmgen/xmrwallet.py
  46. 1 1
      setup.cfg
  47. 2 3
      test/cmdtest.py
  48. 6 3
      test/cmdtest_py_d/ct_base.py
  49. 8 8
      test/cmdtest_py_d/ct_main.py
  50. 32 7
      test/cmdtest_py_d/ct_misc.py
  51. 1 1
      test/cmdtest_py_d/ct_ref.py
  52. 46 27
      test/cmdtest_py_d/ct_ref_3seed.py
  53. 100 36
      test/cmdtest_py_d/ct_regtest.py
  54. 32 22
      test/cmdtest_py_d/ct_shared.py
  55. 3 2
      test/gentest.py
  56. 17 0
      test/objtest_py_d/ot_eth_mainnet.py
  57. 5 0
      test/ref/bitcoin_cash/895108-BCH[2.65913].rawtx
  58. 57 0
      test/ref/bitcoin_cash/bchwallet-testnet.dump
  59. 57 0
      test/ref/bitcoin_cash/bchwallet.dump
  60. 57 49
      test/scrambletest.py
  61. 11 2
      test/test-release.d/cfg.sh
  62. 2 2
      test/tooltest2.py
  63. 1 1
      test/tooltest2_d/data.py
  64. 13 2
      test/unit_tests.py
  65. 2 2
      test/unit_tests_d/ut_bip_hd.py
  66. 151 0
      test/unit_tests_d/ut_cashaddr.py
  67. 70 64
      test/unit_tests_d/ut_lockable.py
  68. 3 3
      test/unit_tests_d/ut_misc.py
  69. 7 16
      test/unit_tests_d/ut_mn_entry.py
  70. 8 10
      test/unit_tests_d/ut_msg.py
  71. 6 8
      test/unit_tests_d/ut_obj.py
  72. 54 62
      test/unit_tests_d/ut_xmrseed.py

+ 20 - 10
mmgen/addr.py

@@ -25,6 +25,7 @@ from collections import namedtuple
 from .objmethods import HiliteStr,InitErrors,MMGenObject
 from .obj import ImmutableAttr,MMGenIdx,get_obj
 from .seed import SeedID
+from . import color as color_mod
 
 ati = namedtuple('addrtype_info',
 	['name','pubkey_type','compressed','gen_method','addr_fmt','wif_label','extra_attrs','desc'])
@@ -49,7 +50,7 @@ class MMGenAddrType(HiliteStr,InitErrors,MMGenObject):
 		'C': ati('compressed','std', True, 'p2pkh',   'p2pkh',   'wif', (), 'Compressed P2PKH address'),
 		'S': ati('segwit',    'std', True, 'segwit',  'p2sh',    'wif', (), 'Segwit P2SH-P2WPKH address'),
 		'B': ati('bech32',    'std', True, 'bech32',  'bech32',  'wif', (), 'Native Segwit (Bech32) address'),
-		'E': ati('ethereum',  'std', False,'ethereum','ethereum','privkey', ('wallet_passwd',),'Ethereum address'),
+		'E': ati('ethereum',  'std', False,'ethereum','p2pkh',   'privkey', ('wallet_passwd',),'Ethereum address'),
 		'Z': ati('zcash_z','zcash_z',False,'zcash_z', 'zcash_z', 'wif',     ('viewkey',),      'Zcash z-address'),
 		'M': ati('monero', 'monero', False,'monero',  'monero',  'spendkey',('viewkey','wallet_passwd'),'Monero address'),
 	}
@@ -144,19 +145,26 @@ class MMGenID(HiliteStr,InitErrors,MMGenObject):
 def is_mmgen_id(proto,s):
 	return get_obj( MMGenID, proto=proto, id_str=s, silent=True, return_bool=True )
 
-class CoinAddr(HiliteStr,InitErrors,MMGenObject):
+class CoinAddr(HiliteStr, InitErrors, MMGenObject):
 	color = 'cyan'
 	hex_width = 40
 	width = 1
 	trunc_ok = False
-	def __new__(cls,proto,addr):
+
+	def __new__(cls, proto, addr):
 		if isinstance(addr,cls):
 			return addr
 		try:
-			assert addr.isascii() and addr.isalnum(), 'not an ASCII alphanumeric string'
-			me = str.__new__(cls,addr)
 			ap = proto.decode_addr(addr)
 			assert ap, f'coin address {addr!r} could not be parsed'
+			if hasattr(ap, 'addr'):
+				me = str.__new__(cls, ap.addr)
+				me.views = ap.views
+				me.view_pref = ap.view_pref
+			else:
+				me = str.__new__(cls, addr)
+				me.views = [addr]
+				me.view_pref = 0
 			me.addr_fmt = ap.fmt
 			me.bytes = ap.bytes
 			me.ver_bytes = ap.ver_bytes
@@ -171,15 +179,17 @@ class CoinAddr(HiliteStr,InitErrors,MMGenObject):
 			self._parsed = self.proto.parse_addr(self.ver_bytes,self.bytes,self.addr_fmt)
 		return self._parsed
 
+	# reimplement some HiliteStr methods:
 	@classmethod
 	def fmtc(cls,s,width,color=False):
 		return super().fmtc( s=s[:width-2]+'..' if len(s) > width else s, width=width, color=color )
 
-	def fmt(self,width,color=False):
-		return (
-			super().fmtc( s=self[:width-2]+'..', width=width, color=color ) if len(self) > width else
-			super().fmt( width=width, color=color )
-		)
+	def fmt(self, view_pref, width, color=False):
+		s = self.views[view_pref]
+		return super().fmtc(f'{s[:width-2]}..' if len(s) > width else s, width=width, color=color)
+
+	def hl(self, view_pref, color=True):
+		return getattr(color_mod, self.color)(self.views[view_pref]) if color else self.views[view_pref]
 
 def is_coin_addr(proto,s):
 	return get_obj( CoinAddr, proto=proto, addr=s, silent=True, return_bool=True )

+ 28 - 24
mmgen/addrfile.py

@@ -82,13 +82,15 @@ class AddrFile(MMGenObject):
 			outdir        = outdir)
 
 	def make_label(self):
-		p = self.parent
-		bc,mt = p.proto.base_coin,p.al_id.mmtype
-		l_coin = [] if bc == 'BTC' else [p.proto.coin] if bc == 'ETH' else [bc]
-		l_type = [] if mt == 'E' or (mt == 'L' and not p.proto.testnet) else [mt.name.upper()]
-		l_tn   = [] if not p.proto.testnet else [p.proto.network.upper()]
-		lbl_p2 = ':'.join(l_coin+l_type+l_tn)
-		return p.al_id.sid + ('',' ')[bool(lbl_p2)] + lbl_p2
+		proto = self.parent.proto
+		coin = proto.coin
+		mmtype = self.parent.al_id.mmtype
+		lbl_p2 = ':'.join(
+			([] if coin == 'BTC' or (coin == 'BCH' and not self.cfg.cashaddr) else [coin])
+			+ ([] if mmtype == 'E' or (mmtype == 'L' and not proto.testnet) else [mmtype.name.upper()])
+			+ ([proto.network.upper()] if proto.testnet else [])
+		)
+		return self.parent.al_id.sid + (' ' if lbl_p2 else '') + lbl_p2
 
 	def format(self,add_comments=False):
 		p = self.parent
@@ -122,7 +124,7 @@ class AddrFile(MMGenObject):
 			elif type(p).__name__ == 'PasswordList':
 				out.append(fs.format(e.idx,e.passwd,c))
 			else: # First line with idx
-				out.append(fs.format(e.idx,e.addr,c))
+				out.append(fs.format(e.idx, e.addr.views[e.addr.view_pref], c))
 				if p.has_keys:
 					if self.cfg.b16:
 						out.append(fs.format( '', f'orig_hex: {e.sec.orig_bytes.hex()}', c ))
@@ -203,17 +205,19 @@ class AddrFile(MMGenObject):
 		def parse_addrfile_label(lbl):
 			"""
 			label examples:
-			- Bitcoin legacy mainnet:   no label
-			- Bitcoin legacy testnet:   'LEGACY:TESTNET'
-			- Bitcoin Segwit:           'SEGWIT'
-			- Bitcoin Segwit testnet:   'SEGWIT:TESTNET'
-			- Bitcoin Bech32 regtest:   'BECH32:REGTEST'
-			- Litecoin legacy mainnet:  'LTC'
-			- Litecoin Bech32 mainnet:  'LTC:BECH32'
-			- Litecoin legacy testnet:  'LTC:LEGACY:TESTNET'
-			- Ethereum mainnet:         'ETH'
-			- Ethereum Classic mainnet: 'ETC'
-			- Ethereum regtest:         'ETH:REGTEST'
+			- Bitcoin legacy mainnet:           no label
+			- BCH legacy mainnet (no cashaddr): no label
+			- BCH legacy mainnet (cashaddr):    'BCH'
+			- Bitcoin legacy testnet:           'LEGACY:TESTNET'
+			- Bitcoin Segwit:                   'SEGWIT'
+			- Bitcoin Segwit testnet:           'SEGWIT:TESTNET'
+			- Bitcoin Bech32 regtest:           'BECH32:REGTEST'
+			- Litecoin legacy mainnet:          'LTC'
+			- Litecoin Bech32 mainnet:          'LTC:BECH32'
+			- Litecoin legacy testnet:          'LTC:LEGACY:TESTNET'
+			- Ethereum mainnet:                 'ETH'
+			- Ethereum Classic mainnet:         'ETC'
+			- Ethereum regtest:                 'ETH:REGTEST'
 			"""
 			lbl = lbl.lower()
 
@@ -229,18 +233,18 @@ class AddrFile(MMGenObject):
 
 			from .proto.btc.params import mainnet
 			if lbl in [MMGenAddrType(mainnet,key).name for key in mainnet.mmtypes]:
-				coin,mmtype_key = ( 'BTC', lbl )
+				coin, mmtype_key = ('BTC', lbl)
 			elif ':' in lbl: # first component is coin, second is mmtype_key
-				coin,mmtype_key = lbl.split(':')
+				coin, mmtype_key = lbl.split(':')
 			else:            # only component is coin
-				coin,mmtype_key = ( lbl, None )
+				coin, mmtype_key = (lbl, None)
 
-			proto = init_proto( p.cfg, coin=coin, network=network )
+			proto = init_proto(p.cfg, coin=coin, network=network)
 
 			if mmtype_key is None:
 				mmtype_key = proto.mmtypes[0]
 
-			return ( proto, proto.addr_type(mmtype_key) )
+			return (proto, proto.addr_type(mmtype_key))
 
 		p = self.parent
 

+ 9 - 8
mmgen/addrlist.py

@@ -121,13 +121,14 @@ class AddrListIDStr(HiliteStr):
 		if fmt_str:
 			ret = fmt_str.format(s)
 		else:
-			bc = (addrlist.proto.base_coin,addrlist.proto.coin)[addrlist.proto.base_coin=='ETH']
-			mt = addrlist.al_id.mmtype
+			proto = addrlist.proto
+			coin = 'BTC' if proto.coin == 'BCH' and not addrlist.cfg.cashaddr else proto.coin
+			mmtype = addrlist.al_id.mmtype
 			ret = '{}{}{}[{}]'.format(
 				addrlist.al_id.sid,
-				('-'+bc,'')[bc == 'BTC'],
-				('-'+mt,'')[mt in ('L','E')],
-				s )
+				(f'-{coin}', '')[coin == 'BTC'],
+				(f'-{mmtype}', '')[mmtype in ('L','E')],
+				s)
 
 		addrlist.dmsg_sc('id_str',ret[8:].split('[')[0])
 
@@ -146,7 +147,7 @@ class AddrList(MMGenObject): # Address info for a single seed ID
 	gen_passwds  = False
 	gen_keys     = False
 	has_keys     = False
-	chksum_rec_f = lambda foo,e: ( str(e.idx), e.addr )
+	chksum_rec_f = lambda foo, e: (str(e.idx), e.addr.views[e.addr.view_pref])
 
 	def dmsg_sc(self,desc,data):
 		Msg(f'sc_debug_{desc}: {data}')
@@ -414,12 +415,12 @@ class KeyAddrList(AddrList):
 	gen_desc_pl  = 's'
 	gen_keys     = True
 	has_keys     = True
-	chksum_rec_f = lambda foo,e: (str(e.idx), e.addr, e.sec.wif)
+	chksum_rec_f = lambda foo, e: (str(e.idx), e.addr.views[e.addr.view_pref], e.sec.wif)
 
 class ViewKeyAddrList(KeyAddrList):
 	desc         = 'viewkey-address'
 	gen_desc     = 'viewkey/address pair'
-	chksum_rec_f = lambda foo,e: ( str(e.idx), e.addr )
+	chksum_rec_f = lambda foo, e: (str(e.idx), e.addr)
 
 class KeyList(KeyAddrList):
 	desc         = 'key'

+ 1 - 1
mmgen/autosign.py

@@ -305,7 +305,7 @@ class Signable:
 						for nm in non_mmgen:
 							yield fs.format(
 								tx.txid.fmt( width=t_wid, color=True ) if nm is non_mmgen[0] else ' '*t_wid,
-								nm.addr.fmt( width=a_wid, color=True ),
+								nm.addr.fmt(nm.addr.view_pref, width=a_wid, color=True),
 								nm.amt.hl() + ' ' + yellow(tx.coin))
 
 				msg('\n' + '\n'.join(gen()))

+ 1 - 1
mmgen/bip_hd/__init__.py

@@ -136,7 +136,7 @@ def check_privkey(key_int):
 
 class BipHDConfig(Lockable):
 
-	supported_coins = ('btc', 'eth', 'doge', 'ltc')
+	supported_coins = ('btc', 'eth', 'doge', 'ltc', 'bch')
 
 	def __init__(self, base_cfg, coin, network, addr_type, from_path, no_path_checks):
 

+ 6 - 2
mmgen/cfg.py

@@ -195,6 +195,9 @@ class Config(Lockable):
 	carol        = False
 	regtest_user = ''
 
+	# altcoin:
+	cashaddr = True
+
 	# Monero:
 	monero_wallet_rpc_user     = 'monero'
 	monero_wallet_rpc_password = ''
@@ -273,6 +276,7 @@ class Config(Lockable):
 		'subseeds',
 		'testnet',
 		'usr_randchars',
+		'bch_cashaddr',
 		'bch_max_tx_fee',
 		'btc_max_tx_fee',
 		'eth_max_tx_fee',
@@ -528,10 +532,10 @@ class Config(Lockable):
 		self._lock()
 
 		if need_proto:
-			from .protocol import warn_trustlevel,init_proto_from_cfg
-			warn_trustlevel(self)
+			from .protocol import init_proto_from_cfg, warn_trustlevel
 			# requires the default-to-none behavior, so do after the lock:
 			self._proto = init_proto_from_cfg(self,need_amt=need_amt)
+			warn_trustlevel(self) # do this after initializing proto
 
 		if self._opts and not do_post_init:
 			self._opts.init_bottom(self)

+ 3 - 0
mmgen/data/mmgen.cfg

@@ -115,6 +115,9 @@
 ## Altcoin options ##
 #####################
 
+# Set this to false to prefer legacy BCH address format:
+# bch_cashaddr true
+
 # Set the maximum transaction fee for BCH:
 # bch_max_tx_fee 0.1
 

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-15.0.0
+15.1.dev1

+ 1 - 1
mmgen/main_addrimport.py

@@ -147,7 +147,7 @@ async def main():
 		mode       = 'i' )
 
 	if cfg.token or cfg.token_addr:
-		msg(f'Importing for token {twctl.token.hl()} ({twctl.token.hlc(proto.tokensym)})')
+		msg(f'Importing for token {twctl.token.hl(0)} ({twctl.token.hlc(proto.tokensym)})')
 
 	for k,v in addrimport_msgs.items():
 		addrimport_msgs[k] = fmt(v,indent='  ',strip_char='\t').rstrip()

+ 8 - 7
mmgen/opts.py

@@ -42,10 +42,11 @@ long_opts_data = {
 --, --accept-defaults      Accept defaults at all prompts
 --, --coin=c               Choose coin unit. Default: BTC. Current choice: {cu_dfl}
 --, --token=t              Specify an ERC20 token by address or symbol
---, --color=0|1            Disable or enable color output (enabled by default)
+--, --cashaddr=0|1         Display BCH addresses in cashaddr format (default: 1)
+--, --color=0|1            Disable or enable color output (default: 1)
 --, --columns=N            Force N columns of output with certain commands
 --, --scroll               Use the curses-like scrolling interface for
-                           tracking wallet views
+                         tracking wallet views
 --, --force-256-color      Force 256-color output when color is enabled
 --, --pager                Pipe output of certain commands to pager (WIP)
 --, --data-dir=path        Specify {pnm} data directory location
@@ -93,11 +94,11 @@ class UserOpts:
 	def __init__(
 			self,
 			cfg,
-			opts_data   = None,
-			init_opts   = None, # dict containing opts to pre-initialize
-			opt_filter  = None, # whitelist of opt letters; all others are skipped
-			parse_only  = False,
-			parsed_opts = None ):
+			opts_data,
+			init_opts,    # dict containing opts to pre-initialize
+			opt_filter,   # whitelist of opt letters; all others are skipped
+			parse_only,
+			parsed_opts):
 
 		self.opts_data = od = opts_data or opts_data_dfl
 		self.opt_filter = opt_filter

+ 99 - 0
mmgen/proto/bch/cashaddr.py

@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+#
+# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
+# Copyright (C)2013-2024 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+proto.bch.cashaddr: Bitcoin Cash cashaddr implementation for the MMGen Project
+"""
+
+# Specification: https://upgradespecs.bitcoincashnode.org/cashaddr
+
+import re
+from collections import namedtuple
+
+b32_matrix = """
+	q  p  z  r  y  9  x  8
+	g  f  2  t  v  d  w  0
+	s  3  j  n  5  4  k  h
+	c  e  6  m  u  a  7  l
+"""
+
+b32a = re.sub(r'\s', '', b32_matrix)
+
+cashaddr_addr_types = {
+	'p2pkh':        0,
+	'p2sh':         1,
+	'token_pubkey': 2,
+	'token_script': 3,
+	'unknown':      15,
+}
+addr_types_rev = {v:k for k,v in cashaddr_addr_types.items()}
+
+data_sizes = (160, 192, 224, 256, 320, 384, 448, 512)
+
+def PolyMod(v):
+	c = 1
+	for d in v:
+		c0 = c >> 35
+		c = ((c & 0x07ffffffff) << 5) ^ d
+		if (c0 & 1): c ^= 0x98f2bc8e61
+		if (c0 & 2): c ^= 0x79b76d99e2
+		if (c0 & 4): c ^= 0xf33e5fb3c4
+		if (c0 & 8): c ^= 0xae2eabe2a8
+		if (c0 & 16): c ^= 0x1e4f43e470
+	return c ^ 1
+
+def parse_ver_byte(ver):
+	assert not (ver >> 7), 'invalid version byte: most-significant bit must be zero'
+	t = namedtuple('parsed_version_byte', ['addr_type', 'bitlen'])
+	return t(addr_types_rev[ver >> 3], data_sizes[ver & 7])
+
+def make_ver_byte(addr_type, bitlen):
+	assert addr_type in addr_types_rev, f'{addr_type}: invalid addr type'
+	return (addr_type << 3) | data_sizes.index(bitlen)
+
+def bin2vec(data_bin):
+	assert not len(data_bin) % 5, f'{len(data_bin)}: data length not a multiple of 5'
+	return [int(data_bin[i*5:(i*5)+5], 2) for i in range(len(data_bin) // 5)]
+
+def make_polymod_vec(pfx, payload_vec):
+	return ([ord(c) & 31  for c in pfx] + [0] + payload_vec)
+
+def cashaddr_parse_addr(addr):
+	t = namedtuple('parsed_cashaddr', ['pfx', 'payload'])
+	return t(*addr.split(':', 1))
+
+def cashaddr_encode_addr(addr_type, size, pfx, data):
+	t = namedtuple('encoded_cashaddr', ['addr', 'pfx', 'payload'])
+	payload_bin = (
+		'{:08b}'.format(make_ver_byte(addr_type, size * 8)) +
+		'{:0{w}b}'.format(int.from_bytes(data), w=len(data) * 8)
+	)
+	payload_vec = bin2vec(payload_bin + '0' * (-len(payload_bin) % 5))
+	chksum_vec = bin2vec('{:040b}'.format(PolyMod(make_polymod_vec(pfx, payload_vec + [0] * 8))))
+	payload = ''.join(b32a[i] for i in payload_vec + chksum_vec)
+	return t(f'{pfx}:{payload}', pfx, payload)
+
+def cashaddr_decode_addr(addr):
+	t = namedtuple('decoded_cashaddr', ['pfx', 'payload', 'addr_type', 'bytes', 'chksum'])
+	a = cashaddr_parse_addr(addr.lower())
+	data_bin = ''.join(f'{b32a.index(c):05b}' for c in a.payload)
+	vi = parse_ver_byte(int(data_bin[:8], 2))
+	assert len(data_bin) >= vi.bitlen + 48, 'cashaddr data length too short!'
+	data    = int(data_bin[8:8+vi.bitlen], 2).to_bytes(vi.bitlen // 8)
+	chksum  = int(data_bin[-40:], 2).to_bytes(5)
+	pad_bin = data_bin[8+vi.bitlen:-40]
+	assert not pad_bin or pad_bin in '0000', f'{pad_bin}: invalid cashaddr data'
+	if chksum_chk := PolyMod(make_polymod_vec(a.pfx, [b32a.index(c) for c in a.payload])) != 0:
+		raise ValueError(
+			'checksum check failed\n'
+			f'  address:  {addr}\n'
+			f'  result:   0x{chksum_chk:x}\n'
+			f'  checksum: 0x{chksum.hex()}')
+	return t(a.pfx, a.payload, vi.addr_type, data, chksum)

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

@@ -12,7 +12,11 @@
 proto.bch.params: Bitcoin Cash protocol
 """
 
-from ..btc.params import mainnet,_finfo
+from ...protocol import decoded_addr_multiview
+from ...addr import CoinAddr
+from ..btc.params import mainnet, _finfo
+from ..btc.common import b58chk_decode, b58chk_encode
+from .cashaddr import cashaddr_decode_addr, cashaddr_encode_addr, cashaddr_addr_types
 
 class mainnet(mainnet):
 	is_fork_of      = 'Bitcoin'
@@ -25,6 +29,50 @@ class mainnet(mainnet):
 	coin_amt        = 'BCHAmt'
 	max_tx_fee      = '0.1'
 	ignore_daemon_version = False
+	cashaddr_pfx    = 'bitcoincash'
+	cashaddr        = True
+
+	def decode_addr(self, addr):
+		if len(addr) >= 42: # cashaddr
+			if addr.islower():
+				pass
+			elif addr.isupper():
+				addr = addr.lower()
+			else:
+				raise ValueError(f'{addr}: address has mixed case!')
+			if ':' in addr:
+				assert addr.startswith(self.cashaddr_pfx), f'{addr}: address has invalid prefix!'
+			else:
+				addr = f'{self.cashaddr_pfx}:{addr}'
+			dec = cashaddr_decode_addr(addr)
+			ver_bytes = self.addr_fmt_to_ver_bytes[dec.addr_type]
+			return decoded_addr_multiview(
+				dec.bytes,
+				ver_bytes,
+				dec.addr_type,
+				addr,
+				[dec.payload, b58chk_encode(ver_bytes+dec.bytes)] if len(dec.bytes) == self.addr_len else
+				[dec.payload],
+				0)
+		else:
+			dec = self.decode_addr_bytes(b58chk_decode(addr))
+			enc = cashaddr_encode_addr(
+				cashaddr_addr_types[dec.fmt],
+				len(dec.bytes),
+				self.cashaddr_pfx,
+				dec.bytes)
+			return decoded_addr_multiview(*dec, enc.addr, [enc.payload, addr], 1)
+
+	def pubhash2addr(self, pubhash, addr_type):
+		return CoinAddr(
+			self,
+			cashaddr_encode_addr(
+				cashaddr_addr_types[addr_type],
+				len(pubhash),
+				self.cashaddr_pfx,
+				pubhash).addr
+				if self.cfg.cashaddr else
+			b58chk_encode(self.addr_fmt_to_ver_bytes[addr_type] + pubhash))
 
 	def pubhash2redeem_script(self,pubhash):
 		raise NotImplementedError
@@ -35,6 +83,8 @@ class mainnet(mainnet):
 class testnet(mainnet):
 	addr_ver_info  = { '6f': 'p2pkh', 'c4': 'p2sh' }
 	wif_ver_num    = { 'std': 'ef' }
+	cashaddr_pfx   = 'bchtest'
 
 class regtest(testnet):
 	halving_interval = 150
+	cashaddr_pfx     = 'bchreg'

+ 6 - 13
mmgen/proto/btc/addrgen.py

@@ -12,17 +12,14 @@
 proto.btc.addrgen: Bitcoin address generation classes for the MMGen suite
 """
 
-from ...addrgen import addr_generator,check_data
-from ...addr import CoinAddr
+from ...addrgen import addr_generator, check_data
 from .common import hash160
 
 class p2pkh(addr_generator.base):
 
 	@check_data
-	def to_addr(self,data):
-		return CoinAddr(
-			self.proto,
-			self.proto.pubhash2addr( hash160(data.pubkey), p2sh=False ))
+	def to_addr(self, data):
+		return self.proto.pubhash2addr(hash160(data.pubkey), 'p2pkh')
 
 class legacy(p2pkh):
 	pass
@@ -34,17 +31,13 @@ class segwit(addr_generator.base):
 
 	@check_data
 	def to_addr(self,data):
-		return CoinAddr(
-			self.proto,
-			self.proto.pubhash2segwitaddr( hash160(data.pubkey)) )
+		return self.proto.pubhash2segwitaddr(hash160(data.pubkey))
 
 	def to_segwit_redeem_script(self,data): # NB: returns hex
-		return self.proto.pubhash2redeem_script( hash160(data.pubkey) ).hex()
+		return self.proto.pubhash2redeem_script(hash160(data.pubkey)).hex()
 
 class bech32(addr_generator.base):
 
 	@check_data
 	def to_addr(self,data):
-		return CoinAddr(
-			self.proto,
-			self.proto.pubhash2bech32addr( hash160(data.pubkey) ))
+		return self.proto.pubhash2bech32addr(hash160(data.pubkey))

+ 0 - 1
mmgen/proto/btc/daemon.py

@@ -79,7 +79,6 @@ class bitcoin_core_daemon(CoinDaemon):
 			['--pid='+self.pidfile,    self.use_pidfile],
 			['--daemon',               self.platform in ('linux', 'darwin') and not self.opt.no_daemonize],
 			['--fallbackfee=0.0002',   self.coin == 'BTC' and self.network == 'regtest'],
-			['--usecashaddr=0',        self.coin == 'BCH'],
 			['--deprecatedrpc=create_bdb', self.coin == 'BTC' and self.opt.bdb_wallet],
 			['--mempoolreplacement=1', self.coin == 'LTC'],
 			['--txindex=1',            self.coin == 'LTC' or self.network == 'regtest'],

+ 21 - 18
mmgen/proto/btc/params.py

@@ -12,8 +12,9 @@
 proto.btc.params: Bitcoin protocol
 """
 
-from ...protocol import CoinProtocol,decoded_wif,decoded_addr,_finfo,_nw
-from .common import b58chk_decode,b58chk_encode,hash160
+from ...protocol import CoinProtocol, decoded_wif, decoded_addr, _finfo, _nw
+from ...addr import CoinAddr
+from .common import b58chk_decode, b58chk_encode, hash160
 
 class mainnet(CoinProtocol.Secp256k1): # chainparams.cpp
 	"""
@@ -75,7 +76,7 @@ class mainnet(CoinProtocol.Secp256k1): # chainparams.cpp
 			pubkey_type = self.wif_ver_bytes_to_pubkey_type[key_data[:vlen]],
 			compressed  = len(key) == self.privkey_len + 1 )
 
-	def decode_addr(self,addr):
+	def decode_addr(self, addr):
 
 		if 'B' in self.mmtypes and addr[:len(self.bech32_hrp)] == self.bech32_hrp:
 			from ...contrib import bech32
@@ -86,33 +87,35 @@ class mainnet(CoinProtocol.Secp256k1): # chainparams.cpp
 				msg(f'{ret[0]}: Invalid witness version number')
 				return False
 
-			return decoded_addr( bytes(ret[1]), None, 'bech32' ) if ret[1] else False
+			return decoded_addr(bytes(ret[1]), None, 'bech32') if ret[1] else False
 
 		return self.decode_addr_bytes(b58chk_decode(addr))
 
-	def pubhash2addr(self,pubhash,p2sh):
-		assert len(pubhash) == 20, f'{len(pubhash)}: invalid length for pubkey hash'
-		return b58chk_encode(
-			self.addr_fmt_to_ver_bytes[('p2pkh','p2sh')[p2sh]] + pubhash
-		)
+	def pubhash2addr(self, pubhash, addr_type):
+		assert len(pubhash) == self.addr_len, f'{len(pubhash)}: invalid length for pubkey hash'
+		return CoinAddr(
+			self,
+			b58chk_encode(self.addr_fmt_to_ver_bytes[addr_type] + pubhash))
 
 	# Segwit:
-	def pubhash2redeem_script(self,pubhash):
+	def pubhash2redeem_script(self, pubhash):
 		# https://bitcoincore.org/en/segwit_wallet_dev/
 		# The P2SH redeemScript is always 22 bytes. It starts with a OP_0, followed
 		# by a canonical push of the keyhash (i.e. 0x0014{20-byte keyhash})
 		return bytes.fromhex(self.witness_vernum_hex + '14') + pubhash
 
-	def pubhash2segwitaddr(self,pubhash):
-		return self.pubhash2addr(
-			hash160( self.pubhash2redeem_script(pubhash) ),
-			p2sh = True )
+	def pubhash2segwitaddr(self, pubhash):
+		return CoinAddr(
+			self,
+			self.pubhash2addr(hash160(self.pubhash2redeem_script(pubhash)), 'p2sh'))
 
-	def pubhash2bech32addr(self,pubhash):
+	def pubhash2bech32addr(self, pubhash):
 		from ...contrib import bech32
-		return bech32.bech32_encode(
-			hrp  = self.bech32_hrp,
-			data = [self.witness_vernum] + bech32.convertbits(list(pubhash),8,5) )
+		return CoinAddr(
+			self,
+			bech32.bech32_encode(
+				hrp  = self.bech32_hrp,
+				data = [self.witness_vernum] + bech32.convertbits(list(pubhash),8,5)))
 
 class testnet(mainnet):
 	addr_ver_info       = { '6f': 'p2pkh', 'c4': 'p2sh' }

+ 9 - 7
mmgen/proto/btc/tw/addresses.py

@@ -21,13 +21,15 @@ from .rpc import BitcoinTwRPC
 class BitcoinTwAddresses(TwAddresses,BitcoinTwRPC):
 
 	has_age = True
-	prompt_fs = """
-Sort options: [a]mt, [A]ge, [M]mgen addr, [r]everse
-Column options: toggle [D]ays/date/confs/block
-Filters: show [E]mpty addrs, [u]sed addrs, all [L]abels
-View/Print: pager [v]iew, [w]ide pager view, [p]rint{s}
-Actions: [q]uit menu, r[e]draw, add [l]abel:
-"""
+	prompt_fs_in = [
+		'Sort options: [a]mt, [A]ge, [M]mgen addr, [r]everse',
+		'Column options: toggle [D]ays/date/confs/block',
+		'Filters: show [E]mpty addrs, [u]sed addrs, all [L]abels',
+		'View/Print: pager [v]iew, [w]ide pager view, [p]rint{s}',
+		'Actions: [q]uit menu, r[e]draw, add [l]abel:']
+	prompt_fs_repl = {
+		'BCH': (1, 'Column options: toggle [D]ays/date/confs/block, cas[h]addr')
+	}
 	key_mappings = {
 		'a':'s_amt',
 		'A':'s_age',

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

@@ -68,7 +68,7 @@ class BitcoinTwCtl(TwCtl):
 		endless = stop is None
 		CR = '\n' if self.cfg.test_suite else '\r'
 
-		if not ( start >= 0 and (stop if stop is not None else start) >= start ):
+		if not (start >= 0 and (stop if stop is not None else start) >= start):
 			die(1,f'{start} {stop}: invalid range')
 
 		async def do_scan(chunks,tip):

+ 9 - 7
mmgen/proto/btc/tw/prune.py

@@ -17,13 +17,15 @@ from .addresses import BitcoinTwAddresses
 
 class BitcoinTwAddressesPrune(BitcoinTwAddresses,TwAddressesPrune):
 
-	prompt_fs = """
-Sort options: [a]mt, [A]ge, [M]mgen addr, [r]everse
-Column options: toggle [D]ays/date/confs/block
-Filters: show [E]mpty addrs, [U]sed addrs, all [L]abels
-View/Actions: pager [v]iew, [w]ide view, r[e]draw{s}
-Pruning: [q]uit pruning, [p]rune, [u]nprune, [c]lear prune list:
-"""
+	prompt_fs_in = [
+		'Sort options: [a]mt, [A]ge, [M]mgen addr, [r]everse',
+		'Column options: toggle [D]ays/date/confs/block',
+		'Filters: show [E]mpty addrs, [U]sed addrs, all [L]abels',
+		'View/Actions: pager [v]iew, [w]ide view, r[e]draw{s}',
+		'Pruning: [q]uit pruning, [p]rune, [u]nprune, [c]lear prune list:']
+	prompt_fs_repl = {
+		'BCH': (1, 'Column options: toggle [D]ays/date/confs/block, cas[h]addr')
+	}
 	key_mappings = {
 		'a':'s_amt',
 		'A':'s_age',

+ 13 - 10
mmgen/proto/btc/tw/txhistory.py

@@ -145,7 +145,7 @@ class BitcoinTwTransaction:
 	def txid_disp(self,color,width=None):
 		return self.txid.hl(color=color) if width is None else self.txid.truncate(width=width,color=color)
 
-	def vouts_list_disp(self,src,color,indent=''):
+	def vouts_list_disp(self, src, color, indent, addr_view_pref):
 
 		fs1,fs2 = {
 			'inputs':  ('{i},{n} {a} {A}', '{i},{n} {a} {A} {l}'),
@@ -159,7 +159,8 @@ class BitcoinTwTransaction:
 					yield fs1.format(
 						i = CoinTxID(e.txid).hl(color=color),
 						n = (nocolor,red)[color](str(e.data['n']).ljust(3)),
-						a = CoinAddr(self.proto,e.coin_addr).fmt( width=self.max_addrlen[src], color=color ),
+						a = CoinAddr(self.proto, e.coin_addr).fmt(
+							addr_view_pref, width=self.max_addrlen[src], color=color),
 						A = self.proto.coin_amt( e.data['value'] ).fmt(color=color)
 					).rstrip()
 				else:
@@ -179,7 +180,7 @@ class BitcoinTwTransaction:
 
 		return f'\n{indent}'.join( gen_output() ).strip()
 
-	def vouts_disp(self,src,width,color):
+	def vouts_disp(self, src, width, color, addr_view_pref):
 
 		def gen_output():
 
@@ -191,7 +192,7 @@ class BitcoinTwTransaction:
 				if not mmid:
 					if width and space_left < addr_w:
 						break
-					yield CoinAddr( self.proto, e.coin_addr ).fmt(width=addr_w,color=color)
+					yield CoinAddr(self.proto, e.coin_addr).fmt(addr_view_pref, width=addr_w, color=color)
 					space_left -= addr_w
 				elif mmid.type == 'mmgen':
 					mmid_disp = mmid + bal_star
@@ -234,12 +235,14 @@ class BitcoinTwTxHistory(TwTxHistory,BitcoinTwRPC):
 	desc = 'transaction history'
 	item_desc = 'transaction'
 	no_data_errmsg = 'No transactions in tracking wallet!'
-	prompt_fs = """
-Sort options: [t]xid, [a]mt, total a[m]t, [A]ge, block[n]um, [r]everse
-Column options: toggle [D]ays/date/confs/block, tx[i]d, [T]otal amt
-View/Print: pager [v]iew, full pager [V]iew, [p]rint, full [P]rint{s}
-Filters/Actions: show [u]nconfirmed, [q]uit menu, r[e]draw:
-"""
+	prompt_fs_in = [
+		'Sort options: [t]xid, [a]mt, total a[m]t, [A]ge, block[n]um, [r]everse',
+		'Column options: toggle [D]ays/date/confs/block, tx[i]d, [T]otal amt',
+		'View/Print: pager [v]iew, full pager [V]iew, [p]rint, full [P]rint{s}',
+		'Filters/Actions: show [u]nconfirmed, [q]uit menu, r[e]draw:']
+	prompt_fs_repl = {
+		'BCH': (1, 'Column options: toggle [D]ate/confs, cas[h]addr, tx[i]d, [T]otal amt')
+	}
 	key_mappings = {
 		'A':'s_age',
 		'n':'s_blockheight',

+ 8 - 6
mmgen/proto/btc/tw/unspent.py

@@ -28,12 +28,14 @@ class BitcoinTwUnspentOutputs(TwUnspentOutputs):
 	item_desc = 'unspent output'
 	no_data_errmsg = 'No unspent outputs in tracking wallet!'
 	dump_fn_pfx = 'listunspent'
-	prompt_fs = """
-Sort options: [t]xid, [a]mount, [A]ge, a[d]dr, [M]mgen addr, [r]everse
-Column options: toggle [D]ays/date/confs/block, gr[o]up, show [m]mgen addr
-View options: pager [v]iew, [w]ide pager view{s}
-Actions: [q]uit menu, [p]rint, r[e]draw, add [l]abel:
-"""
+	prompt_fs_in = [
+		'Sort options: [t]xid, [a]mount, [A]ge, a[d]dr, [M]mgen addr, [r]everse',
+		'Column options: toggle [D]ays/date/confs/block, gr[o]up, show [m]mgen addr',
+		'View options: pager [v]iew, [w]ide pager view{s}',
+		'Actions: [q]uit menu, [p]rint, r[e]draw, add [l]abel:']
+	prompt_fs_repl = {
+		'BCH': (1, 'Column options: toggle [D]ate/confs, cas[h]addr, gr[o]up, show [m]mgen addr')
+	}
 	key_mappings = {
 		't':'s_txid',
 		'a':'s_amt',

+ 13 - 14
mmgen/proto/btc/tx/base.py

@@ -33,11 +33,11 @@ def addr2scriptPubKey(proto,addr):
 
 def scriptPubKey2addr(proto,s):
 	if len(s) == 50 and s[:6] == '76a914' and s[-4:] == '88ac':
-		return proto.pubhash2addr(bytes.fromhex(s[6:-4]),p2sh=False),'p2pkh'
+		return proto.pubhash2addr(bytes.fromhex(s[6:-4]), 'p2pkh'), 'p2pkh'
 	elif len(s) == 46 and s[:4] == 'a914' and s[-2:] == '87':
-		return proto.pubhash2addr(bytes.fromhex(s[4:-2]),p2sh=True),'p2sh'
+		return proto.pubhash2addr(bytes.fromhex(s[4:-2]), 'p2sh'), 'p2sh'
 	elif len(s) == 44 and s[:4] == proto.witness_vernum_hex + '14':
-		return proto.pubhash2bech32addr(bytes.fromhex(s[4:])),'bech32'
+		return proto.pubhash2bech32addr(bytes.fromhex(s[4:])), 'bech32'
 	else:
 		raise NotImplementedError(f'Unknown scriptPubKey ({s})')
 
@@ -284,17 +284,17 @@ class Base(TxBase.Base):
 		"""
 
 		def do_error(errmsg):
-			die( 'TxHexMismatch', errmsg+'\n'+hdr )
+			die('TxHexMismatch', errmsg+'\n'+hdr)
 
 		def check_equal(desc,hexio,mmio):
 			if mmio != hexio:
 				msg('\nMMGen {d}:\n{m}\nSerialized {d}:\n{h}'.format(
 					d = desc,
 					m = pp_fmt(mmio),
-					h = pp_fmt(hexio) ))
+					h = pp_fmt(hexio)))
 				do_error(
 					f'{desc.capitalize()} in serialized transaction data from coin daemon ' +
-					'do not match those in MMGen transaction!' )
+					'do not match those in MMGen transaction!')
 
 		hdr = 'A malicious or malfunctioning coin daemon or other program may have altered your data!'
 
@@ -303,23 +303,22 @@ class Base(TxBase.Base):
 		if dtx.locktime != int(self.locktime or 0):
 			do_error(
 				f'Transaction hex nLockTime ({dtx.locktime}) ' +
-				f'does not match MMGen transaction nLockTime ({self.locktime})' )
+				f'does not match MMGen transaction nLockTime ({self.locktime})')
 
 		check_equal(
 			'sequence numbers',
 			[i['nSeq'] for i in dtx.txins],
-			['{:08x}'.format(i.sequence or self.proto.max_int) for i in self.inputs] )
+			['{:08x}'.format(i.sequence or self.proto.max_int) for i in self.inputs])
 
 		check_equal(
 			'inputs',
-			sorted((i['txid'],i['vout']) for i in dtx.txins),
-			sorted((i.txid,i.vout) for i in self.inputs) )
+			sorted((i['txid'], i['vout']) for i in dtx.txins),
+			sorted((i.txid, i.vout) for i in self.inputs))
 
 		check_equal(
 			'outputs',
-			sorted((o['address'],self.proto.coin_amt(o['amount'])) for o in dtx.txouts),
-			sorted((o.addr,o.amt) for o in self.outputs) )
+			sorted((o['address'], self.proto.coin_amt(o['amount'])) for o in dtx.txouts),
+			sorted((o.addr, o.amt) for o in self.outputs))
 
 		if str(self.txid) != make_chksum_6(bytes.fromhex(dtx.unsigned_hex)).upper():
-			do_error(
-				f'MMGen TxID ({self.txid}) does not match serialized transaction data!')
+			do_error(f'MMGen TxID ({self.txid}) does not match serialized transaction data!')

+ 42 - 24
mmgen/proto/btc/tx/info.py

@@ -48,56 +48,66 @@ class TxInfo(TxInfo):
 			out += f', Base {tsize-wsize}, Witness {wsize}'
 		return out + '\n'
 
-	def format_body(self,blockcount,nonmm_str,max_mmwid,enl,terse,sort):
-		tx = self.tx
+	def format_body(self, blockcount, nonmm_str, max_mmwid, enl, terse, sort):
 
 		if sort not in self.sort_orders:
 			die(1,'{!r}: invalid transaction view sort order. Valid options: {}'.format(
 				sort,
 				','.join(self.sort_orders) ))
 
+		def get_mmid_fmt(e, is_input):
+			if e.mmid:
+				return e.mmid.fmt2(
+					width=max_mmwid,
+					encl='()',
+					color=True,
+					append_chars=('',' (chg)')[bool(not is_input and e.is_chg and terse)],
+					append_color='green')
+			else:
+				return MMGenID.fmtc( nonmm_str, width=max_mmwid, color=True )
+
 		def format_io(desc):
 			io = getattr(tx,desc)
 			is_input = desc == 'inputs'
 			yield desc.capitalize() + ':\n' + enl
 			confs_per_day = 60*60*24 // tx.proto.avg_bdi
+
 			io_sorted = {
 				'addr': lambda: sorted(
 					io, # prepend '+' (sorts before '0') to ensure non-MMGen addrs are displayed first
 					key = lambda o: (o.mmid.sort_key if o.mmid else f'+{o.addr}') + f'{o.amt:040.20f}' ),
 				'raw':  lambda: io
 			}[sort]
+
 			if terse:
 				iwidth = max(len(str(int(e.amt))) for e in io)
-			else:
-				col1_w = len(str(len(io))) + 1
-			for n,e in enumerate(io_sorted()):
-				if is_input and blockcount:
-					confs = e.confs + blockcount - tx.blockcount
-					days = int(confs // confs_per_day)
-				if e.mmid:
-					mmid_fmt = e.mmid.fmt2(
-						width=max_mmwid,
-						encl='()',
-						color=True,
-						append_chars=('',' (chg)')[bool(not is_input and e.is_chg and terse)],
-						append_color='green')
-				else:
-					mmid_fmt = MMGenID.fmtc( nonmm_str, width=max_mmwid, color=True )
-				if terse:
+				addr_w = max(len(e.addr.views[vp1]) for f in (tx.inputs,tx.outputs) for e in f)
+				for n,e in enumerate(io_sorted()):
 					yield '{:3} {} {} {} {}\n'.format(
 						n+1,
-						e.addr.fmt(color=True,width=addr_w),
-						mmid_fmt,
+						e.addr.fmt(vp1, width=addr_w, color=True),
+						get_mmid_fmt(e, is_input),
 						e.amt.fmt(iwidth=iwidth,color=True),
 						tx.dcoin )
-				else:
+					if have_bch:
+						yield '{:3} [{}]\n'.format('', e.addr.hl(vp2, color=False))
+			else:
+				col1_w = len(str(len(io))) + 1
+				for n,e in enumerate(io_sorted()):
+					mmid_fmt = get_mmid_fmt(e, is_input)
+					if is_input and blockcount:
+						confs = e.confs + blockcount - tx.blockcount
+						days = int(confs // confs_per_day)
 					def gen():
 						if is_input:
 							yield (n+1, 'tx,vout:', f'{e.txid.hl()},{red(str(e.vout))}')
-							yield ('',  'address:', f'{e.addr.hl()} {mmid_fmt}')
+							yield ('',  'address:', f'{e.addr.hl(vp1)} {mmid_fmt}')
+							if have_bch:
+								yield ('', '', f'[{e.addr.hl(vp2, color=False)}]')
 						else:
-							yield (n+1, 'address:', f'{e.addr.hl()} {mmid_fmt}')
+							yield (n+1, 'address:', f'{e.addr.hl(vp1)} {mmid_fmt}')
+							if have_bch:
+								yield ('', '', f'[{e.addr.hl(vp2, color=False)}]')
 						if e.comment:
 							yield ('',  'comment:', e.comment.hl())
 						yield     ('',  'amount:',  f'{e.amt.hl()} {tx.dcoin}')
@@ -107,7 +117,15 @@ class TxInfo(TxInfo):
 							yield ('',  'change:',  green('True'))
 					yield '\n'.join('{:>{w}} {:<8} {}'.format(*d,w=col1_w) for d in gen()) + '\n\n'
 
-		addr_w = max(len(e.addr) for f in (tx.inputs,tx.outputs) for e in f)
+		tx = self.tx
+
+		if self.cfg._proto.coin == 'BCH':
+			have_bch = True
+			vp1 = 1 if not self.cfg.cashaddr else not self.cfg._proto.cashaddr
+			vp2 = (vp1 + 1) % 2
+		else:
+			have_bch = False
+			vp1 = 0
 
 		return (
 			'Displaying inputs and outputs in {} sort order'.format({'raw':'raw','addr':'address'}[sort])

+ 3 - 6
mmgen/proto/eth/addrgen.py

@@ -12,13 +12,10 @@
 proto.eth.addrgen: Ethereum address generation class for the MMGen suite
 """
 
-from ...addrgen import addr_generator,check_data
-from ...addr import CoinAddr
+from ...addrgen import addr_generator, check_data
 
 class ethereum(addr_generator.keccak):
 
 	@check_data
-	def to_addr(self,data):
-		return CoinAddr(
-			self.proto,
-			self.keccak_256(data.pubkey[1:]).hexdigest()[24:] )
+	def to_addr(self, data):
+		return self.proto.pubhash2addr(self.keccak_256(data.pubkey[1:]).digest()[12:], 'p2pkh')

+ 7 - 5
mmgen/proto/eth/params.py

@@ -13,6 +13,7 @@ proto.eth.params: Ethereum protocol
 """
 
 from ...protocol import CoinProtocol,_nw,decoded_addr
+from ...addr import CoinAddr
 from ...util import is_hex_str_lc,Msg
 
 class mainnet(CoinProtocol.DummyWIF,CoinProtocol.Secp256k1):
@@ -54,9 +55,9 @@ class mainnet(CoinProtocol.DummyWIF,CoinProtocol.Secp256k1):
 	def dcoin(self):
 		return self.tokensym or self.coin
 
-	def decode_addr(self,addr):
+	def decode_addr(self, addr):
 		if is_hex_str_lc(addr) and len(addr) == self.addr_len * 2:
-			return decoded_addr( bytes.fromhex(addr), None, 'ethereum' )
+			return decoded_addr(bytes.fromhex(addr), None, 'p2pkh')
 		if self.cfg.debug:
 			Msg(f'Invalid address: {addr}')
 		return False
@@ -65,10 +66,11 @@ class mainnet(CoinProtocol.DummyWIF,CoinProtocol.Secp256k1):
 		h = self.keccak_256(addr.encode()).digest().hex()
 		return ''.join(addr[i].upper() if int(h[i],16) > 7 else addr[i] for i in range(len(addr)))
 
-	def pubhash2addr(self,pubhash,p2sh):
+	def pubhash2addr(self, pubhash, addr_type):
 		assert len(pubhash) == 20, f'{len(pubhash)}: invalid length for {self.name} pubkey hash'
-		assert not p2sh, f'{self.name} protocol has no P2SH address format'
-		return pubhash.hex()
+		assert addr_type == 'p2pkh', (
+				f'{addr_type}: bad addr type - {self.name} protocol supports P2PKH address format only')
+		return CoinAddr(self, pubhash.hex())
 
 class testnet(mainnet):
 	chain_names = ['kovan','goerli','rinkeby']

+ 5 - 6
mmgen/proto/eth/tw/addresses.py

@@ -19,12 +19,11 @@ from .rpc import EthereumTwRPC
 class EthereumTwAddresses(TwAddresses,EthereumTwView,EthereumTwRPC):
 
 	has_age = False
-	prompt_fs = """
-Sort options: [a]mt, [M]mgen addr, [r]everse
-Filters: show [E]mpty addrs, show all [L]abels
-View/Print: pager [v]iew, [w]ide pager view, [p]rint{s}
-Actions: [q]uit menu, r[e]draw, [D]elete addr, add [l]abel:
-"""
+	prompt_fs_in = [
+		'Sort options: [a]mt, [M]mgen addr, [r]everse',
+		'Filters: show [E]mpty addrs, show all [L]abels',
+		'View/Print: pager [v]iew, [w]ide pager view, [p]rint{s}',
+		'Actions: [q]uit menu, r[e]draw, [D]elete addr, add [l]abel:']
 	key_mappings = {
 		'a':'s_amt',
 		'M':'s_twmmid',

+ 5 - 6
mmgen/proto/eth/tw/unspent.py

@@ -45,12 +45,11 @@ class EthereumTwUnspentOutputs(EthereumTwView,TwUnspentOutputs):
 	desc    = 'account balances'
 	item_desc = 'account'
 	dump_fn_pfx = 'balances'
-	prompt_fs = """
-Sort options: [a]mount, a[d]dr, [M]mgen addr, [r]everse
-Display options: show [m]mgen addr, r[e]draw screen
-View/Print: pager [v]iew, [w]ide pager view, [p]rint to file{s}
-Actions: [q]uit menu, [D]elete addr, add [l]abel, [R]efresh balance:
-"""
+	prompt_fs_in = [
+		'Sort options: [a]mount, a[d]dr, [M]mgen addr, [r]everse',
+		'Display options: show [m]mgen addr, r[e]draw screen',
+		'View/Print: pager [v]iew, [w]ide pager view, [p]rint to file{s}',
+		'Actions: [q]uit menu, [D]elete addr, add [l]abel, [R]efresh balance:']
 	key_mappings = {
 		'a':'s_amt',
 		'd':'s_addr',

+ 22 - 18
mmgen/proto/eth/tx/info.py

@@ -13,10 +13,9 @@ proto.eth.tx.info: Ethereum transaction info class
 """
 
 from ....tx.info import TxInfo
-from ....util import fmt,pp_fmt
-from ....color import pink,yellow,blue
+from ....util import fmt, pp_fmt
+from ....color import pink, yellow, blue
 from ....addr import MMGenID
-from ....obj import Str
 
 class TxInfo(TxInfo):
 	txinfo_hdr_fs = 'TRANSACTION DATA\n\nID={i} ({a} {c}) Sig={s} Locktime={l}\n'
@@ -27,33 +26,38 @@ class TxInfo(TxInfo):
 		Remaining balance: {C} {d}
 		TX fee:            {a} {c}{r}
 	""")
-	fmt_keys = ('from','to','amt','nonce')
+	to_addr_key = 'to'
 
-	def format_body(self,blockcount,nonmm_str,max_mmwid,enl,terse,sort):
+	def format_body(self, blockcount, nonmm_str, max_mmwid, enl, terse, sort):
 		tx = self.tx
 		m = {}
 		for k in ('inputs','outputs'):
 			if len(getattr(tx,k)):
 				m[k] = getattr(tx,k)[0].mmid if len(getattr(tx,k)) else ''
 				m[k] = ' ' + m[k].hl() if m[k] else ' ' + MMGenID.hlc(nonmm_str)
-		fs = """From:      {}{f_mmid}
-				To:        {}{t_mmid}
-				Amount:    {} {c}
-				Gas price: {g} Gwei
-				Start gas: {G} Kwei
-				Nonce:     {}
-				Data:      {d}
-				\n""".replace('\t','')
+		fs = """
+			From:      {f}{f_mmid}
+			To:        {t}{t_mmid}
+			Amount:    {a} {c}
+			Gas price: {g} Gwei
+			Start gas: {G} Kwei
+			Nonce:     {n}
+			Data:      {d}
+		""".strip().replace('\t','')
 		t = tx.txobj
 		td = t['data']
+		to_addr = t[self.to_addr_key]
 		return fs.format(
-			*((t[k] if t[k] != '' else Str('None')).hl() for k in self.fmt_keys),
-			d      = '{}... ({} bytes)'.format(td[:40],len(td)//2) if len(td) else Str('None'),
+			f      = t['from'].hl(0),
+			t      = to_addr.hl(0) if to_addr else blue('None'),
+			a      = t['amt'].hl(),
+			n      = t['nonce'].hl(),
+			d      = '{}... ({} bytes)'.format(td[:40],len(td)//2) if len(td) else blue('None'),
 			c      = tx.proto.dcoin if len(tx.outputs) else '',
 			g      = yellow(str(t['gasPrice'].to_unit('Gwei',show_decimal=True))),
 			G      = yellow(str(t['startGas'].to_unit('Kwei'))),
 			t_mmid = m['outputs'] if len(tx.outputs) else '',
-			f_mmid = m['inputs'] )
+			f_mmid = m['inputs']) + '\n\n'
 
 	def format_abs_fee(self,color,iwidth):
 		return self.tx.fee.fmt(color=color,iwidth=iwidth) + (' (max)' if self.tx.txobj['data'] else '')
@@ -71,13 +75,13 @@ class TxInfo(TxInfo):
 			return ''
 
 class TokenTxInfo(TxInfo):
-	fmt_keys = ('from','token_to','amt','nonce')
+	to_addr_key = 'token_to'
 
 	def format_rel_fee(self):
 		return ''
 
 	def format_body(self,*args,**kwargs):
 		return 'Token:     {d} {c}\n{r}'.format(
-			d = self.tx.txobj['token_addr'].hl(),
+			d = self.tx.txobj['token_addr'].hl(0),
 			c = blue('(' + self.tx.proto.dcoin + ')'),
 			r = super().format_body(*args,**kwargs ))

+ 6 - 6
mmgen/proto/eth/tx/new.py

@@ -15,12 +15,12 @@ proto.eth.tx.new: Ethereum new transaction class
 import json
 
 from ....tx import new as TxBase
-from ....obj import Int,ETHNonce,MMGenTxID,Str,HexStr
-from ....util import msg,is_int,is_hex_str,make_chksum_6,suf,die
+from ....obj import Int, ETHNonce, MMGenTxID, HexStr
+from ....util import msg, is_int, is_hex_str, make_chksum_6, suf, die
 from ....tw.ctl import TwCtl
-from ....addr import is_mmgen_id,is_coin_addr
+from ....addr import is_mmgen_id, is_coin_addr
 from ..contract import Token
-from .base import Base,TokenBase
+from .base import Base, TokenBase
 
 class New(Base,TxBase.New):
 	desc = 'transaction'
@@ -51,7 +51,7 @@ class New(Base,TxBase.New):
 	async def make_txobj(self): # called by create_serialized()
 		self.txobj = {
 			'from': self.inputs[0].addr,
-			'to':   self.outputs[0].addr if self.outputs else Str(''),
+			'to':   self.outputs[0].addr if self.outputs else None,
 			'amt':  self.outputs[0].amt if self.outputs else self.proto.coin_amt('0'),
 			'gasPrice': self.fee_abs2rel(self.usr_fee,to_unit='eth'),
 			'startGas': self.start_gas,
@@ -69,7 +69,7 @@ class New(Base,TxBase.New):
 		o_ok = 0 if self.usr_contract_data else 1
 		assert o_num == o_ok, f'Transaction has {o_num} output{suf(o_num)} (should have {o_ok})'
 		await self.make_txobj()
-		odict = { k: str(v) for k,v in self.txobj.items() if k != 'token_to' }
+		odict = {k:v if v is None else str(v) for k,v in self.txobj.items() if k != 'token_to'}
 		self.serialized = json.dumps(odict)
 		self.update_txid()
 

+ 1 - 1
mmgen/proto/eth/tx/online.py

@@ -56,7 +56,7 @@ class OnlineSigned(Signed,TxBase.OnlineSigned):
 
 	def print_contract_addr(self):
 		if 'token_addr' in self.txobj:
-			msg('Contract address: {}'.format( self.txobj['token_addr'].hl() ))
+			msg('Contract address: {}'.format(self.txobj['token_addr'].hl(0)))
 
 class TokenOnlineSigned(TokenSigned,OnlineSigned):
 

+ 4 - 4
mmgen/proto/eth/tx/signed.py

@@ -13,9 +13,9 @@ proto.eth.tx.signed: Ethereum signed transaction class
 """
 
 from ....tx import signed as TxBase
-from ....obj import Str,CoinTxID,ETHNonce,HexStr
-from ....addr import CoinAddr,TokenAddr
-from .completed import Completed,TokenCompleted
+from ....obj import CoinTxID, ETHNonce, HexStr
+from ....addr import CoinAddr, TokenAddr
+from .completed import Completed, TokenCompleted
 
 class Signed(Completed,TxBase.Signed):
 
@@ -32,7 +32,7 @@ class Signed(Completed,TxBase.Signed):
 		o = {
 			'from':     CoinAddr(self.proto,d['sender']),
 			# NB: for token, 'to' is token address
-			'to':       CoinAddr(self.proto,d['to']) if d['to'] else Str(''),
+			'to':       CoinAddr(self.proto,d['to']) if d['to'] else None,
 			'amt':      self.proto.coin_amt(d['value'],'wei'),
 			'gasPrice': self.proto.coin_amt(d['gasprice'],'wei'),
 			'startGas': self.proto.coin_amt(d['startgas'],'wei'),

+ 9 - 9
mmgen/proto/eth/tx/unsigned.py

@@ -15,11 +15,11 @@ proto.eth.tx.unsigned: Ethereum unsigned transaction class
 import json
 
 from ....tx import unsigned as TxBase
-from ....util import msg,msg_r
-from ....obj import Str,CoinTxID,ETHNonce,Int,HexStr
-from ....addr import CoinAddr,TokenAddr
+from ....util import msg, msg_r
+from ....obj import CoinTxID, ETHNonce, Int, HexStr
+from ....addr import CoinAddr, TokenAddr
 from ..contract import Token
-from .completed import Completed,TokenCompleted
+from .completed import Completed, TokenCompleted
 
 class Unsigned(Completed,TxBase.Unsigned):
 	desc = 'unsigned transaction'
@@ -29,7 +29,7 @@ class Unsigned(Completed,TxBase.Unsigned):
 		o = {
 			'from':     CoinAddr(self.proto,d['from']),
 			# NB: for token, 'to' is sendto address
-			'to':       CoinAddr(self.proto,d['to']) if d['to'] else Str(''),
+			'to':       CoinAddr(self.proto,d['to']) if d['to'] else None,
 			'amt':      self.proto.coin_amt(d['amt']),
 			'gasPrice': self.proto.coin_amt(d['gasPrice']),
 			'startGas': self.proto.coin_amt(d['startGas']),
@@ -43,16 +43,16 @@ class Unsigned(Completed,TxBase.Unsigned):
 	async def do_sign(self,wif):
 		o = self.txobj
 		o_conv = {
-			'to':       bytes.fromhex(o['to']),
+			'to':       bytes.fromhex(o['to'] or ''),
 			'startgas': o['startGas'].toWei(),
 			'gasprice': o['gasPrice'].toWei(),
 			'value':    o['amt'].toWei() if o['amt'] else 0,
 			'nonce':    o['nonce'],
-			'data':     bytes.fromhex(o['data']) }
+			'data':     bytes.fromhex(o['data'])}
 
 		from ..pyethereum.transactions import Transaction
-		etx = Transaction(**o_conv).sign(wif,o['chainId'])
-		assert etx.sender.hex() == o['from'],(
+		etx = Transaction(**o_conv).sign(wif, o['chainId'])
+		assert etx.sender.hex() == o['from'], (
 			'Sender address recovered from signature does not match true sender')
 
 		from .. import rlp

+ 4 - 4
mmgen/proto/zec/params.py

@@ -45,7 +45,7 @@ class mainnet(mainnet):
 	def get_addr_len(self,addr_fmt):
 		return (20,64)[addr_fmt in ('zcash_z','viewkey')]
 
-	def decode_addr_bytes(self,addr_bytes):
+	def decode_addr_bytes(self, addr_bytes):
 		"""
 		vlen must be set dynamically since Zcash has variable-length version bytes
 		"""
@@ -53,7 +53,7 @@ class mainnet(mainnet):
 			vlen = len(ver_bytes)
 			if addr_bytes[:vlen] == ver_bytes:
 				if len(addr_bytes[vlen:]) == self.get_addr_len(addr_fmt):
-					return decoded_addr( addr_bytes[vlen:], ver_bytes, addr_fmt )
+					return decoded_addr(addr_bytes[vlen:], ver_bytes, addr_fmt)
 
 		return False
 
@@ -63,10 +63,10 @@ class mainnet(mainnet):
 		else:
 			return super().preprocess_key(sec,pubkey_type)
 
-	def pubhash2addr(self,pubhash,p2sh):
+	def pubhash2addr(self,pubhash, addr_type):
 		hash_len = len(pubhash)
 		if hash_len == 20:
-			return super().pubhash2addr(pubhash,p2sh)
+			return super().pubhash2addr(pubhash, addr_type)
 		elif hash_len == 64:
 			raise NotImplementedError('Zcash z-addresses do not support pubhash2addr()')
 		else:

+ 4 - 3
mmgen/protocol.py

@@ -26,7 +26,8 @@ from .cfg import gc
 from .objmethods import MMGenObject
 
 decoded_wif = namedtuple('decoded_wif',['sec','pubkey_type','compressed'])
-decoded_addr = namedtuple('decoded_addr',['bytes','ver_bytes','fmt'])
+decoded_addr = namedtuple('decoded_addr', ['bytes', 'ver_bytes', 'fmt'])
+decoded_addr_multiview = namedtuple('mv_decoded_addr', ['bytes', 'ver_bytes', 'fmt', 'addr', 'views', 'view_pref'])
 parsed_addr = namedtuple('parsed_addr',['ver_bytes','data'])
 
 _finfo = namedtuple('fork_info',['height','hash','name','replayable'])
@@ -149,12 +150,12 @@ class CoinProtocol(MMGenObject):
 		def get_addr_len(self,addr_fmt):
 			return self.addr_len
 
-		def decode_addr_bytes(self,addr_bytes):
+		def decode_addr_bytes(self, addr_bytes):
 			vlen = self.addr_ver_bytes_len
 			return decoded_addr(
 				addr_bytes[vlen:],
 				addr_bytes[:vlen],
-				self.addr_ver_bytes[addr_bytes[:vlen]] )
+				self.addr_ver_bytes[addr_bytes[:vlen]])
 
 		def coin_addr(self,addr):
 			from .addr import CoinAddr

+ 2 - 3
mmgen/term.py

@@ -295,9 +295,8 @@ def init_term(cfg,noecho=False):
 
 	term.init(noecho=noecho)
 
-	from . import term as self
-	for var in ('get_char','get_char_raw','kb_hold_protect','get_terminal_size'):
-		setattr( self, var, getattr(term,var) )
+	for var in ('get_char', 'get_char_raw', 'kb_hold_protect', 'get_terminal_size'):
+		globals()[var] = getattr(term, var)
 
 	term.cfg = cfg # setting the _class_ attribute
 

+ 5 - 7
mmgen/tool/coin.py

@@ -148,7 +148,7 @@ class tool_cmd(tool_cmd_base):
 		"convert a hexadecimal pubkey to a Segwit P2SH-P2WPKH redeem script"
 		assert self.mmtype.name == 'segwit','This command is meaningful only for --type=segwit'
 		from ..proto.btc.common import hash160
-		return self.proto.pubhash2redeem_script( hash160(bytes.fromhex(pubkeyhex)) ).hex()
+		return self.proto.pubhash2redeem_script(hash160(bytes.fromhex(pubkeyhex))).hex()
 
 	def redeem_script2addr(self,redeem_script_hex:'sstr'): # new
 		"convert a Segwit P2SH-P2WPKH redeem script to an address"
@@ -156,19 +156,17 @@ class tool_cmd(tool_cmd_base):
 		assert redeem_script_hex[:4] == '0014', f'{redeem_script_hex!r}: invalid redeem script'
 		assert len(redeem_script_hex) == 44, f'{len(redeem_script_hex)//2} bytes: invalid redeem script length'
 		from ..proto.btc.common import hash160
-		return self.proto.pubhash2addr(
-			hash160( bytes.fromhex(redeem_script_hex) ),
-			p2sh = True )
+		return self.proto.pubhash2addr(hash160(bytes.fromhex(redeem_script_hex)), 'p2sh')
 
 	def pubhash2addr(self,pubhashhex:'sstr'):
 		"convert public key hash to address"
 		pubhash = bytes.fromhex(pubhashhex)
 		if self.mmtype.name == 'segwit':
-			return self.proto.pubhash2segwitaddr( pubhash )
+			return self.proto.pubhash2segwitaddr(pubhash)
 		elif self.mmtype.name == 'bech32':
-			return self.proto.pubhash2bech32addr( pubhash )
+			return self.proto.pubhash2bech32addr(pubhash)
 		else:
-			return self.proto.pubhash2addr( pubhash, self.mmtype.addr_fmt=='p2sh' )
+			return self.proto.pubhash2addr(pubhash, self.mmtype.addr_fmt)
 
 	def addr2pubhash(self,addr:'sstr'):
 		"convert coin address to public key hash"

+ 2 - 2
mmgen/tw/addresses.py

@@ -183,7 +183,7 @@ class TwAddresses(TwView):
 			n = str(n) + ')',
 			m = d.twmmid.fmt( width=cw.mmid, color=color ),
 			u = yes if d.recvd else no,
-			a = d.addr.fmt( color=color, width=cw.addr ),
+			a = d.addr.fmt(self.addr_view_pref, width=cw.addr, color=color),
 			c = d.comment.fmt2( width=cw.comment, color=color, nullrepl='-' ),
 			A = d.amt.fmt( color=color, iwidth=cw.iwidth, prec=self.disp_prec ),
 			d = self.age_disp( d, self.age_fmt )
@@ -194,7 +194,7 @@ class TwAddresses(TwView):
 			n = str(n) + ')',
 			m = d.twmmid.fmt( width=cw.mmid, color=color ),
 			u = yes if d.recvd else no,
-			a = d.addr.fmt( color=color, width=cw.addr ),
+			a = d.addr.fmt(self.addr_view_pref, width=cw.addr, color=color),
 			c = d.comment.fmt2( width=cw.comment, color=color, nullrepl='-' ),
 			A = d.amt.fmt( color=color, iwidth=cw.iwidth, prec=self.disp_prec ),
 			b = self.age_disp( d, 'block' ),

+ 4 - 3
mmgen/tw/ctl.py

@@ -278,9 +278,10 @@ class TwCtl(MMGenObject,metaclass=AsyncInit):
 
 		if await self.set_label(res.coinaddr,lbl):
 			if not silent:
-				desc = '{} address {} in tracking wallet'.format(
-					res.twmmid.type.replace('mmgen','MMGen'),
-					res.twmmid.addr.hl() )
+				desc = '{t} address {a} in tracking wallet'.format(
+					t = res.twmmid.type.replace('mmgen','MMGen'),
+					a = res.twmmid.addr.hl() if res.twmmid.type == 'mmgen' else
+						res.twmmid.addr.hl(res.twmmid.addr.view_pref))
 				msg(
 					'Added label {} to {}'.format(comment.hl2(encl='‘’'),desc) if comment else
 					'Removed label from {}'.format(desc) )

+ 0 - 1
mmgen/tw/shared.py

@@ -31,7 +31,6 @@ class TwMMGenID(HiliteStr,InitErrors,MMGenObject):
 				coin,addr = id_str.split(':',1)
 				assert coin == proto.base_coin.lower(),(
 					f'not a string beginning with the prefix {proto.base_coin.lower()!r}:' )
-				assert addr.isascii() and addr.isalnum(), 'not an ASCII alphanumeric string'
 				ret,sort_key,idtype,disp = (id_str,'z_'+id_str,'non-mmgen','non-MMGen')
 				addr = proto.coin_addr(addr)
 			except Exception as e2:

+ 8 - 6
mmgen/tw/txhistory.py

@@ -62,8 +62,10 @@ class TwTxHistory(TwView):
 		# var cols: inputs outputs comment [txid]
 		if not hasattr(self,'varcol_maxwidths'):
 			self.varcol_maxwidths = {
-				'inputs': max(len(d.vouts_disp('inputs',width=None,color=False)) for d in data),
-				'outputs': max(len(d.vouts_disp('outputs',width=None,color=False)) for d in data),
+				'inputs': max(len(d.vouts_disp(
+					'inputs', width=None, color=False, addr_view_pref=self.addr_view_pref)) for d in data),
+				'outputs': max(len(d.vouts_disp(
+					'outputs', width=None, color=False, addr_view_pref=self.addr_view_pref)) for d in data),
 				'comment': max(len(d.comment) for d in data),
 			}
 
@@ -123,9 +125,9 @@ class TwTxHistory(TwView):
 				n = str(n) + ')',
 				t = d.txid_disp( width=cw.txid, color=color ) if hasattr(cw,'txid') else None,
 				d = d.age_disp( self.age_fmt, width=self.age_w, color=color ),
-				i = d.vouts_disp( 'inputs', width=cw.inputs, color=color ),
+				i = d.vouts_disp('inputs', width=cw.inputs, color=color, addr_view_pref=self.addr_view_pref),
 				A = d.amt_disp(self.show_total_amt).fmt( iwidth=cw.iwidth, prec=self.disp_prec, color=color ),
-				o = d.vouts_disp( 'outputs', width=cw.outputs, color=color ),
+				o = d.vouts_disp('outputs', width=cw.outputs, color=color, addr_view_pref=self.addr_view_pref),
 				c = d.comment.fmt2( width=cw.comment, color=color, nullrepl='-' ) )
 
 	def gen_detail_display(self,data,cw,fs,color,fmt_method):
@@ -153,9 +155,9 @@ class TwTxHistory(TwView):
 				A = d.amt_disp(show_total_amt=True).hl( color=color ),
 				B = d.amt_disp(show_total_amt=False).hl( color=color ),
 				f = d.fee_disp( color=color ),
-				i = d.vouts_list_disp( 'inputs', color=color, indent=' '*8 ),
+				i = d.vouts_list_disp('inputs', color=color, indent=' '*8, addr_view_pref=self.addr_view_pref),
 				N = d.nOutputs,
-				o = d.vouts_list_disp( 'outputs', color=color, indent=' '*8 ),
+				o = d.vouts_list_disp('outputs', color=color, indent=' '*8, addr_view_pref=self.addr_view_pref),
 			)
 
 	sort_disp = {

+ 2 - 2
mmgen/tw/unspent.py

@@ -187,7 +187,7 @@ class TwUnspentOutputs(TwView):
 					else d.txid.truncate( width=cw.txid, color=color )) if cw.txid else None,
 				v = ' ' + d.vout.fmt( width=cw.vout-1, color=color ) if cw.vout else None,
 				a = d.addr.fmtc( '|' + '.'*(cw.addr-1), width=cw.addr, color=color ) if d.skip == 'addr'
-					else d.addr.fmt( width=cw.addr, color=color ),
+					else d.addr.fmt(self.addr_view_pref, width=cw.addr, color=color),
 				m = (d.twmmid.fmtc( '.'*cw.mmid, width=cw.mmid, color=color ) if d.skip == 'addr'
 					else d.twmmid.fmt( width=cw.mmid, color=color )) if cw.mmid else None,
 				c = d.comment.fmt2( width=cw.comment, color=color, nullrepl='-' ) if cw.comment else None,
@@ -203,7 +203,7 @@ class TwUnspentOutputs(TwView):
 				n = str(n+1) + ')',
 				t = d.txid.fmt( width=cw.txid, color=color ) if cw.txid else None,
 				v = ' ' + d.vout.fmt( width=cw.vout-1, color=color ) if cw.vout else None,
-				a = d.addr.fmt( width=cw.addr, color=color ),
+				a = d.addr.fmt(self.addr_view_pref, width=cw.addr, color=color),
 				m = d.twmmid.fmt( width=cw.mmid, color=color ),
 				A = d.amt.fmt( color=color, iwidth=cw.iwidth, prec=self.disp_prec ),
 				B = d.amt2.fmt( color=color, iwidth=cw.iwidth2, prec=self.disp_prec ) if cw.amt2 else None,

+ 15 - 2
mmgen/tw/view.py

@@ -90,12 +90,14 @@ class TwView(MMGenObject,metaclass=AsyncInit):
 	sort_key    = 'age'
 	display_hdr = ()
 	display_body = ()
+	prompt_fs_repl = {}
 	nodata_msg = '[no data for requested parameters]'
 	cols = 0
 	term_height = 0
 	term_width = 0
 	scrollable_height = 0
 	min_scrollable_height = 5
+	addr_view_pref = 0
 	pos = 0
 	filters = ()
 
@@ -121,6 +123,8 @@ class TwView(MMGenObject,metaclass=AsyncInit):
 	age_fmts_date_dependent = ('days','date','date_time')
 	_age_fmt = 'confs'
 
+	bch_addr_fmts = ('cashaddr', 'legacy')
+
 	age_col_params = {
 		'confs':     (7,  'Confs'),
 		'block':     (8,  'Block'),
@@ -194,6 +198,12 @@ class TwView(MMGenObject,metaclass=AsyncInit):
 			from .ctl import TwCtl
 			self.twctl = await TwCtl(cfg,proto,mode='w')
 		self.amt_keys = {'amt':'iwidth','amt2':'iwidth2'} if self.has_amt2 else {'amt':'iwidth'}
+		if repl := self.prompt_fs_repl.get(self.proto.coin):
+			self.prompt_fs_in[repl[0]] = repl[1]
+		self.prompt_fs = '\n'.join(self.prompt_fs_in)
+		if self.proto.coin == 'BCH':
+			self.key_mappings.update({'h': 'd_addr_view_pref'})
+			self.addr_view_pref = 1 if not self.cfg.cashaddr else not self.proto.cashaddr
 
 	@property
 	def age_w(self):
@@ -418,7 +428,7 @@ class TwView(MMGenObject,metaclass=AsyncInit):
 
 				yield from getattr(self,dt.subhdr_fmt_method)(cw,color)
 
-				yield ''
+				yield ' ' * self.term_width
 
 				if data and dt.colhdr_fmt_method:
 					col_hdr = getattr(self,dt.colhdr_fmt_method)(cw,hdr_fs,color)
@@ -580,7 +590,7 @@ class TwView(MMGenObject,metaclass=AsyncInit):
 				msg('')
 				if self.scroll:
 					self.term.set('echo')
-				return self.disp_data
+				return
 			else:
 				if not scroll:
 					msg_r('\ninvalid keypress ')
@@ -826,3 +836,6 @@ class TwView(MMGenObject,metaclass=AsyncInit):
 
 		def d_redraw(self,parent):
 			msg_r(CUR_HOME + ERASE_ALL)
+
+		def d_addr_view_pref(self,parent):
+			parent.addr_view_pref = (parent.addr_view_pref + 1) % len(parent.bch_addr_fmts)

+ 1 - 1
mmgen/tx/base.py

@@ -126,7 +126,7 @@ class Base(MMGenObject):
 	@property
 	def info(self):
 		from .info import init_info
-		return init_info(self)
+		return init_info(self.cfg, self)
 
 	def check_correct_chain(self):
 		if hasattr(self,'rpc'):

+ 12 - 8
mmgen/tx/info.py

@@ -21,10 +21,11 @@ from ..util2 import format_elapsed_hr
 
 class TxInfo:
 
-	def __init__(self,tx):
+	def __init__(self, cfg, tx):
+		self.cfg = cfg
 		self.tx = tx
 
-	def format(self,terse=False,sort='addr'):
+	def format(self, terse=False, sort='addr'):
 
 		tx = self.tx
 
@@ -61,9 +62,6 @@ class TxInfo:
 					_ = decode_timestamp(val)
 					yield f'{label:8} {make_timestr(_)} ({format_elapsed_hr(_)})\n'
 
-			if not terse:
-				yield '\n'
-
 			if tx.chain != 'mainnet': # if mainnet has a coin-specific name, display it
 				yield green(f'Chain: {tx.chain.upper()}') + '\n'
 
@@ -76,7 +74,13 @@ class TxInfo:
 			if tx.comment:
 				yield f'Comment: {tx.comment.hl()}\n{enl}'
 
-			yield self.format_body(blockcount,nonmm_str,max_mmwid,enl,terse=terse,sort=sort)
+			yield self.format_body(
+				blockcount,
+				nonmm_str,
+				max_mmwid,
+				enl,
+				terse = terse,
+				sort  = sort)
 
 			iwidth = len(str(int(tx.sum_inputs())))
 
@@ -123,7 +127,7 @@ class TxInfo:
 				get_char('Press any key to continue: ')
 				msg('')
 
-def init_info(tx):
+def init_info(cfg, tx):
 	return getattr(
 		importlib.import_module(f'mmgen.proto.{tx.proto.base_proto_coin.lower()}.tx.info'),
-		('Token' if tx.proto.tokensym else '') + 'TxInfo' )(tx)
+		('Token' if tx.proto.tokensym else '') + 'TxInfo' )(cfg, tx)

+ 1 - 1
mmgen/tx/new.py

@@ -297,7 +297,7 @@ class New(Base):
 					self.cfg,
 					'{a} {b} {c}\n{d}'.format(
 						a = yellow('Requested change address'),
-						b = (chg.mmid or chg.addr).hl(),
+						b = chg.mmid.hl() if chg.mmid else chg.addr.hl(chg.addr.view_pref),
 						c = yellow('is already used!'),
 						d = yellow('Address reuse harms your privacy and security. Continue anyway? (y/N): ')
 					),

+ 6 - 6
mmgen/xmrwallet.py

@@ -116,7 +116,7 @@ def gen_acct_addr_info(self, wallet_data, account, indent=''):
 			continue
 		yield fs.format(
 			I = addr['address_index'],
-			A = ca.hl() if self.cfg.full_address else ca.fmt(color=True, width=addr_width),
+			A = ca.hl(0) if self.cfg.full_address else ca.fmt(0, color=True, width=addr_width),
 			U = (red('True ') if addr['used'] else green('False')),
 			B = fmt_amt(bal),
 			L = pink(addr['label']))
@@ -277,7 +277,7 @@ class MoneroMMGenTX:
 					f = red('{}:{}'.format(d.source.wallet,d.source.account).ljust(6)),
 					g = red('{}:{}'.format(d.dest.wallet,d.dest.account).ljust(6)) if d.dest else cyan('ext   '),
 					h = d.amount.fmt( color=True, iwidth=4, prec=12 ),
-					j = d.dest_address.fmt(width=addr_w, color=True) if addr_w else d.dest_address.hl(),
+					j = d.dest_address.fmt(0, width=addr_w, color=True) if addr_w else d.dest_address.hl(0),
 					x = '->'
 				)
 
@@ -317,8 +317,8 @@ class MoneroMMGenTX:
 					m = d.amount.hl(),
 					F = (Int(d.priority).hl() + f' [{tx_priorities[d.priority]}]') if d.priority else None,
 					n = d.fee.hl(),
-					o = d.dest_address.hl() if self.cfg.full_address
-						else d.dest_address.fmt(width=addr_width, color=True),
+					o = d.dest_address.hl(0) if self.cfg.full_address
+						else d.dest_address.fmt(0, width=addr_width, color=True),
 					P = pink(pmt_id.hex()) if pmt_id else None,
 					s = make_timestr(d.submit_time) if d.submit_time else None,
 					S = pink(f" [cold signed{', submitted' if d.complete else ''}]") if d.signed_txset else '',
@@ -1079,7 +1079,7 @@ class MoneroWalletOps:
 					ca = CoinAddr(self.proto, e['base_address'])
 					yield fs.format(
 						I = str(e['account_index']),
-						A = ca.hl() if self.cfg.full_address else ca.fmt(color=True, width=addr_width),
+						A = ca.hl(0) if self.cfg.full_address else ca.fmt(0, color=True, width=addr_width),
 						N = red(str(len(addrs_data[i]['addresses'])).ljust(6)),
 						B = fmt_amt(e['unlocked_balance']),
 						L = pink(e['label']))
@@ -1831,7 +1831,7 @@ class MoneroWalletOps:
 			ca = CoinAddr(self.proto, addr['address'])
 			msg('\n  {a} {b}\n  {c} {d}\n  {e} {f}'.format(
 					a = 'Address:       ',
-					b = ca.hl() if self.cfg.full_address else ca.fmt(color=True, width=addr_width),
+					b = ca.hl(0) if self.cfg.full_address else ca.fmt(0, color=True, width=addr_width),
 					c = 'Existing label:',
 					d = pink(addr['label']) if addr['label'] else gray('[none]'),
 					e = 'New label:     ',

+ 1 - 1
setup.cfg

@@ -16,7 +16,7 @@ author       = The MMGen Project
 author_email = mmgen@tuta.io
 url          = https://github.com/mmgen/mmgen-wallet
 license      = GNU GPL v3
-platforms    = Linux, Armbian, Raspbian, MS Windows
+platforms    = Linux, Armbian, Raspbian, MS Windows, MacOS
 keywords     = file: mmgen/data/keywords
 project_urls =
 	Website = https://mmgen-wallet.cc

+ 2 - 3
test/cmdtest.py

@@ -685,12 +685,11 @@ class CmdTestRunner:
 		nws = [(e.split('_')[0],'testnet') if '_' in e else (e,'mainnet') for e in ct_cls.networks]
 		if nws:
 			coin = proto.coin.lower()
-			nw = ('mainnet','testnet')[proto.testnet]
 			for a,b in nws:
-				if a == coin and b == nw:
+				if a == coin and b == proto.network:
 					break
 			else:
-				iqmsg(gray(f'INFO → skipping {m} (network={nw})'))
+				iqmsg(gray(f'INFO → skipping {m} for {proto.coin} {proto.network}'))
 				return None
 
 		if do_clean and not cfg.skipping_deps:

+ 6 - 3
test/cmdtest_py_d/ct_base.py

@@ -52,10 +52,10 @@ class CmdTestBase:
 		self.have_dfl_wallet = False
 		self.usr_rand_chars = (5,30)[bool(cfg.usr_random)]
 		self.usr_rand_arg = f'-r{self.usr_rand_chars}'
-		self.altcoin_pfx = '' if self.proto.base_coin == 'BTC' else '-'+self.proto.base_coin
 		self.tn_ext = ('','.testnet')[self.proto.testnet]
-		d = {'bch':'btc','btc':'btc','ltc':'ltc'}
-		self.fork = d[self.proto.coin.lower()] if self.proto.coin.lower() in d else None
+		self.coin = self.proto.coin.lower()
+		self.fork = 'btc' if self.coin == 'bch' and not cfg.cashaddr else self.coin
+		self.altcoin_pfx = '' if self.fork == 'btc' else f'-{self.proto.coin}'
 		if len(self.tmpdir_nums) == 1:
 			self.tmpdir_num = self.tmpdir_nums[0]
 		if self.tr:
@@ -114,3 +114,6 @@ class CmdTestBase:
 
 	def noop(self):
 		return 'ok'
+
+	def _cashaddr_opt(self, val):
+		return [f'--cashaddr={val}'] if self.proto.coin == 'BCH' else []

+ 8 - 8
test/cmdtest_py_d/ct_main.py

@@ -307,11 +307,11 @@ class CmdTestMain(CmdTestBase,CmdTestShared):
 
 	def __init__(self,trunner,cfgs,spawn):
 		CmdTestBase.__init__(self,trunner,cfgs,spawn)
-		if trunner is None or self.proto.coin.lower() not in self.networks:
+		if trunner is None or self.coin not in self.networks:
 			return
-		if self.proto.coin in ('BTC','BCH','LTC'):
-			self.tx_fee     = {'btc':'0.0001','bch':'0.001','ltc':'0.01'}[self.proto.coin.lower()]
-			self.txbump_fee = {'btc':'123s','bch':'567s','ltc':'12345s'}[self.proto.coin.lower()]
+		if self.coin in ('btc','bch','ltc'):
+			self.tx_fee     = {'btc':'0.0001','bch':'0.001','ltc':'0.01'}[self.coin]
+			self.txbump_fee = {'btc':'123s','bch':'567s','ltc':'12345s'}[self.coin]
 
 		self.unspent_data_file = joinpath('test','trash','unspent.json')
 		self.spawn_env['MMGEN_BOGUS_UNSPENT_DATA'] = self.unspent_data_file
@@ -342,8 +342,8 @@ class CmdTestMain(CmdTestBase,CmdTestShared):
 	def export_seed_dfl_wallet(self,pf,out_fmt='seed'):
 		return self.export_seed(wf=None,out_fmt=out_fmt,pf=pf)
 
-	def addrgen_dfl_wallet(self,pf=None,check_ref=False):
-		return self.addrgen(wf=None,check_ref=check_ref,dfl_wallet=True)
+	def addrgen_dfl_wallet(self, pf):
+		return self.addrgen(wf=None, dfl_wallet=True)
 
 	def txcreate_dfl_wallet(self,addrfile):
 		return self.txcreate_common(sources=['15'])
@@ -475,7 +475,7 @@ class CmdTestMain(CmdTestBase,CmdTestShared):
 		s_beg,s_end = { 'p2pkh':  ('76a914','88ac'),
 						'p2sh':   ('a914','87'),
 						'bech32': (self.proto.witness_vernum_hex + '14','') }[k]
-		amt1,amt2 = {'btc':(10,40),'bch':(10,40),'ltc':(1000,4000)}[self.proto.coin.lower()]
+		amt1,amt2 = {'btc':(10,40),'bch':(10,40),'ltc':(1000,4000)}[self.coin]
 		ret = {
 			self.lbl_id: (
 				f'{self.proto.base_coin.lower()}:{coinaddr}' if non_mmgen
@@ -558,7 +558,7 @@ class CmdTestMain(CmdTestBase,CmdTestShared):
 		)
 
 		# total of two outputs must be < 10 BTC (<1000 LTC)
-		mods = {'btc':(6,4),'bch':(6,4),'ltc':(600,400)}[self.proto.coin.lower()]
+		mods = {'btc':(6,4),'bch':(6,4),'ltc':(600,400)}[self.coin]
 		for k in self.cfgs:
 			self.cfgs[k]['amts'] = [None,None]
 			for idx,mod in enumerate(mods):

+ 32 - 7
test/cmdtest_py_d/ct_misc.py

@@ -62,13 +62,17 @@ class CmdTestMisc(CmdTestBase):
 	tmpdir_nums = [99]
 	passthru_opts = ('daemon_data_dir','rpc_port')
 	cmd_group = (
-		('rpc_backends',     'RPC backends'),
-		('xmrwallet_txview', "'mmgen-xmrwallet' txview"),
-		('xmrwallet_txlist', "'mmgen-xmrwallet' txlist"),
-		('coin_daemon_info', "'examples/coin-daemon-info.py'"),
-		('examples_bip_hd',  "'examples/bip_hd.py'"),
-		('term_echo',        "term.set('echo')"),
-		('term_cleanup',     'term.register_cleanup()'),
+		('rpc_backends',         'RPC backends'),
+		('bch_txview_legacy1',   "'mmgen-tool --coin=bch --cashaddr=0 txview terse=0'"),
+		('bch_txview_legacy2',   "'mmgen-tool --coin=bch --cashaddr=0 txview terse=1'"),
+		('bch_txview_cashaddr1', "'mmgen-tool --coin=bch --cashaddr=1 txview terse=0'"),
+		('bch_txview_cashaddr2', "'mmgen-tool --coin=bch --cashaddr=1 txview terse=1'"),
+		('xmrwallet_txview',     "'mmgen-xmrwallet' txview"),
+		('xmrwallet_txlist',     "'mmgen-xmrwallet' txlist"),
+		('coin_daemon_info',     "'examples/coin-daemon-info.py'"),
+		('examples_bip_hd',      "'examples/bip_hd.py'"),
+		('term_echo',            "term.set('echo')"),
+		('term_cleanup',         'term.register_cleanup()'),
 	)
 	need_daemon = True
 	color = True
@@ -79,6 +83,27 @@ class CmdTestMisc(CmdTestBase):
 			t = self.spawn_chk('mmgen-tool',[f'--rpc-backend={b}','daemon_version'],extra_desc=f'({b})')
 		return t
 
+	def _bch_txview(self, view_pref, terse, expect):
+		if cfg.no_altcoin:
+			return 'skip'
+		tx = 'test/ref/bitcoin_cash/895108-BCH[2.65913].rawtx'
+		t = self.spawn('mmgen-tool', ['--coin=bch', f'--cashaddr={view_pref}', 'txview', tx, f'terse={terse}'])
+		#t = self.spawn('mmgen-tool', ['--coin=bch', '--longhelp'])
+		t.expect(expect)
+		return t
+
+	def bch_txview_legacy1(self):
+		return self._bch_txview(0, 0, '[qzuffa536e0eqfwz3smapckhlw9wge4p5spvx5j7h7]')
+
+	def bch_txview_legacy2(self):
+		return self._bch_txview(0, 1, '[qzuffa536e0eqfwz3smapckhlw9wge4p5spvx5j7h7]')
+
+	def bch_txview_cashaddr1(self):
+		return self._bch_txview(1, 0, '[1HpynST7vkLn8yNtdrqPfeghexZk4sdB3W]')
+
+	def bch_txview_cashaddr2(self):
+		return self._bch_txview(1, 1, '[1HpynST7vkLn8yNtdrqPfeghexZk4sdB3W]')
+
 	def xmrwallet_txview(self,op='txview'):
 		if cfg.no_altcoin:
 			return 'skip'

+ 1 - 1
test/cmdtest_py_d/ct_ref.py

@@ -297,7 +297,7 @@ class CmdTestRef(CmdTestBase,CmdTestShared):
 		return self.ref_passwdfile_chk(key='hex2bip39_12',pat=r'BIP39.*len.* 12\b')
 
 	def ref_tx_chk(self):
-		fn = self.sources['ref_tx_file'][self.proto.coin.lower()][bool(self.tn_ext)]
+		fn = self.sources['ref_tx_file'][self.coin][bool(self.tn_ext)]
 		if not fn:
 			return
 		tf = joinpath(ref_dir,self.ref_subdir,fn)

+ 46 - 27
test/cmdtest_py_d/ct_ref_3seed.py

@@ -214,16 +214,17 @@ class CmdTestRef3Seed(CmdTestBase,CmdTestShared):
 
 class CmdTestRef3Addr(CmdTestRef3Seed):
 	'generated reference address, key and password files for 128-, 192- and 256-bit seeds'
-	networks = ('btc','btc_tn','ltc','ltc_tn')
-	passthru_opts = ('coin','testnet')
-	tmpdir_nums = [26,27,28]
-	shared_deps = ['mmdat',pwfile]
+	networks = ('btc', 'btc_tn', 'ltc', 'ltc_tn', 'bch', 'bch_tn')
+	passthru_opts = ('coin', 'testnet', 'cashaddr')
+	tmpdir_nums = [26, 27, 28]
+	shared_deps = ['mmdat', pwfile]
 
 	chk_data = {
 		'lens': (128, 192, 256),
 		'sids': ('FE3C6545', '1378FC64', '98831F3A'),
 		'refaddrgen_legacy_1': {
 			'btc': ('B230 7526 638F 38CB','A9DC 5A13 12CB 1317'),
+			'bch': ('026D AFE0 8C60 6CFF','B406 4937 D884 6E48'),
 			'ltc': ('2B23 5E97 848A B961','AEC3 E774 0B21 0202'),
 		},
 		'refaddrgen_segwit_1': {
@@ -236,14 +237,17 @@ class CmdTestRef3Addr(CmdTestRef3Seed):
 		},
 		'refaddrgen_compressed_1': {
 			'btc': ('95EB 8CC0 7B3B 7856','16E6 6170 154D 2202'),
+			'bch': ('C560 A343 CEAB 118E','3F56 8DC5 0383 CD78'),
 			'ltc': ('35D5 8ECA 9A42 46C3','15B3 5492 D3D3 6854'),
 		},
 		'refkeyaddrgen_legacy_1': {
 			'btc': ('CF83 32FB 8A8B 08E2','1F67 B73A FF8C 5D15'),
+			'bch': ('6909 4C64 119A 7681','7E48 5071 5E41 D1AE'),
 			'ltc': ('1896 A26C 7F14 2D01','FA0E CD4E ADAF DBF4'),
 		},
 		'refkeyaddrgen_compressed_1': {
 			'btc': ('E43A FA46 5751 720A','FDEE 8E45 1C0A 02AD'),
+			'bch': ('7068 9B37 8ABF 3E31','C688 29A5 BA4C 21B2'),
 			'ltc': ('7603 2FE3 2145 FFAD','3FE0 5A8E 5FBE FF3E'),
 		},
 		'refkeyaddrgen_segwit_1': {
@@ -265,10 +269,12 @@ class CmdTestRef3Addr(CmdTestRef3Seed):
 		'ref_hex2bip39_24_passwdgen_1': '91AF E735 A31D 72A0',
 		'refaddrgen_legacy_2': {
 			'btc': ('8C17 A5FA 0470 6E89','764C 66F9 7502 AAEA'),
+			'bch': ('8117 24B6 3FDA 6B40','E58C A8A4 C371 66AE'),
 			'ltc': ('2B77 A009 D5D0 22AD','51D1 979D 0A35 F24B'),
 		},
 		'refaddrgen_compressed_2': {
 			'btc': ('2615 8401 2E98 7ECA','A386 EE07 A356 906D'),
+			'bch': ('3364 0F9D 8355 2A53','3451 F741 0A8A FA56'),
 			'ltc': ('197C C48C 3C37 AB0F','8DDC 5FE3 BFF9 1226'),
 		},
 		'refaddrgen_segwit_2': {
@@ -281,10 +287,12 @@ class CmdTestRef3Addr(CmdTestRef3Seed):
 		},
 		'refkeyaddrgen_legacy_2': {
 			'btc': ('9648 5132 B98E 3AD9','1BD3 5A36 D51C 256D'),
+			'bch': ('C4D8 7C36 DC77 F8C2','953D 245C 8CFF AC72'),
 			'ltc': ('DBD4 FAB6 7E46 CD07','8822 3FDF FEC0 6A8C'),
 		},
 		'refkeyaddrgen_compressed_2': {
 			'btc': ('6D6D 3D35 04FD B9C3','94BF 4BCF 10B2 394B'),
+			'bch': ('3E7F C369 2AB9 BD58','0C99 14CD 5ADE 6782'),
 			'ltc': ('F5DA 9D60 6798 C4E9','7918 88DE 9096 DD7A'),
 		},
 		'refkeyaddrgen_segwit_2': {
@@ -306,10 +314,12 @@ class CmdTestRef3Addr(CmdTestRef3Seed):
 		'ref_hex2bip39_24_passwdgen_2': '0E8E 23C9 923F 7C2D',
 		'refaddrgen_legacy_3': {
 			'btc': ('6FEF 6FB9 7B13 5D91','424E 4326 CFFE 5F51'),
+			'bch': ('E580 43BB 0F96 AA93','630E 174A 8DDE 1BCE'),
 			'ltc': ('AD52 C3FE 8924 AAF0','4EBE 2E85 E969 1B30'),
 		},
 		'refaddrgen_compressed_3': {
 			'btc': ('A33C 4FDE F515 F5BC','6C48 AA57 2056 C8C8'),
+			'bch': ('E37B AF41 7997 A28C','0D5D 9A58 D6E9 92EE'),
 			'ltc': ('3FC0 8F03 C2D6 BD19','4C0A 49B6 2DD1 1BE0'),
 		},
 		'refaddrgen_segwit_3': {
@@ -322,10 +332,12 @@ class CmdTestRef3Addr(CmdTestRef3Seed):
 		},
 		'refkeyaddrgen_legacy_3': {
 			'btc': ('9F2D D781 1812 8BAD','88CC 5120 9A91 22C2'),
+			'bch': ('A0EE B039 48F4 24AE','B014 E0AB 5F87 EC64'),
 			'ltc': ('B804 978A 8796 3ED4','98B5 AC35 F334 0398'),
 		},
 		'refkeyaddrgen_compressed_3': {
 			'btc': ('420A 8EB5 A9E2 7814','F43A CB4A 81F3 F735'),
+			'bch': ('33E7 5C06 88CF 2792','6E09 FF73 B7C8 00D4'),
 			'ltc': ('8D1C 781F EB7F 44BC','05F3 5C68 FD31 FCEF'),
 		},
 		'refkeyaddrgen_segwit_3': {
@@ -370,39 +382,47 @@ class CmdTestRef3Addr(CmdTestRef3Seed):
 		('ref_hex2bip39_24_passwdgen',([],'new refwallet passwd file chksum (hex-to-BIP39, up to 24 words)')),
 	)
 
-	def call_addrgen(self,mmtype,pfx='addr'):
+	def call_addrgen(self, mmtype, name='addrgen'):
 		wf = self.get_file_with_ext('mmdat')
-		return getattr(self,pfx+'gen')(wf,check_ref=True,mmtype=mmtype)
+		return getattr(self, name)(wf, check_ref=True, mmtype=mmtype)
 
 	def refaddrgen_legacy(self):
 		return self.call_addrgen('legacy')
 	def refaddrgen_compressed(self):
 		return self.call_addrgen('compressed')
 	def refaddrgen_segwit(self):
+		if cfg.coin == 'BCH':
+			return 'skip'
 		return self.call_addrgen('segwit')
 	def refaddrgen_bech32(self):
+		if cfg.coin == 'BCH':
+			return 'skip'
 		return self.call_addrgen('bech32')
 
 	def refkeyaddrgen_legacy(self):
-		return self.call_addrgen('legacy','keyaddr')
+		return self.call_addrgen('legacy', 'keyaddrgen')
 	def refkeyaddrgen_compressed(self):
-		return self.call_addrgen('compressed','keyaddr')
+		return self.call_addrgen('compressed', 'keyaddrgen')
 	def refkeyaddrgen_segwit(self):
-		return self.call_addrgen('segwit','keyaddr')
+		if cfg.coin == 'BCH':
+			return 'skip'
+		return self.call_addrgen('segwit', 'keyaddrgen')
 	def refkeyaddrgen_bech32(self):
-		return self.call_addrgen('bech32','keyaddr')
+		if cfg.coin == 'BCH':
+			return 'skip'
+		return self.call_addrgen('bech32', 'keyaddrgen')
 
-	def pwgen(self,ftype,id_str,pwfmt=None,pwlen=None,extra_args=[],stdout=False):
+	def pwgen(self, ftype, id_str, pwfmt=None, pwlen=None, extra_opts=[], stdout=False):
 		wf = self.get_file_with_ext('mmdat')
-		pwfmt = (['--passwd-fmt='+pwfmt] if pwfmt else [])
-		pwlen = (['--passwd-len='+str(pwlen)] if pwlen else [])
+		pwfmt = ([f'--passwd-fmt={pwfmt}'] if pwfmt else [])
+		pwlen = ([f'--passwd-len={pwlen}'] if pwlen else [])
 		return self.addrgen(
-				wf,
-				check_ref  = True,
-				ftype      = ftype,
-				id_str     = id_str,
-				extra_args = pwfmt + pwlen + extra_args,
-				stdout     = stdout)
+			wf,
+			check_ref  = True,
+			ftype      = ftype,
+			id_str     = id_str,
+			extra_opts = pwfmt + pwlen + extra_opts,
+			stdout     = stdout)
 
 	def refpasswdgen(self):
 		return self.pwgen('pass','alice@crypto.org')
@@ -416,22 +436,21 @@ class CmdTestRef3Addr(CmdTestRef3Seed):
 		return self.pwgen('passhex','фубар@crypto.org','hex',pwlen)
 
 	def ref_hexpasswdgen_half(self):
-		ea = ['--accept-defaults']
-		return self.pwgen('passhex','фубар@crypto.org','hex','h',ea,stdout=True)
+		return self.pwgen('passhex', 'фубар@crypto.org', 'hex', 'h', ['--accept-defaults'], stdout=True)
 
-	def mn_pwgen(self,req_pw_len,pwfmt,ftype='passbip39',stdout=False):
-		pwlen = min(req_pw_len,{'1':12,'2':18,'3':24}[self.test_name[-1]])
+	def mn_pwgen(self, pwlen, pwfmt, ftype='passbip39'):
+		if pwlen > {'1':12, '2':18, '3':24}[self.test_name[-1]]:
+			return 'skip'
 		if pwfmt == 'xmrseed':
 			if cfg.no_altcoin:
 				return 'skip'
 			pwlen += 1
-		ea = ['--accept-defaults']
-		return self.pwgen(ftype,'фубар@crypto.org',pwfmt,pwlen,ea,stdout=stdout)
+		return self.pwgen(ftype, 'фубар@crypto.org', pwfmt, pwlen, ['--accept-defaults'])
 
 	def ref_bip39_12_passwdgen(self):
-		return self.mn_pwgen(12,'bip39',stdout=True)
+		return self.mn_pwgen(12,'bip39')
 	def ref_bip39_18_passwdgen(self):
-		return self.mn_pwgen(18,'bip39',stdout=True)
+		return self.mn_pwgen(18,'bip39')
 	def ref_bip39_24_passwdgen(self):
 		return self.mn_pwgen(24,'bip39')
 	def ref_hex2bip39_24_passwdgen(self):

+ 100 - 36
test/cmdtest_py_d/ct_regtest.py

@@ -24,6 +24,8 @@ import os, json, time, re
 from decimal import Decimal
 
 from mmgen.proto.btc.regtest import MMGenRegtest
+from mmgen.proto.bch.cashaddr import b32a
+from mmgen.proto.btc.common import b58a
 from mmgen.color import yellow
 from mmgen.util import msg_r,die,gmsg,capfirst,fmt_list
 from mmgen.protocol import init_proto
@@ -164,7 +166,7 @@ def make_burn_addr(proto):
 		cfg     = cfg,
 		cmdname = 'pubhash2addr',
 		proto   = proto,
-		mmtype  = 'compressed' ).pubhash2addr('00'*20)
+		mmtype  = 'compressed').pubhash2addr('00'*20)
 
 class CmdTestRegtest(CmdTestBase,CmdTestShared):
 	'transacting and tracking wallet operations via regtest mode'
@@ -394,6 +396,7 @@ class CmdTestRegtest(CmdTestBase,CmdTestShared):
 	'view': (
 		'viewing addresses and unspent outputs',
 		('alice_listaddresses_scroll',    'listaddresses (--scroll, interactive=1)'),
+		('alice_listaddresses_cashaddr',  'listaddresses (BCH cashaddr)'),
 		('alice_listaddresses_empty',     'listaddresses (no data)'),
 		('alice_listaddresses_menu',      'listaddresses (menu items)'),
 		('alice_listaddresses1',          'listaddresses'),
@@ -404,6 +407,7 @@ class CmdTestRegtest(CmdTestBase,CmdTestShared):
 		('alice_twview_days',             'twview (age_fmt=days)'),
 		('alice_twview_date',             'twview (age_fmt=date)'),
 		('alice_twview_date_time',        'twview (age_fmt=date_time)'),
+		('alice_twview_interactive_cashaddr', 'twview (interactive=1, BCH cashaddr)'),
 		('alice_txcreate_info',           'txcreate -i'),
 		('alice_txcreate_info_term',      'txcreate -i (pexpect_spawn)'),
 		('bob_send_to_alice_2addr',       'sending a TX to 2 addresses in Alice’s wallet'),
@@ -471,18 +475,19 @@ class CmdTestRegtest(CmdTestBase,CmdTestShared):
 		if self.proto.testnet:
 			die(2,'--testnet and --regtest options incompatible with regtest test suite')
 
-		self.proto = init_proto( cfg, self.proto.coin, network='regtest', need_amt=True )
-		coin = self.proto.coin.lower()
+		coin = self.coin
+
+		self.proto = init_proto(cfg, coin, network='regtest', need_amt=True)
 
 		gldict = globals()
 		for k in rt_data:
 			gldict[k] = rt_data[k][coin] if coin in rt_data[k] else None
 
-		self.use_bdb_wallet = self.bdb_wallet or self.proto.coin != 'BTC'
+		self.use_bdb_wallet = self.bdb_wallet or coin != 'btc'
 
-		self.rt = MMGenRegtest(cfg, self.proto.coin, bdb_wallet=self.use_bdb_wallet)
+		self.rt = MMGenRegtest(cfg, coin, bdb_wallet=self.use_bdb_wallet)
 
-		if self.proto.coin == 'BTC':
+		if coin == 'btc':
 			self.test_rbf = True # tests are non-coin-dependent, so run just once for BTC
 			if cfg.test_suite_deterministic:
 				self.deterministic = True
@@ -490,7 +495,7 @@ class CmdTestRegtest(CmdTestBase,CmdTestShared):
 		self.spawn_env['MMGEN_BOGUS_SEND'] = ''
 		self.write_to_tmpfile('wallet_password',rt_pw)
 
-		self.dfl_mmtype = 'C' if self.proto.coin == 'BCH' else 'B'
+		self.dfl_mmtype = 'C' if coin == 'bch' else 'B'
 		self.burn_addr = make_burn_addr(self.proto)
 		self.user_sids = {}
 
@@ -553,8 +558,8 @@ class CmdTestRegtest(CmdTestBase,CmdTestShared):
 	def walletgen_alice(self):
 		return self.walletgen('alice')
 
-	def _user_dir(self,user,coin=None):
-		return joinpath(self.tr.data_dir,'regtest',coin or self.proto.coin.lower(),user)
+	def _user_dir(self, user, coin=None):
+		return joinpath(self.tr.data_dir, 'regtest', coin or self.coin, user)
 
 	def _user_sid(self,user):
 		if user in self.user_sids:
@@ -718,31 +723,39 @@ class CmdTestRegtest(CmdTestBase,CmdTestShared):
 	def fund_alice(self):
 		return self.fund_wallet('alice',('L','S')[self.proto.cap('segwit')],rtFundAmt)
 
-	def user_twview(self, user, chk=None, expect=None, cmdline=['twview'], sort='age', exit_val=None):
-		t = self.spawn('mmgen-tool',[f'--{user}'] + cmdline + ['sort='+sort], exit_val=exit_val)
+	def user_twview(
+			self,
+			user,
+			chk      = None,
+			expect   = None,
+			cmd      = 'twview',
+			opts     = [],
+			sort     = 'age',
+			exit_val = None):
+		t = self.spawn('mmgen-tool', [f'--{user}'] + opts + [cmd] + [f'sort={sort}'], exit_val=exit_val)
 		if chk:
 			t.expect(r'{}\b.*\D{}\b'.format(*chk),regex=True)
 		if expect:
-			t.expect(expect)
+			t.expect(expect, regex=True)
 		return t
 
 	def bob_twview_noaddrs(self):
 		return self.user_twview('bob', expect='No spendable', exit_val=1)
 
 	def bob_listaddrs_noaddrs(self):
-		return self.user_twview('bob', cmdline=['listaddresses'], expect='No addresses', exit_val=1)
+		return self.user_twview('bob', cmd='listaddresses', expect='No addresses', exit_val=1)
 
 	def bob_twview_nobal(self):
 		return self.user_twview('bob', expect='No spendable', exit_val=1)
 
 	def bob_listaddrs_nobal(self):
-		return self.user_twview('bob', cmdline=['listaddresses'], expect='TOTAL:')
+		return self.user_twview('bob', cmd='listaddresses', expect='TOTAL:')
 
 	def bob_twview1(self):
-		return self.user_twview('bob', chk = ('1',rtAmts[0]) )
+		return self.user_twview('bob', chk=('1', rtAmts[0]))
 
-	def user_bal(self,user,bal,args=['showempty=1'],skip_check=False):
-		t = self.spawn('mmgen-tool',['--'+user,'listaddresses'] + args)
+	def user_bal(self, user, bal, opts=[], args=['showempty=1'], skip_check=False):
+		t = self.spawn('mmgen-tool', opts + [f'--{user}', 'listaddresses'] + args)
 		if not skip_check:
 			cmp_or_die(f'{bal} {self.proto.coin}',strip_ansi_escapes(t.expect_getend('TOTAL: ')))
 		return t
@@ -754,10 +767,10 @@ class CmdTestRegtest(CmdTestBase,CmdTestShared):
 		return self.user_bal('alice',rtBals[8])
 
 	def bob_bal1(self):
-		return self.user_bal('bob',rtFundAmt)
+		return self.user_bal('bob', rtFundAmt, self._cashaddr_opt(0))
 
 	def bob_bal2(self):
-		return self.user_bal('bob',rtBals[0])
+		return self.user_bal('bob', rtBals[0], self._cashaddr_opt(1))
 
 	def bob_bal2a(self):
 		return self.user_bal('bob',rtBals[0],args=['showempty=1','age_fmt=confs'])
@@ -818,11 +831,21 @@ class CmdTestRegtest(CmdTestBase,CmdTestShared):
 
 	def bob_twview2(self):
 		sid1 = self._get_user_subsid('bob','29L')
-		return self.user_twview('bob',chk=(sid1+':C:2','0.29'),sort='twmmid')
+		return self.user_twview(
+			'bob',
+			opts = self._cashaddr_opt(0),
+			chk  = (f'{sid1}:C:2', '0.29'),
+			sort = 'twmmid',
+			expect = rf'[{b58a}]{{8}}' if self.proto.coin == 'BCH' else None)
 
 	def bob_twview3(self):
 		sid2 = self._get_user_subsid('bob','127S')
-		return self.user_twview('bob',chk=(sid2+':C:3','0.127'),sort='amt')
+		return self.user_twview(
+			'bob',
+			opts = self._cashaddr_opt(1),
+			chk  = (f'{sid2}:C:3', '0.127'),
+			sort = 'amt',
+			expect = rf'[{b32a}]{{8}}' if self.proto.coin == 'BCH' else None)
 
 	def bob_subwallet_txcreate(self):
 		sid1 = self._get_user_subsid('bob','29L')
@@ -853,21 +876,28 @@ class CmdTestRegtest(CmdTestBase,CmdTestShared):
 		sid = self._user_sid('bob')
 		return self.user_twview('bob',chk=(sid+':L:5',rtBals[9]),sort='twmmid')
 
-	def user_txhist(self,user,args,expect):
-		t = self.spawn('mmgen-tool',['--'+user,'txhist'] + args)
-		m = re.search( expect, t.read(strip_color=True), re.DOTALL )
-		assert m, f'Expected: {expect}'
+	def user_txhist(self, user, args, expect, opts=[], expect2=None):
+		t = self.spawn('mmgen-tool', opts + [f'--{user}', 'txhist'] + args)
+		text = t.read(strip_color=True)
+		for s in (expect, expect2):
+			if s:
+				m = re.search(s, text, re.DOTALL)
+				assert m, f'Expected: {s}'
 		return t
 
 	def bob_txhist1(self):
 		return self.user_txhist('bob',
+			opts = self._cashaddr_opt(1),
 			args = ['sort=age'],
-			expect = fr'\s1\).*\s{rtFundAmt}\s' )
+			expect = fr'\s1\).*\s{rtFundAmt}\s',
+			expect2 = rf'[{b32a}]{{8}}' if self.proto.coin == 'BCH' else None)
 
 	def bob_txhist2(self):
 		return self.user_txhist('bob',
+			opts = self._cashaddr_opt(0),
 			args = ['sort=blockheight','reverse=1','age_fmt=block'],
-			expect = fr'\s1\).*:{self.dfl_mmtype}:1\s' )
+			expect = fr'\s1\).*:{self.dfl_mmtype}:1\s',
+			expect2 = rf'[{b58a}]{{8}}' if self.proto.coin == 'BCH' else None)
 
 	def bob_txhist3(self):
 		return self.user_txhist('bob',
@@ -891,6 +921,13 @@ class CmdTestRegtest(CmdTestBase,CmdTestShared):
 		self.get_file_with_ext('out',delete_all=True)
 		t = self.spawn('mmgen-tool',
 			['--bob',f'--outdir={self.tmpdir}','txhist','age_fmt=date_time','interactive=true'] )
+		if self.proto.coin == 'BCH':
+			for expect, resp in (
+					(rf'[{b32a}]{{8}}', 'h'),
+					(rf'[{b58a}]{{8}}', 'h')
+				):
+				t.expect(expect, regex=True)
+				t.expect('draw:\b', resp, regex=True)
 		for resp in ('u','i','t','a','m','T','A','r','r','D','D','D','D','p','P','n','V'):
 			t.expect('draw:\b',resp,regex=True)
 		if t.pexpect_spawn:
@@ -1476,6 +1513,8 @@ class CmdTestRegtest(CmdTestBase,CmdTestShared):
 
 	async def carol_delete_wallet(self):
 		imsg('Unloading Carol’s tracking wallet')
+		if self.proto.coin == 'BCH':
+			time.sleep(0.2)
 		t = self.spawn('mmgen-regtest',['cli','unloadwallet','carol'])
 		t.ok()
 		wdir = joinpath((await self.rt.rpc).daemon.network_datadir, 'wallets', 'carol')
@@ -1577,7 +1616,7 @@ class CmdTestRegtest(CmdTestBase,CmdTestShared):
 
 	def alice_add_comment_badaddr2(self):
 		# mainnet zero address:
-		addr = init_proto( cfg, self.proto.coin, network='mainnet' ).pubhash2addr(bytes(20),False)
+		addr = init_proto(cfg, self.proto.coin, network='mainnet').pubhash2addr(bytes(20), 'p2pkh')
 		return self.alice_add_comment_badaddr( addr, 'invalid address', 2 )
 
 	def alice_add_comment_badaddr3(self):
@@ -1585,7 +1624,7 @@ class CmdTestRegtest(CmdTestBase,CmdTestShared):
 		return self.alice_add_comment_badaddr( addr, f'MMGen address {addr!r} not found in tracking wallet', 2 )
 
 	def alice_add_comment_badaddr4(self):
-		addr = self.proto.pubhash2addr(bytes(20),False) # regtest (testnet) zero address
+		addr = self.proto.pubhash2addr(bytes(20), 'p2pkh') # regtest (testnet) zero address
 		return self.alice_add_comment_badaddr( addr, f'Coin address {addr!r} not found in tracking wallet', 2 )
 
 	def alice_remove_comment1(self):
@@ -1635,28 +1674,42 @@ class CmdTestRegtest(CmdTestBase,CmdTestShared):
 				'interactive=1',
 			]
 		)
-		t.expect('abel:\b','p')
-		ret = t.expect([ 'abel:\b', 'to confirm: ' ])
+		prompt = 'abel:\b'
+		t.expect(prompt, 'p')
+		ret = t.expect([prompt, 'to confirm: '])
 		if ret == 1:
 			t.send('YES\n')
-			t.expect('abel:\b')
+			t.expect(prompt)
 		t.send('l')
 		t.expect(
 			'main menu): ',
 			'{}\n'.format(2 if self.proto.coin == 'BCH' else 1) )
 		t.expect('for address.*: ','\n',regex=True)
 		t.expect('unchanged')
-		t.expect('abel:\b','q')
+		t.expect(prompt, 'q')
 		return t
 
 	def _alice_listaddresses_interactive(self,expect=(),expect_menu=()):
 		t = self.spawn('mmgen-tool',['--alice','listaddresses','interactive=1'])
+		prompt = 'abel:\b'
+		for e in expect:
+			t.expect(*e, regex=True)
 		for s in expect_menu:
-			t.expect('abel:\b',s)
-		for p,s in expect:
-			t.expect(p,s)
+			t.expect(prompt, s)
 		return t
 
+	def alice_listaddresses_cashaddr(self):
+		if self.proto.coin != 'BCH':
+			return 'skip'
+		prompt = 'abel:\b'
+		expect = (
+			[rf'[{b32a}]{{8}}'],
+			[prompt, 'h'],
+			[rf'[{b58a}]{{8}}'],
+			[prompt, 'q']
+		)
+		return self._alice_listaddresses_interactive(expect=expect)
+
 	def alice_listaddresses_empty(self):
 		return self._alice_listaddresses_interactive(expect_menu='uuEq')
 
@@ -1715,6 +1768,17 @@ class CmdTestRegtest(CmdTestBase,CmdTestShared):
 			args = ['age_fmt=date_time'],
 			expect = (rtAmts[0],pat_date_time) )
 
+	def alice_twview_interactive_cashaddr(self):
+		if self.proto.coin != 'BCH':
+			return 'skip'
+		t = self.spawn('mmgen-tool', ['--alice', 'twview', 'interactive=true'])
+		prompt = 'abel:\b'
+		t.expect(rf'[{b32a}]{{8}}', regex=True)
+		t.expect(prompt, 'h')
+		t.expect(rf'[{b58a}]{{8}}', regex=True)
+		t.expect(prompt, 'q')
+		return t
+
 	def alice_txcreate_info(self,pexpect_spawn=False):
 		t = self.spawn('mmgen-txcreate',['--alice','-Bi'],pexpect_spawn=pexpect_spawn)
 		pats = (

+ 32 - 22
test/cmdtest_py_d/ct_shared.py

@@ -22,8 +22,10 @@ test.cmdtest_py_d.ct_shared: Shared methods for the cmdtest.py test suite
 
 from mmgen.util import get_extension
 from mmgen.wallet import get_wallet_cls
+from mmgen.addrlist import AddrList
+from mmgen.passwdlist import PasswordList
 
-from ..include.common import cfg,cmp_or_die,strip_ansi_escapes,joinpath
+from ..include.common import cfg, cmp_or_die, strip_ansi_escapes, joinpath, silence, end_silence
 from .common import ref_bw_file,ref_bw_hash_preset,ref_dir
 
 class CmdTestShared:
@@ -238,9 +240,9 @@ class CmdTestShared:
 		if wcls.enc and wcls.type != 'brain':
 			t.passphrase(wcls.desc,self.wpasswd)
 			t.expect(['Passphrase is OK', 'Passphrase.* are correct'],regex=True)
-		chk = t.expect_getend(f'Valid {wcls.desc} for Seed ID ')[:8]
+		chksum = t.expect_getend(f'Valid {wcls.desc} for Seed ID ')[:8]
 		if sid:
-			cmp_or_die(chk,sid)
+			cmp_or_die(chksum, sid)
 		return t
 
 	def addrgen(
@@ -249,53 +251,61 @@ class CmdTestShared:
 			check_ref  = False,
 			ftype      = 'addr',
 			id_str     = None,
-			extra_args = [],
+			extra_opts = [],
 			mmtype     = None,
 			stdout     = False,
 			dfl_wallet = False):
-		passgen = ftype[:4] == 'pass'
+		list_type = ftype[:4]
+		passgen = list_type == 'pass'
 		if not mmtype and not passgen:
 			mmtype = self.segwit_mmtype
-		cmd_pfx = (ftype,'pass')[passgen]
 		t = self.spawn(
-				f'mmgen-{cmd_pfx}gen',
-				['-d',self.tmpdir] + extra_args +
+				f'mmgen-{list_type}gen',
+				['-d',self.tmpdir] + extra_opts +
 				([],['--type='+str(mmtype)])[bool(mmtype)] +
 				([],['--stdout'])[stdout] +
 				([],[wf])[bool(wf)] +
 				([],[id_str])[bool(id_str)] +
-				[getattr(self,f'{cmd_pfx}_idx_list')],
+				[getattr(self,f'{list_type}_idx_list')],
 				extra_desc=f'({mmtype})' if mmtype in ('segwit','bech32') else '')
 		t.license()
 		wcls = get_wallet_cls( ext = 'mmdat' if dfl_wallet else get_extension(wf) )
 		t.passphrase(wcls.desc,self.wpasswd)
 		t.expect('Passphrase is OK')
 		desc = ('address','password')[passgen]
-		chk = t.expect_getend(rf'Checksum for {desc} data .*?: ',regex=True)
-		if passgen:
-			t.expect('Encrypt password list? (y/N): ','N')
-		t.read() if stdout else t.written_to_file(('Addresses','Password list')[passgen])
+		chksum = strip_ansi_escapes(t.expect_getend(rf'Checksum for {desc} data .*?: ', regex=True))
 		if check_ref:
-			chk_ref = (
+			chksum_chk = (
 				self.chk_data[self.test_name] if passgen else
 				self.chk_data[self.test_name][self.fork][self.proto.testnet])
-			cmp_or_die(chk,chk_ref,desc=f'{ftype}list data checksum')
+			cmp_or_die(chksum, chksum_chk, desc=f'{ftype}list data checksum')
+		if passgen:
+			t.expect('Encrypt password list? (y/N): ','N')
+		if stdout:
+			t.read()
+		else:
+			fn = t.written_to_file('Password list' if passgen else 'Addresses')
+			cls = PasswordList if passgen else AddrList
+			silence()
+			al = cls(cfg, self.proto, fn, skip_chksum_msg=True) # read back the file we’ve written
+			end_silence()
+			cmp_or_die(al.chksum, chksum, desc=f'{ftype}list data checksum from file')
 		return t
 
-	def keyaddrgen(self,wf,check_ref=False,mmtype=None):
+	def keyaddrgen(self, wf, check_ref=False, extra_opts=[], mmtype=None):
 		if not mmtype:
 			mmtype = self.segwit_mmtype
-		args = ['-d',self.tmpdir,self.usr_rand_arg,wf,self.addr_idx_list]
+		args = ['-d', self.tmpdir, self.usr_rand_arg, wf, self.addr_idx_list]
 		t = self.spawn('mmgen-keygen',
-				([],['--type='+str(mmtype)])[bool(mmtype)] + args,
-				extra_desc=f'({mmtype})' if mmtype in ('segwit','bech32') else '')
+				([f'--type={mmtype}'] if mmtype else []) + extra_opts + args,
+				extra_desc = f'({mmtype})' if mmtype in ('segwit', 'bech32') else '')
 		t.license()
 		wcls = get_wallet_cls(ext=get_extension(wf))
 		t.passphrase(wcls.desc,self.wpasswd)
-		chk = t.expect_getend(r'Checksum for key-address data .*?: ',regex=True)
+		chksum = t.expect_getend(r'Checksum for key-address data .*?: ',regex=True)
 		if check_ref:
-			chk_ref = self.chk_data[self.test_name][self.fork][self.proto.testnet]
-			cmp_or_die(chk,chk_ref,desc='key-address list data checksum')
+			chksum_chk = self.chk_data[self.test_name][self.fork][self.proto.testnet]
+			cmp_or_die(chksum, chksum_chk, desc='key-address list data checksum')
 		t.expect('Encrypt key list? (y/N): ','y')
 		t.usr_rand(self.usr_rand_chars)
 		t.hash_preset('new key-address list','1')

+ 3 - 2
test/gentest.py

@@ -294,6 +294,7 @@ def do_ab_test(proto,scfg,addr_type,gen1,kg2,ag,tool,cache_data):
 		sec = PrivKey(proto,in_bytes,compressed=addr_type.compressed,pubkey_type=addr_type.pubkey_type)
 		data = kg1.gen_data(sec)
 		addr1 = ag.to_addr(data)
+		view_pref = 1 if proto.coin == 'BCH' else 0
 		tinfo = ( in_bytes, sec, sec.wif, type(kg1).__name__, type(kg2).__name__ if kg2 else tool.desc )
 
 		def do_msg():
@@ -304,14 +305,14 @@ def do_ab_test(proto,scfg,addr_type,gen1,kg2,ag,tool,cache_data):
 			def run_tool():
 				o = tool.run_tool(sec,cache_data)
 				test_equal( 'WIF keys', sec.wif, o.wif, *tinfo )
-				test_equal( 'addresses', addr1, o.addr, *tinfo )
+				test_equal('addresses', addr1.views[view_pref], o.addr, *tinfo)
 				if o.viewkey:
 					test_equal( 'view keys', ag.to_viewkey(data), o.viewkey, *tinfo )
 				return o.viewkey
 			vk2 = run_tool()
 			do_msg()
 		else:
-			test_equal( 'addresses', addr1, ag.to_addr(kg2.gen_data(sec)), *tinfo )
+			test_equal('addresses', addr1.views[view_pref], ag.to_addr(kg2.gen_data(sec)), *tinfo)
 			vk2 = None
 			do_msg()
 

+ 17 - 0
test/objtest_py_d/ot_eth_mainnet.py

@@ -11,8 +11,25 @@ from decimal import Decimal
 
 from mmgen.obj import ETHNonce
 from mmgen.amt import ETHAmt
+from mmgen.protocol import init_proto
+from mmgen.addr import CoinAddr
+
+from ..include.common import cfg
+
+proto = init_proto(cfg, 'eth', need_amt=True)
 
 tests = {
+	'CoinAddr': {
+		'arg1': 'addr',
+		'good':  (
+			{'addr':'beadcafe' * 5, 'proto':proto},
+		),
+		'bad':  (
+			{'addr':'aaaaxxxx' * 5, 'proto':proto},
+			{'addr':'beadcafe' * 2, 'proto':proto},
+			{'addr':'beadcafe' * 6, 'proto':proto},
+		),
+	},
 	'ETHAmt': {
 		'bad':  ('-3.2','0.1234567891234567891',123,'123L',
 					{'num':'1','from_decimal':True},

+ 5 - 0
test/ref/bitcoin_cash/895108-BCH[2.65913].rawtx

@@ -0,0 +1,5 @@
+0bd373
+BCH MAINNET 895108 2.65913 20240926_071550 1000000
+0200000002eb4f3508ac2ca1ec5c2851274214aee247dfc60ae3c739bae926216f13b8ed4e0000000000ffffffffd751a1d02474628bffca80020a4f430a5ab97e52c8eb0327255bce8f406844610700000000ffffffff030859de01000000001976a9140d5ea98cdb1d5c41bbb73750c579352b63cb8b6e88aca029fb0d000000001976a914f956b8114948fb7d8b651fc23fef66e0087412f388ac63b07167010000001976a914295e2be7fb7ae4a00d8e0ed267da0e00f14eb00588ac00000000
+[{'vout': 0, 'txid': '4eedb8136f2126e9ba39c7e30ac6df47e2ae14422751285ceca12cac08354feb', 'scriptPubKey': '76a914b894f691d65f9025c28c37d0e2d7fb8ae466a1a488ac', 'comment': 'Alice\u2019s allowance', 'amt': '37.52425504', 'addr': 'bitcoincash:qzuffa536e0eqfwz3smapckhlw9wge4p5spvx5j7h7', 'confs': 574214, 'mmid': 'EB5572C5:L:1', 'sequence': 4294967295}, {'vout': 7, 'txid': '614468408fce5b252703ebc8527eb95a0a434f0a0280caff8b627424d0a151d7', 'scriptPubKey': '76a91460e025db040aaa5ed9a7a8051cb9e82f58df489588ac', 'comment': '', 'amt': '25.44058763', 'addr': 'bitcoincash:qpswqfwmqs925hke575q289eaqh43h6gj54ysy3xae', 'confs': 676627, 'sequence': 4294967295}]
+[{'addr': 'bitcoincash:qqx4a2vvmvw4csdmkum4p3tex54k8jutdctzd50x4u', 'amt': '0.31349', 'is_chg': False}, {'addr': 'bitcoincash:qru4dwq3f9y0klvtv50uy0l0vmsqsaqj7vugke5esy', 'amt': '2.34564', 'is_chg': False, 'mmid': 'EB5572C5:L:7'}, {'addr': 'bitcoincash:qq54u2l8ldawfgqd3c8dye76pcq0zn4sq5letrxcdf', 'amt': '60.30471267', 'is_chg': True, 'mmid': 'EB5572C5:L:8'}]

+ 57 - 0
test/ref/bitcoin_cash/bchwallet-testnet.dump

@@ -0,0 +1,57 @@
+# Wallet dump created by Bitcoin Cash Node v27.1.0-9f9aa5a6e
+# * Created on 2024-09-28T09:13:38Z
+# * Best block at time of backup was 0 (000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943),
+#   mined on 2011-02-02T23:16:42Z
+
+# extended private masterkey: tprv8ZgxMBicQKsPexYGMbCXgorSb94LSNHruGV8TcYGnvvAmfYhMsFgeLQNcZNGjpFNGZECug2pNiGx9CxgPfmm1MBai3ChRco9zvMhGgjCrbc
+
+cVp8wiSLfBHmFTM8ANuNQaGSYdaeGzQPTjzy35cctkD5NYpWVedV 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qqtw9k8rrz5yuuu8mpxqrrlt05nslxp3qqsswccy0t hdkeypath=m/0'/0'/636'
+cRLvPdrsqR3izskvzcz4cS8pGzLTsnMDA6Lo6vPepYhp8vEc7NnD 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qq57qrhm3r3w0cjjtr865l7qq47ed66gqqtz3dcqh2 hdkeypath=m/0'/0'/668'
+cNUKQrbW6y9usj4rn8cX1LTP4f3rDmLcouuQcex1d8UykdyNvGqN 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qp2f2fvxccmpdnkzc3h252d3gupzj9y9qqw7qa6wus hdkeypath=m/0'/0'/650'
+cRvhFvDo4DQ6xeFDyG8N6kfgff3FS2sJL2yRCYWeG9rFHHhBMqD5 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qp74u03veyxpwr0fr6mn5vamvr5gd8ufqqttu5hvz9 hdkeypath=m/0'/1'/438'
+cUxwL9tKy83GX5zUHtGbsfZK23Y4CQ6hKdqbCiMkLJpNMvRyboV2 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qrrdu293cnrdy0eyfc29pugulk339ddpqqnz5h86m4 hdkeypath=m/0'/0'/572'
+cUxUVnJ1CzoTCA4nsC1zKWJHwYYhNCGghc3iaLecnS5Yez57DTKV 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qzju897ltu2gwuy029grdesgp64dwv49qq7g4u4jrp hdkeypath=m/0'/1'/650'
+cNeXwV6a2XnvkmhxRJFjTBrZuFrmR8geii1AWzK8TyPxMd9R5yHX 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qql35gw4lcvjn7y0rtajtjvar566rpwzqqwg9v8wz6 hdkeypath=m/0'/1'/26'
+cNY9Aw3KUXbu2fVoNHJKEeyae2dcu6wkrehuUY5FtDPATaoyGqqy 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qqr52pjym9zx3q660ya989shtrs3dlk5qqjskuc2cz hdkeypath=m/0'/0'/245'
+cS8qYyAg8QGRGn1HPTpaT5yogQdhxy7EG2tJvhrNRVF4SSpevwcp 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qzycyx62gt2r9aa40zqry38mzcdplfkeqq79tkmya8 hdkeypath=m/0'/0'/59'
+cTdMWafCwAd9o5UDNQNwXfVRcAvybo98NguKyxDVCDRLrDRax3TL 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qz4rxgqy0duzf5kxhd7te53edqca9qklqqtf0jv8dc hdkeypath=m/0'/1'/575'
+cPtJLThtyGbj8QnmiuXiQhX6A4Mdb8DPyRYMYrQ4cLnKeRxXLdF6 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qzpwh9dh6j6g0juc50cl9dslcgmcs4hmqq9djlmk5j hdkeypath=m/0'/1'/425'
+cRzHF8okeRPFVMSewpAjWqfiWgjkjFxZqAEVPjiFt5ZCdyZUr3C7 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qp97umelqwpukmtaf72y083dy54lqmqdqy7yjcvjmj hdkeypath=m/0'/1'/559'
+cS99oNTaQB5RBpHDSHJEenyjmJhF9KyWDtq96J91wKHkBmbU8ANq 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qpc5qjz6lt0v5cjeg5z9h0u65sd3lpe9qydu3d30uf hdkeypath=m/0'/1'/765'
+cTkaVbTNPfYEZvVQum7vf7RWYy4hwqrg4BD1cnk2TE8fSe5LDiex 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qqqdmv4u2l8g3vttakq8a0vdpgw0v2fsqyutg60vpd hdkeypath=m/0'/1'/29'
+cNZqJhwScX3TrAF4pMcZnzku9QoLmfbamuxCVMVmZNJkubRyv4ht 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qz7vmyvj805g7ahnmrz623qg9t49jh6mqy8xhz29az hdkeypath=m/0'/1'/491'
+cV5FaqsEYyYWokVNRMGgoweUneU8AJhVJ8F8PrSc5XsL3g8fUT4A 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qz0u3kgqq5xrkj8zch8v28jypdvtaur5qyjfv33a4r hdkeypath=m/0'/1'/190'
+cUyaVudNPss23dKjJUfs4cqKDnvh1taigAhtHDRXone75VWmMbMN 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qp9mkm0u9jcuf8yzmntk497d8vccfw5hqy40lxngtk hdkeypath=m/0'/0'/978'
+cNLhJ4nomtwsvDKoFXGDondDZJFgfF2e8XTxDPERs9zP9NPqUDKg 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qpx96famcmfr7g7370qufkh58ffmwp4nqylaj6tsw2 hdkeypath=m/0'/0'/564'
+cR3JBZBk7RtbEwt3VMKqQimWZXKyhuidU5pwpLoY2GGNLphSnrsP 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qp5recsz4w0lsqxkudzjwe4syhqqpr8xqyx06hqhdt hdkeypath=m/0'/1'/329'
+cRkrg9gFU8e51RBW8ZXZYoEoktWRYLV3RZFL5aEdpfk6wkparAHD 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qqk48mrkq8hjzysdz2y53vs6u4ulkt8aqy7he5yr9x hdkeypath=m/0'/0'/569'
+cMzNpPeeXtYTVswwULSF6AAoa2nUBHb6myufP7AF4hoovMU4RAZh 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qr5fceuvff9khx5304aqkvxhzwmemngpqgcykgc7ts hdkeypath=m/0'/1'/495'
+cSJ4aztJyTfzsR2yRhQXBtYm6hoANLQHmV1fRodnepWaNps7N5Fy 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qzveklahtds9u4p0pt04f3vz4gznsccdqgqd9zn7gy hdkeypath=m/0'/1'/583'
+cTrDo25RNhpGw4j7Kn8oTVfYioJ7kVKPt52Vu8mbWQA7LZfQ9uTY 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qrd4ctzd9lfqksyxyh4rk3ydvs9ncfs4qgln04njqf hdkeypath=m/0'/0'/985'
+cRxbuanYahFoZxykkFJPCYpHLkhzGznmcDiE3pjJAbaALDy7t4iX 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qzg4jh3lakadpm0vmenms59javvevcfmqgtprztuje hdkeypath=m/0'/1'/48'
+cQcnMRahdsvAEUZo6GGMhCi3KJdhZiTE7PLjvoZFSWUHDZSZVpHz 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qzxmlct4rjemuw7l3mecc7lehkt8hxzjqg0ry8h6z8 hdkeypath=m/0'/1'/635'
+cV9hcAoazBJTRuDoBhDfMgrb5b484g2UechpaCCbbLhEvxktGVit 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qq95ljjemaf528krs6xgja9zpzd6656eqgg6f7atfa hdkeypath=m/0'/1'/387'
+cNc5Y7fwUFumaWxnq63FGACVK2Uy5aUCMCeqzEGEzLFBaU4oqLxW 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qrhqfqsetnxmc9ajwsfjwluefqy6uytsqgahzal7wx hdkeypath=m/0'/0'/353'
+cTmGh4sv7gDJUDe8PBsZQ77KoKpEZrpz9MTM6mxeTgSC5ikYbLNK 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qqeqttck033lrgac0smvnq44cryfqw5fqgfws59ujw hdkeypath=m/0'/0'/384'
+cUNh8FEFYuJYEb2dfRst1SwMPonbBbUxy5QGP2ednbxm9KahzY67 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qrspfwz5mwlgc55vxmjls2xy5yd3tkvwqga5xut3sc hdkeypath=m/0'/0'/493'
+cVQeD6M16HKPsR5BZdiKHk576cxoT1emU43w7R8XDQkeCi2oGg5y 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qzeq0wr07r75mkygukase2ye7dxg42asqgwhh8uhpa hdkeypath=m/0'/1'/442'
+cVCah2gzytEK4iBXVJLeTNHmvmGzLNqFLrR5dR1XtstMsCgwyCgC 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qz8cavad8ujsj5p8mnuycg6efcsupxlpqg0wd35j2e hdkeypath=m/0'/1'/687'
+cUFejVszCwodxV3B3ppB9CSbBc6yJ9e9RjGR4aK6Z5LPXnbdCEv2 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qrm9hrvw2stpnlkl0f6rje75ltv4ftpzqvvgawxhcd hdkeypath=m/0'/1'/469'
+cQEYTwzo22pEwE7T1zWATsL4eZ7gYYanQR3XxdLdo8RGpqd1KSeZ 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qqtgazx0dcyypeaxy50x00fve5y3zkeeqvrl76ku5k hdkeypath=m/0'/1'/910'
+cRoFNNQYRm5V72cokeAjoWUYuCjieZTjTvd6743m8cmYXazSp22m 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qzenr7dmrt68tcjm7daawn6srkcy76jtqvn92m6de2 hdkeypath=m/0'/1'/287'
+cSK2tKxkNZQojqtohor5yL7dSb4edeuG5qMh8JzUJeLZvN8cxukt 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qq2w0d3vw5202hse28p0la9rvcne5j24qv6t4hj58d hdkeypath=m/0'/0'/555'
+cVPsuH8v4PpLJieDFMYyXuRAVmFkVmRTHBGxEeGELo4QMPz8bYyZ 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qp8nn4zyy5v8su6v3c6sf2hq0nrxeqzuqvqs4h5xam hdkeypath=m/0'/0'/858'
+cPGthbqMbX6kCUXG72kZfYWZX1cwWhLUaRvKj1LeNTZTEQkEQKez 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qzy7flccw4g3nhh4mksxwwuz2smx85naqvcealz8pc hdkeypath=m/0'/0'/89'
+cV76D5Z8UxuskGaerD5VYR6oies1HUHDvq8eLuQ9dnEnrupiHesN 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qqeug07w2n6gjrnzflr8dp2x4ku3794dqv46y80v4e hdkeypath=m/0'/1'/849'
+cPi5RxfS2HHMWMt3YnY5x2JnLLis5wtYoX7uf8swYb2DaC93ja83 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qzn0nkz9klkctg3s08zgf7xwexung27qqv33s57lfa hdkeypath=m/0'/0'/445'
+cTmE2XrXjay4pN6rSQqGdQ61RfH71716i9Gr41XRE1H5am998HWT 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qq9vkfzflckgrzndj5um4xnxtzkwl5wtqvrurey9a6 hdkeypath=m/0'/1'/97'
+cQLSaL6A4taGdsWLGWQK2RrAjsRHE3iKQ9bQ4sNFFFJaPqeSzEdW 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qrje963zup3k4w0usvfcfm6xwm6rmah3qvp3y5p5f2 hdkeypath=m/0'/1'/807'
+cUwbXhFJzLvDM6cahsmjteUSPB9R86pNjRWbGiidcdnHQtZcjffS 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qpzqpt9nnqvrpzp4kydxrl87jq5qv2glqs3lty3jqv hdkeypath=m/0'/0'/76'
+cNnhDbhrvxMsmr8NQwuBubg2LqtdmyVF1cZCCUNZXtkQvCFzeisB 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qql8htcgd7fmk94q443jptzn0ulv06zqqsl0k7pxlh hdkeypath=m/0'/0'/51'
+cUqbctTgaD19iZ8FJjKMGr5YdqCXs3ti3KHqtZNPNryVaa9EA2RD 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qzz6rhxw92daxjawff92c2ghxfjjwkmqqszz0s68te hdkeypath=m/0'/0'/26'
+cQXB8bGdcPjyNVnrPwUKCdJcx4yNJoYauAGE4pZvcB8ZrAe1Z8ZT 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qqex9m5ty059pgqwryfffm9dzuapfg4rqskq3hqm0s hdkeypath=m/0'/1'/896'
+cNReXczzABpyPxPBAPK1ddwwV1aoy2xnqeMt9mXGH7EpqEiiisqm 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qr6gf7vx08rq0uchl0wwsh0jlnp9l6ayqsds94yuqd hdkeypath=m/0'/1'/712'
+cTZ1XaNjpTnr5dgCGAkeV4NZCxZhoYwRrJoenmAqbgWC2yzA1vLi 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qrhyd89258ultrjf3hsk72lg3n7wx3lxqsrvpgx04e hdkeypath=m/0'/1'/224'
+cSnbnKjTEinAxVMk4i7qDzAeC7duseKvVLninzCiVskSVjwSmAv4 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qp05qf87mkfgxs0hpgvgdchtj3zqex8wqs8h8954e9 hdkeypath=m/0'/1'/717'
+cVLJtk7kFFCAfEvj3ULNr59ogFhprhY4LmBSaKyepDtssuBV4aoj 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qz6pxp99qtvewl6zxa90268y6nwx3mhhqsfkf4d7ms hdkeypath=m/0'/0'/779'
+cTrScwV6E1uRtrcNufcDPJyord3AXgGiAz7HHPk88d5AFD6WVbUx 2024-09-28T09:13:20Z reserve=1 # addr=bchtest:qqeyp4gqhd3thj235xw8rkjxyg2hh7leqs55paumr5 hdkeypath=m/0'/1'/565'

+ 57 - 0
test/ref/bitcoin_cash/bchwallet.dump

@@ -0,0 +1,57 @@
+# Wallet dump created by Bitcoin Cash Node v27.1.0-9f9aa5a6e
+# * Created on 2024-09-28T09:01:27Z
+# * Best block at time of backup was 0 (000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f),
+#   mined on 2009-01-03T18:15:05Z
+
+# extended private masterkey: xprv9s21ZrQH143K3rtmqd4EARwck2w9PMoozEdPXdN3q7G34xYWJUi6f78acEx3go1648yunqrmRzcbjQDYuFTPAXnG1CUjqc7LZnkwMNg3QBG
+
+KzVH2yXbj8oKGPiBqqwze9CyfkE5bsCUVCt6hd8nVxwZg7MCjaVh 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qryzwf3zl5vl4txc4v03yuzymenfrfwmqqye26zhtv hdkeypath=m/0'/0'/13'
+KzF25zJ1SEZrWFbc1V7GnCrXWgUFCHXirpi5UDNZ83PTd524Qz9B 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qqw6uwxf096nmgfucnqjdlgnhu9jxyswqyuejq9cac hdkeypath=m/0'/0'/9'
+L4t5RMzGSUps2pVtFS2gfx1W9hTaLXKkLfdsowt6de3NUvd9g6kv 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qzjz6zrkjxcm0t7xv2p6d5ylrvnrj5x4pqrxczzt6d hdkeypath=m/0'/0'/3'
+L3WHKc7JY9QtnPQmrmk6w8rbMugBpy2ydNhzYpgoG6vxzTofxTAQ 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qz4vyfzutpp53nfnzqtwasavh9zvr45r9vequueu05 hdkeypath=m/0'/0'/4'
+L3C7osemZLh9dzUmtBpL3xz2zgkFuY1wt5ykFAevZigudUsSjHDd 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qz7uunmyx67nvujz9qg59yh2dedv4q6v9sddgmywe7 hdkeypath=m/0'/0'/2'
+L1ua9H1EJq6bGTgrj4SjSipEBMPhM4naE2udaGZxeYyXxmgEDWSG 2024-09-28T09:00:51Z hdseed=1 # addr=bitcoincash:qzaatxgp2p6dqjppu43jxwpsllyh94s6xy4dgksysv hdkeypath=s
+L2PZUxH9n9J6dLu8MUvKzpngcg1EJLQy9QfWfFtBj5yLQb1Uqr5L 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qpy40kh7nkfd9heuc5f0fy2yq9s400quxv8y4xntu6 hdkeypath=m/0'/0'/21'
+L3cPRuFrvewas5WyAksyYnNmjBJzkEsFtrTqKKJKEz5VpfW2FPcW 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qpneya8njpm6n6mfsrcj3s9evr9k67jyxs5gfrgzcj hdkeypath=m/0'/0'/17'
+KxRmkfp4cJy4inmxy7sPVjrqN6F8d2pxJoZf7rBfyYc4KK7wokdh 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qqa4ezt3gzf3j7uqcjgudntpwvlxvp5d8vjqav3rht hdkeypath=m/0'/0'/20'
+KzM9HmLtuD8skinau2eUtkMeaqjokiTXVgrYPzCw7ySJfSa6cQsb 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qz9aw6gv4d69v8lmd7srn2gcwda2t0a085w8xunskq hdkeypath=m/0'/0'/6'
+L2Xze59SbhtNUwv1JwUMzzA5RQB4dw3j8FZZdrt9QC7ErSB5w4L1 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qpmx2se4glcfc83mtm4eglthhyqm7ee52ss72alnmy hdkeypath=m/0'/0'/19'
+KwdDU4G4rppt773ois2BULKGP8Lr6PoBVtWjfMGYD8WJAbq1Fk67 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qrq6cm6cml3xyn63c7lgdncqszqka7a0tu4zlkfynh hdkeypath=m/0'/0'/0'
+Kxfb92ePatCnDcjpJhdtqvyWfE63KWNtqiNpbmzuecFQRvPB2giB 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qzuq9zummldyc5xjxsqgauprm2rqqjmqvvy9jcflq3 hdkeypath=m/0'/0'/12'
+L1FpyeQFFmk1YkFC4FU4WhTjDETi9TRYDxVqZC48phUigfJnCGLf 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qq2vn4qf04pwjrfddn8zwx0afqv9u674dul0p9tqyz hdkeypath=m/0'/0'/7'
+KxeABzNJ32G81B2KYis3QgCmPH2LmgrvsLKUyTbFTa9TBBpMXpGU 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qpm786fm57k3qr9m2yrpj33exgvedrha0ua9pk5dfz hdkeypath=m/0'/0'/8'
+KwfUszgfu1SLJAydoHDz6HYCsj5x7yeLUfEkWkRBk6pHHQmdvdNT 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qzz2gms7w3g0u2jqrgwqf00pnpw4xv5w3g68pn0lwl hdkeypath=m/0'/0'/10'
+KyFEkJhebt7ZnzaLTGBokkeKECj3eUHSMLVz16LmQiKE9YkYjnDx 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qzudzz8um9thpe3n6sx6y2s83g3f5gffnvzvydmrd2 hdkeypath=m/0'/0'/15'
+KzjQPk5xVmsqfTai9Qnwsw8PrUxSjoLT9TqXi2iXrCHAPqFEN9KL 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qz2wp0tsd2222hf2nw23rhvjp8cgyext4gnuruujts hdkeypath=m/0'/0'/16'
+KwrEu8kc7FWbAJnfpGniYVvLkCyQXdsZANtYmfXEzRnTUoNTFkL5 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qr5d0gr79c5gcy6f46x3f997aw98ttaxkszemm2sc5 hdkeypath=m/0'/0'/5'
+L1QrY5tecZ6pQ7FjaUG3ZFWPy55qm7JRjivaPzp7tUHorLVpxsts 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qzf8e846e070d4ms6rkrrcwfhfvqgzvhhvjemgw90y hdkeypath=m/0'/0'/14'
+L4fjz4VZL8MyhSXXnmqCaPpJ1gpDJSWVkEj9JwGqHNh3ftFt7fSK 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qq5j965zc0ynlyx7gu8a3ay6ynwpgjcwmuge4nx9d7 hdkeypath=m/0'/0'/11'
+Kx5doQ9MZZNNjdaR9AjV18SFojQf3dDyvqR8xgu5f99kaPFrxfih 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qqgjaznt55w8p6445jvlp3l99vpd58pqugvgdw54m6 hdkeypath=m/0'/0'/18'
+Ky4UDQL9uJn9BsgqhEpyZzCwKM4S3VZbjj7MizaZv4qoktSco4SK 2024-09-28T09:00:51Z reserve=1 # addr=bitcoincash:qqq5jwnp0lhszkly4href262wnl3e8dxaqx67edhwj hdkeypath=m/0'/0'/1'
+L4Pg3BFXMD4GxfsDujY56is1GDt7TuFUzq9D8EpzmZAMoxG5VGe1 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qzq2l67xz9zver23lyhqdn6j4fxa0v32qqgy7tuxpx hdkeypath=m/0'/1'/392'
+KzmaLSVCL5RythXGPUw4uyJQDS2j5qXxcLUxWkQbDsRpKTiMyr9h 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qqt30vesj72xegyxhhu5vph9s474gjt9qq0vcxvp8k hdkeypath=m/0'/1'/217'
+KxNiUoG6xoWn1dBqDW6QU5n1hSD1rdPNPPfv8BqnMsdPDViYtDMG 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qzmrdx6m0lmn5qxcg2p7yghkndwtusunqqzhy0a937 hdkeypath=m/0'/0'/991'
+Kwf7frjVxR8geZwMGr4YY4rd3urTyThTQmPwmzZHsRmJYopuVEdR 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qzjp3udaawrwzjpp0ha3r2u8v0cpcmvaqqqgrhvhvp hdkeypath=m/0'/1'/146'
+L3RXLas6ZmBKENVQB9PCgFqZqvpCaJH8yc5SD29e3ysfViaodV2K 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qqa267qf9a07z6la89hvr6dppyckzfkwqq20wrhypu hdkeypath=m/0'/0'/965'
+L1Uyd6thQJnRbFr9ytWPJz1ogZLZNdhfTgmpDt16FwANedb7oteS 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qzkxc8c5ull9qc485yyjzh06tcmkw4l7qq9seqpqy7 hdkeypath=m/0'/0'/963'
+L2LpY1dUY5VL9bDqc11zLzCSJKEmfkAZNaJRwjrbk2HJFKkM2fmr 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qzvrcr3f46czytk3wr3runlqwznv9p3zqy9lzjvetm hdkeypath=m/0'/1'/927'
+KweRCR3RkpfhZvRRMFBpq16z3XpB29N6UguN8VWjD4PQr96kmqVP 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qzllfzpwshvhjxe98y7vq3sm0ulz42peqy2ngg2xpz hdkeypath=m/0'/0'/268'
+L3GYWNvrReSpL6ED6bmN6NmVoPrJeQg2363BBZNAbfJXE8ofTcjP 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qzgtmde25a7q29tpu4jf38dvkcujw4j2qyh0l62alz hdkeypath=m/0'/0'/332'
+L3VwEfgMaWViYkY2xdvZknGMNHSWbfqjrokpzX5KAL2w347Tahho 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qzvqr39nmtylzhzpf7cjjxzgjv5syx6dqyl38cuyt3 hdkeypath=m/0'/0'/884'
+KydYvd86K9d88CdxWbTNrM3gnoos4jvKENfgiDtPraKiEYmv4MNX 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qpwssgatqta6nx5ru58r20w8qcrzaxrjqyvvz5q7n5 hdkeypath=m/0'/0'/876'
+L2Ka61jjXQvNKFsZXJyB6YhYcSeSSpKqPy6zW5ABVXftxT9Bqjwu 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qraclt7lpx6um64s3ruyqtznt0hhn6vnqyyjf04z6y hdkeypath=m/0'/1'/260'
+L2Bko5eZ4nSSowtZZuUUZHTQEkr5sHvzjGprkG9QwGqpf7AUhHV3 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qr78tq3cgdv5h0ywqep89fyhy34l9t4dqyc5dcenvm hdkeypath=m/0'/1'/865'
+L45JdFaTJe4TagqcsMCo7WHfKHyitKDG6eC5BoeiVF7yqfU4BG44 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qpzu3sq2ljmvmwr0rjdxjg0xal26yyh2qynadya0r5 hdkeypath=m/0'/0'/704'
+L4ZfyviTt2Js2nH2X6ogGNoLnoauma9VfZYioga9f5iJzp1yKBsj 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qqff4nfsr7zwt3rw79u75gtnr72velcxqgn30n9dn9 hdkeypath=m/0'/1'/745'
+Kz3oWqWx8p4bYT11rQG6GjADNC5ykuUtpRb7pBWQogQDGRYBQzyc 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qz85l6lfl7d7qzg9c3nmtg958kvfzqsgqgvv2pr0zs hdkeypath=m/0'/0'/456'
+L5SZrXNW8Z9yvYdUkruhBNwcKEjJVHXT5zH78HXDYGCjM1HDoPRC 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qpcp3jvcxu89a5lxmdkns29ejxxn53fgqgv0l9ksna hdkeypath=m/0'/1'/515'
+KzLmpFiekUUpwchKLw14m1nDsx5UVvzvEwU6PY7qCb8bFFdfgVue 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qzv0v5wq7vqxeq9fuz5rm8f7azu2g43wqgvrlkxzyf hdkeypath=m/0'/1'/885'
+L4g6VoesU45xfbD1dpjKn4bSEQz26dumPD4vsTc4FFAyhphwxnbh 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qzp565dxv8efvmc3uh4z4h0ylds0cn36qgegl9fm5p hdkeypath=m/0'/0'/741'
+Kxwbnh2SjK5yoZwPTZ69VP4J6fnTEYPFtPbys9S7xU8xFxq512Zm 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qqea3z09daw8tj25w47npuwx5lsp9njaqgwddrunhc hdkeypath=m/0'/1'/246'
+L15Eg9gUugCAZvMRUi3282xb8Q9J7d5tiVawtJejZieHX457jgdk 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qrt7ej2m2hzu5yshuutm2pka6l8n0nnxqgqx92plfm hdkeypath=m/0'/1'/801'
+L2K5S297cfQnEx4tkaSS1xan6N5HVoH5TARavLEgdQbcPhhPbEjT 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qqe2kanrhh720y00uk6c5w63rd43gr4hqgwkgrrn33 hdkeypath=m/0'/1'/737'
+KyXyudwnHEDFvE1mVFY5m175Xw45BUF5bLMxJ1Xi5fHCqMoQ6kbk 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qqc0g42hx2czecul7u94ke7fh3mvh3ggqv3mrq8suu hdkeypath=m/0'/0'/518'
+KyXJuaTB4JwhHFvp6pHHFhNUv5JHfjTc1CJpfX1SCvZqEkW6Mg4f 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qr9kx4a6puadxwtm3vjqtf5hkjaa56qwqv7nqnczgm hdkeypath=m/0'/1'/39'
+KzcozomWBQgkGq1FB94UBqdBQeAaWHj6bP9wLwUVu7Vh2WiorTqF 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qp0p9t0075uf56nm2t2x2gc4xd7909ceqvccy6gsdp hdkeypath=m/0'/0'/376'
+KwS6Ln2E3bFqYNW1y7AAfWXNSuJdTmksmi1a3nt4TULmRqg44rMC 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qpj7z3vx762z0m2cnf92xtc8yw5e65ftqv99x2u3nv hdkeypath=m/0'/1'/628'
+L4BGf9Ri2ELjNgAyLipKX3j2vLst7t3jSK7XBhmk5cf2Z8SxAMEk 2024-09-28T09:00:52Z reserve=1 # addr=bitcoincash:qpmhuh97dpa00c2ha406fh7kcu6k68f0qvqnkj6d7c hdkeypath=m/0'/0'/892'

+ 57 - 49
test/scrambletest.py

@@ -21,8 +21,8 @@ test/scrambletest.py: seed scrambling and addrlist data generation tests for all
                       supported coins + passwords
 """
 
-import sys,os,time
-from subprocess import run,PIPE
+import sys, os, time
+from subprocess import run, PIPE
 from collections import namedtuple
 
 try:
@@ -30,9 +30,11 @@ try:
 except ImportError:
 	from test.include import test_init
 
+from mmgen.main import launch
 from mmgen.cfg import Config
-from mmgen.util import msg,msg_r,bmsg,die
-from mmgen.color import gray
+from mmgen.util import msg, msg_r, bmsg, die, list_gen
+from mmgen.color import gray, green
+from test.include.common import set_globals, init_coverage, end_msg
 
 opts_data = {
 	'text': {
@@ -55,18 +57,16 @@ If no command is given, the whole suite of tests is run.
 
 cfg = Config(opts_data=opts_data)
 
-from test.include.common import set_globals,init_coverage,end_msg,green
-
 set_globals(cfg)
 
-td = namedtuple('scrambletest_entry',['seed','str','id_str','lbl','addr'])
+td = namedtuple('scrambletest_entry', ['seed', 'str', 'id_str', 'lbl', 'addr'])
 
 bitcoin_data = {
-#                   SCRAMBLED_SEED[:8] SCRAMBLE_KEY     ID_STR   LBL              FIRST ADDR
-'btc':           td('456d7f5f1c4bfe3b','(none)',        '',      '',              '1MU7EdgqYy9JX35L25hR6CmXXcSEBDAwyv'),
-'btc_compressed':td('bf98a4af5464a4ef','compressed',    '-C',    'COMPRESSED',    '1F97Jd89wwmu4ELadesAdGDzg3d8Y6j5iP'),
-'btc_segwit':    td('b56962d829ffc678','segwit',        '-S',    'SEGWIT',        '36TvVzU5mxSjJ3D9qKAmYzCV7iUqtTDezF'),
-'btc_bech32':    td('d09eea818f9ad17f','bech32',        '-B',    'BECH32',        'bc1q8snv94j6959y3gkqv4gku0cm5mersnpucsvw5z'),
+#                   SCRAMBLED_SEED[:8] SCRAMBLE_KEY     ID_STR   LBL          FIRST ADDR
+'btc':           td('456d7f5f1c4bfe3b','(none)',        '',      '',          '1MU7EdgqYy9JX35L25hR6CmXXcSEBDAwyv'),
+'btc_compressed':td('bf98a4af5464a4ef','compressed',    '-C',    'COMPRESSED','1F97Jd89wwmu4ELadesAdGDzg3d8Y6j5iP'),
+'btc_segwit':    td('b56962d829ffc678','segwit',        '-S',    'SEGWIT',    '36TvVzU5mxSjJ3D9qKAmYzCV7iUqtTDezF'),
+'btc_bech32':    td('d09eea818f9ad17f','bech32',        '-B',    'BECH32',    'bc1q8snv94j6959y3gkqv4gku0cm5mersnpucsvw5z'),
 }
 
 altcoin_data = {
@@ -98,70 +98,78 @@ passwd_data = {
 'xmrseed_dfl_αω':td('62f5b72a5ca89cab', 'xmrseed:25:αω','-αω-xmrseed-25','αω xmrseed:25','tequila eden skulls giving jester hospital dreams bakery adjust nanny cactus inwardly films amply nanny soggy vials muppet yellow woken ashtray organs exhale foes eden'),
 }
 
-cvr_opts = ' -m trace --count --coverdir={} --file={}'.format( *init_coverage() ) if cfg.coverage else ''
-cmd_base = f'python3{cvr_opts} cmds/mmgen-{{}}gen -qS'
-
 run_env = dict(os.environ)
 run_env['MMGEN_DEBUG_ADDRLIST'] = '1'
+words_file = 'test/ref/98831F3A.mmwords'
+cvrg_opts = '-m trace --count --coverdir={} --file={}'.format(*init_coverage()).split() if cfg.coverage else []
 
-def get_cmd_output(cmd):
-	cp = run( cmd.split(), stdout=PIPE, stderr=PIPE, env=run_env )
+def make_cmd(progname, opts, add_opts, args):
+	return ['python3'] + cvrg_opts + [f'cmds/{progname}', '-qS'] + opts + add_opts + [words_file] + args + ['1']
+
+def run_cmd(cmd):
+	cp = run(cmd, stdout=PIPE, stderr=PIPE, text=True, env=run_env)
 	if cp.returncode != 0:
-		die(2,f'\nSpawned program exited with error code {cp.returncode}:\n{cp.stderr.decode()}')
-	return cp.stdout.decode().splitlines()
+		die(2,f'\nSpawned program exited with error code {cp.returncode}:\n{cp.stderr}')
+	return cp.stdout.splitlines()
 
-def do_test(cmd,tdata,msg_str,addr_desc):
-	cfg._util.vmsg(green(f'Executing: {cmd}'))
-	msg_r('Testing: ' + msg_str)
+def run_test(progname, opts, add_opts, args, test_data, addr_desc, opts_w):
+	cmd = make_cmd(progname, opts, add_opts, args)
+	if cfg.verbose:
+		msg(green(f'Executing: {" ".join(cmd)}'))
+	else:
+		msg_r('Testing: {} {:{w}} '.format(progname, ' '.join(opts), w=opts_w))
 
-	lines = get_cmd_output(cmd)
+	lines = run_cmd(cmd)
 	cmd_out = dict([e[9:].split(': ') for e in lines if e.startswith('sc_debug_')])
 	cmd_out['addr'] = lines[-2].split(None,1)[-1]
 
-	ref_data = tdata._asdict()
-	cfg._util.vmsg('')
+	ref_data = test_data._asdict()
 	for k in ref_data:
 		if cmd_out[k] == ref_data[k]:
 			s = k.replace('seed','seed[:8]').replace('addr',addr_desc)
 			cfg._util.vmsg(f'  {s:9}: {cmd_out[k]}')
 		else:
 			die(4,f'\nError: sc_{k} value {cmd_out[k]} does not match reference value {ref_data[k]}')
-	msg('OK')
+	msg(green('OK') if cfg.verbose else 'OK')
 
-def do_coin_tests():
+def make_coin_test_data():
 	bmsg('Testing address scramble strings and list IDs')
-	for tname,tdata in (
-			tuple(bitcoin_data.items()) +
-			tuple(altcoin_data.items() if not cfg.no_altcoin else []) ):
-		if tname == 'zec_zcash_z' and sys.platform == 'win32':
+	coin_data = bitcoin_data | ({} if cfg.no_altcoin else altcoin_data)
+	for id_str, test_data in coin_data.items():
+		if id_str == 'zec_zcash_z' and sys.platform == 'win32':
 			msg(gray("Skipping 'zec_zcash_z' test for Windows platform"))
 			continue
-		coin,mmtype = tname.split('_',1) if '_' in tname else (tname,None)
-		type_arg = ' --type='+mmtype if mmtype else ''
-		cmd = cmd_base.format('addr') + f' --coin={coin}{type_arg} test/ref/98831F3A.mmwords 1'
-		do_test(cmd,tdata,f'--coin {coin.upper():4} {type_arg:22}','address')
-
-def do_passwd_tests():
+		coin, mmtype = id_str.split('_', 1) if '_' in id_str else (id_str, None)
+		opts = list_gen(
+			[f'--coin={coin}'],
+			[f'--type={mmtype}', mmtype],
+			[ '--cashaddr=0', coin == 'bch']
+		)
+		yield ('mmgen-addrgen', opts, [], [], test_data, 'address')
+
+def make_passwd_test_data():
 	bmsg('Testing password scramble strings and list IDs')
-	for tname,tdata in passwd_data.items():
-		if tname.startswith('xmrseed') and cfg.no_altcoin:
+	for id_str, test_data in passwd_data.items():
+		if id_str.startswith('xmrseed') and cfg.no_altcoin:
 			continue
-		a,b,pwid = tname.split('_')
-		fmt_arg = '' if a == 'dfl' else f'--passwd-fmt={a} '
-		len_arg = '' if b == 'dfl' else f'--passwd-len={b} '
-		fs = '{}' + fmt_arg + len_arg + '{}' + pwid + ' 1'
-		cmd = cmd_base.format('pass') + ' ' + fs.format('--accept-defaults ','test/ref/98831F3A.mmwords ')
-		s = fs.format('','')
-		do_test(cmd,tdata,s+' '*(40-len(s)),'password')
+		pw_fmt, pw_len, pw_id = id_str.split('_')
+		opts = list_gen(
+			[f'--passwd-fmt={pw_fmt}', pw_fmt != 'dfl'],
+			[f'--passwd-len={pw_len}', pw_len != 'dfl'],
+		)
+		yield ('mmgen-passgen', opts, ['--accept-defaults'], [pw_id], test_data, 'password')
 
 def main():
 	start_time = int(time.time())
 
-	cmds = cfg._args or ('coin','pw')
+	cmds = cfg._args or ('coin', 'pw')
+	funcs = {'coin': make_coin_test_data, 'pw': make_passwd_test_data}
 	for cmd in cmds:
-		{'coin': do_coin_tests, 'pw': do_passwd_tests }[cmd]()
+		data = tuple(funcs[cmd]())
+		opts_w = max(len(' '.join(e[1])) for e in data)
+		for d in data:
+			run_test(*d, opts_w)
 
 	end_msg(int(time.time()) - start_time)
 
-from mmgen.main import launch
 launch(func=main)

+ 11 - 2
test/test-release.d/cfg.sh

@@ -197,10 +197,16 @@ init_tests() {
 	"
 
 	d_bch="overall operations with emulated RPC data (Bitcoin Cash Node)"
-	t_bch="- $cmdtest_py --coin=bch --exclude regtest"
+	t_bch="
+		- $cmdtest_py --coin=bch --exclude regtest
+		- $cmdtest_py --coin=bch --cashaddr=0 ref3_addr
+	"
 
 	d_bch_tn="overall operations with emulated RPC data (Bitcoin Cash Node testnet)"
-	t_bch_tn="- $cmdtest_py --coin=bch --testnet=1"
+	t_bch_tn="
+		- $cmdtest_py --coin=bch --testnet=1
+		- $cmdtest_py --coin=bch --testnet=1 --cashaddr=0 ref3_addr
+	"
 
 	d_bch_rt="overall operations using the regtest network (Bitcoin Cash Node)"
 	t_bch_rt="- $cmdtest_py --coin=bch regtest"
@@ -286,6 +292,9 @@ init_tests() {
 		a $gentest_py --coin=ltc --type=segwit 1 $REFDIR/litecoin/ltcwallet-segwit.dump
 		a $gentest_py --coin=ltc --type=bech32 1 $REFDIR/litecoin/ltcwallet-bech32.dump
 		a $gentest_py --coin=ltc --type=compressed --testnet=1 1 $REFDIR/litecoin/ltcwallet-testnet.dump
+		a $gentest_py --coin=bch --type=compressed --cashaddr=0 1 $REFDIR/bitcoin_cash/bchwallet.dump
+		a $gentest_py --coin=bch --type=compressed --cashaddr=1 1 $REFDIR/bitcoin_cash/bchwallet.dump
+		a $gentest_py --coin=bch --type=compressed --testnet=1 1 $REFDIR/bitcoin_cash/bchwallet-testnet.dump
 		- # libsecp256k1 vs python-ecdsa:
 		- $gentest_py --type=legacy 1:2 $rounds100x
 		- $gentest_py --type=compressed 1:2 $rounds100x

+ 2 - 2
test/tooltest2.py

@@ -184,8 +184,8 @@ def run_test(cls, gid, cmd_name):
 	# behavior is like cmdtest.py: run coin-dependent tests only if proto.testnet or proto.coin != BTC
 	if gid in coin_dependent_groups:
 		k = '{}_{}'.format(
-			( cfg.token.lower() if proto.tokensym else proto.coin.lower() ),
-			('mainnet','testnet')[proto.testnet] )
+			(cfg.token.lower() if proto.tokensym else proto.coin.lower()),
+			proto.network)
 		if k in data:
 			data = data[k]
 			m2 = f' ({k})'

+ 1 - 1
test/tooltest2_d/data.py

@@ -25,7 +25,7 @@ from mmgen.xmrseed import is_xmrseed
 
 from ..unit_tests_d.ut_baseconv import unit_test as ut_baseconv
 from ..unit_tests_d.ut_bip39 import unit_tests as ut_bip39
-from ..unit_tests_d.ut_xmrseed import unit_test as ut_xmrseed
+from ..unit_tests_d.ut_xmrseed import unit_tests as ut_xmrseed
 
 from ..include.common import cfg,sample_text
 proto = cfg._proto

+ 13 - 2
test/unit_tests.py

@@ -35,7 +35,7 @@ if not os.getenv('MMGEN_DEVTOOLS'):
 
 from mmgen.cfg import Config,gc
 from mmgen.color import gray, brown, orange, yellow, red
-from mmgen.util import msg,gmsg,ymsg,Msg
+from mmgen.util import msg, msg_r, gmsg, ymsg, Msg
 
 from test.include.common import set_globals,end_msg
 
@@ -158,9 +158,20 @@ def run_test(test,subtest=None):
 			getattr(t,'_pre_subtest')(test,subtest,UnitTestHelpers(subtest))
 
 		try:
-			ret = getattr(t,subtest.replace('-','_'))(test,UnitTestHelpers(subtest))
+			func = getattr(t,subtest.replace('-','_'))
+			c = func.__code__
+			do_desc = c.co_varnames[c.co_argcount-1] == 'desc'
+			if do_desc:
+				if cfg.verbose:
+					msg(f'Testing {func.__defaults__[0]}')
+				elif not cfg.quiet:
+					msg_r(f'Testing {func.__defaults__[0]}...')
+
+			ret = func(test, UnitTestHelpers(subtest))
 			if type(ret).__name__ == 'coroutine':
 				ret = asyncio.run(ret)
+			if do_desc and not cfg.quiet:
+				msg('OK\n' if cfg.verbose else 'OK')
 		except:
 			if getattr(t,'silence_output',False):
 				t._end_silence()

+ 2 - 2
test/unit_tests_d/ut_bip_hd.py

@@ -151,7 +151,7 @@ vectors_multicoin = {
 	'doge':         'DFX88RXpi4S4W24YVvuMgbdUcCAYNeEYGd',
 	'avax-c':       '0x373731f4d885Fc7Da05498F9f0804a87A14F891b',
 	'ltc_bech32':   'ltc1q3uh5ga5cp9kkdfx6a52uymxj9keq4tpzep7er0',
-	'bch_cashaddr': 'bitcoincash:qpqpcllprftg4s0chdgkpxhxv23wfymq3gj7n0a9vw',
+	'bch_compressed': 'bitcoincash:qpqpcllprftg4s0chdgkpxhxv23wfymq3gj7n0a9vw',
 	'bsc_smart':    '0x373731f4d885Fc7Da05498F9f0804a87A14F891b',
 	'bnb_beacon':   'bnb179c3ymltqm4utlp089zxqeta5dvn48a305rhe5',
 }
@@ -316,6 +316,7 @@ class unit_tests:
 			if coin not in BipHDConfig.supported_coins:
 				vmsg(gray(fs.format(coin.upper(), (addr_type or ''), '[not supported yet]')))
 				continue
+			vmsg(fs.format(coin.upper(), (addr_type or 'auto'), addr_chk))
 			node = m.to_chain(idx=0,coin=coin,addr_type=addr_type).derive_private(0)
 			xpub_parsed = node.key_extended(public=True)
 			xprv_parsed = node.key_extended(public=False)
@@ -332,7 +333,6 @@ class unit_tests:
 			if proto.base_proto == 'Ethereum':
 				addr = proto.checksummed_addr(node.address)
 				addr_from_wif = proto.checksummed_addr(addr_from_wif)
-			vmsg(fs.format(coin.upper(), (addr_type or 'auto'), addr))
 			assert addr == addr_chk, f'{addr} != {addr_chk}'
 			assert addr == addr_from_wif, f'{addr} != {addr_from_wif}'
 

+ 151 - 0
test/unit_tests_d/ut_cashaddr.py

@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+
+"""
+test.unit_tests_d.ut_cashaddr: unit test for the BCH cashaddr module
+"""
+
+altcoin_dep = True
+
+from collections import namedtuple
+
+from mmgen.proto.bch.cashaddr import cashaddr_parse_addr, cashaddr_decode_addr, cashaddr_encode_addr
+from mmgen.addr import CoinAddr
+
+from ..include.common import cfg, vmsg
+
+from mmgen.protocol import init_proto
+proto = init_proto(cfg, 'bch')
+
+# Source: https://upgradespecs.bitcoincashnode.org/cashaddr
+alias_data = """
+1BpEi6DfDAUFd7GtittLSdBeYJvcoaVggu  bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a
+1KXrWXciRDZUpQwQmuM1DbwsKDLYAYsVLR  bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy
+16w1D5WRVKJuZUsSRzdLp9w3YGcgoxDXb   bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r
+3CWFddi6m4ndiGyKqzYvsFYagqDLPVMTzC  bitcoincash:ppm2qsznhks23z7629mms6s4cwef74vcwvn0h829pq
+3LDsS579y7sruadqu11beEJoTjdFiFCdX4  bitcoincash:pr95sy3j9xwd2ap32xkykttr4cvcu7as4yc93ky28e
+31nwvkZwyPdgzjBJZXfDmSWsC4ZLKpYyUw  bitcoincash:pqq3728yw0y47sqn6l2na30mcw6zm78dzq5ucqzc37
+"""
+
+vec_data = """
+F5BF48B397DAE70BE82B3CCA4793F8EB2B6CDAC9
+20 0  bitcoincash:qr6m7j9njldwwzlg9v7v53unlr4jkmx6eylep8ekg2
+20 1  bchtest:pr6m7j9njldwwzlg9v7v53unlr4jkmx6eyvwc0uz5t
+20 1  pref:pr6m7j9njldwwzlg9v7v53unlr4jkmx6ey65nvtks5
+20 15 prefix:0r6m7j9njldwwzlg9v7v53unlr4jkmx6ey3qnjwsrf
+
+7ADBF6C17084BC86C1706827B41A56F5CA32865925E946EA
+24 0  bitcoincash:q9adhakpwzztepkpwp5z0dq62m6u5v5xtyj7j3h2ws4mr9g0
+24 1  bchtest:p9adhakpwzztepkpwp5z0dq62m6u5v5xtyj7j3h2u94tsynr
+24 1  pref:p9adhakpwzztepkpwp5z0dq62m6u5v5xtyj7j3h2khlwwk5v
+24 15 prefix:09adhakpwzztepkpwp5z0dq62m6u5v5xtyj7j3h2p29kc2lp
+
+3A84F9CF51AAE98A3BB3A78BF16A6183790B18719126325BFC0C075B
+28 0  bitcoincash:qgagf7w02x4wnz3mkwnchut2vxphjzccwxgjvvjmlsxqwkcw59jxxuz
+28 1  bchtest:pgagf7w02x4wnz3mkwnchut2vxphjzccwxgjvvjmlsxqwkcvs7md7wt
+28 1  pref:pgagf7w02x4wnz3mkwnchut2vxphjzccwxgjvvjmlsxqwkcrsr6gzkn
+28 15 prefix:0gagf7w02x4wnz3mkwnchut2vxphjzccwxgjvvjmlsxqwkc5djw8s9g
+
+3173EF6623C6B48FFD1A3DCC0CC6489B0A07BB47A37F47CFEF4FE69DE825C060
+32 0  bitcoincash:qvch8mmxy0rtfrlarg7ucrxxfzds5pamg73h7370aa87d80gyhqxq5nlegake
+32 1  bchtest:pvch8mmxy0rtfrlarg7ucrxxfzds5pamg73h7370aa87d80gyhqxq7fqng6m6
+32 1  pref:pvch8mmxy0rtfrlarg7ucrxxfzds5pamg73h7370aa87d80gyhqxq4k9m7qf9
+32 15 prefix:0vch8mmxy0rtfrlarg7ucrxxfzds5pamg73h7370aa87d80gyhqxqsh6jgp6w
+
+C07138323E00FA4FC122D3B85B9628EA810B3F381706385E289B0B25631197D194B5C238BEB136FB
+40 0  bitcoincash:qnq8zwpj8cq05n7pytfmskuk9r4gzzel8qtsvwz79zdskftrzxtar994cgutavfklv39gr3uvz
+40 1  bchtest:pnq8zwpj8cq05n7pytfmskuk9r4gzzel8qtsvwz79zdskftrzxtar994cgutavfklvmgm6ynej
+40 1  pref:pnq8zwpj8cq05n7pytfmskuk9r4gzzel8qtsvwz79zdskftrzxtar994cgutavfklv0vx5z0w3
+40 15 prefix:0nq8zwpj8cq05n7pytfmskuk9r4gzzel8qtsvwz79zdskftrzxtar994cgutavfklvwsvctzqy
+
+E361CA9A7F99107C17A622E047E3745D3E19CF804ED63C5C40C6BA763696B98241223D8CE62AD48D863F4CB18C930E4C
+48 0  bitcoincash:qh3krj5607v3qlqh5c3wq3lrw3wnuxw0sp8dv0zugrrt5a3kj6ucysfz8kxwv2k53krr7n933jfsunqex2w82sl
+48 1  bchtest:ph3krj5607v3qlqh5c3wq3lrw3wnuxw0sp8dv0zugrrt5a3kj6ucysfz8kxwv2k53krr7n933jfsunqnzf7mt6x
+48 1  pref:ph3krj5607v3qlqh5c3wq3lrw3wnuxw0sp8dv0zugrrt5a3kj6ucysfz8kxwv2k53krr7n933jfsunqjntdfcwg
+48 15 prefix:0h3krj5607v3qlqh5c3wq3lrw3wnuxw0sp8dv0zugrrt5a3kj6ucysfz8kxwv2k53krr7n933jfsunqakcssnmn
+
+D9FA7C4C6EF56DC4FF423BAAE6D495DBFF663D034A72D1DC7D52CBFE7D1E6858F9D523AC0A7A5C34077638E4DD1A701BD017842789982041
+56 0  bitcoincash:qmvl5lzvdm6km38lgga64ek5jhdl7e3aqd9895wu04fvhlnare5937w4ywkq57juxsrhvw8ym5d8qx7sz7zz0zvcypqscw8jd03f
+56 1  bchtest:pmvl5lzvdm6km38lgga64ek5jhdl7e3aqd9895wu04fvhlnare5937w4ywkq57juxsrhvw8ym5d8qx7sz7zz0zvcypqs6kgdsg2g
+56 1  pref:pmvl5lzvdm6km38lgga64ek5jhdl7e3aqd9895wu04fvhlnare5937w4ywkq57juxsrhvw8ym5d8qx7sz7zz0zvcypqsammyqffl
+56 15 prefix:0mvl5lzvdm6km38lgga64ek5jhdl7e3aqd9895wu04fvhlnare5937w4ywkq57juxsrhvw8ym5d8qx7sz7zz0zvcypqsgjrqpnw8
+
+D0F346310D5513D9E01E299978624BA883E6BDA8F4C60883C10F28C2967E67EC77ECC7EEEAEAFC6DA89FAD72D11AC961E164678B868AEEEC5F2C1DA08884175B
+64 0  bitcoincash:qlg0x333p4238k0qrc5ej7rzfw5g8e4a4r6vvzyrcy8j3s5k0en7calvclhw46hudk5flttj6ydvjc0pv3nchp52amk97tqa5zygg96mtky5sv5w
+64 1  bchtest:plg0x333p4238k0qrc5ej7rzfw5g8e4a4r6vvzyrcy8j3s5k0en7calvclhw46hudk5flttj6ydvjc0pv3nchp52amk97tqa5zygg96mc773cwez
+64 1  pref:plg0x333p4238k0qrc5ej7rzfw5g8e4a4r6vvzyrcy8j3s5k0en7calvclhw46hudk5flttj6ydvjc0pv3nchp52amk97tqa5zygg96mg7pj3lh8
+64 15 prefix:0lg0x333p4238k0qrc5ej7rzfw5g8e4a4r6vvzyrcy8j3s5k0en7calvclhw46hudk5flttj6ydvjc0pv3nchp52amk97tqa5zygg96ms92w6845
+"""
+
+class unit_tests:
+
+	@property
+	def vectors(self):
+		t = namedtuple('vectors', ['size', 'type', 'addr', 'data'])
+		def gen():
+			for a in vec_data.splitlines():
+				if a:
+					d = a.split()
+					if len(d) == 1:
+						data = d[0].lower()
+					else:
+						yield t(int(d[0]), int(d[1]), d[2], data)
+		return list(gen())
+
+	@property
+	def aliases(self):
+		t = namedtuple('aliases', ['legacy', 'cashaddr'])
+		return [t(*a.split()) for a in alias_data.splitlines() if a]
+
+	def encode(self, name, ut, desc='low-level address encoding'):
+		data = None
+		for v in self.vectors:
+			if not data or data != v.data:
+				data = v.data
+				vmsg(f'\n{data}')
+			vmsg(f'    {v.addr}')
+			ret = cashaddr_encode_addr(v.type, v.size, cashaddr_parse_addr(v.addr).pfx, bytes.fromhex(v.data))
+			assert ret.addr == v.addr
+		return True
+
+	def decode(self, name, ut, desc='low-level address decoding'):
+		data = None
+		for v in self.vectors:
+			if not data or data != v.data:
+				data = v.data
+				vmsg(f'\n{data}')
+			vmsg(f'    {v.addr}')
+			ret = cashaddr_decode_addr(v.addr)
+			assert ret.bytes.hex() == v.data
+		return True
+
+	def coinaddr(self, name, ut, desc='CoinAddr class'):
+		for e in self.aliases:
+			for addr in (
+					e.cashaddr.upper(),
+					e.cashaddr,
+					e.cashaddr.split(':')[1],
+					e.legacy,
+				):
+				a = CoinAddr(proto, addr)
+				vmsg(addr)
+				assert e.legacy == a.views[1]
+				assert e.cashaddr == a.proto.cashaddr_pfx + ':' + a.views[0]
+			vmsg('')
+		return True
+
+	def errors(self, name, ut, desc='error handling'):
+		# could do these in objtest.py:
+		def bad1(): a = CoinAddr(proto, self.aliases[0].cashaddr.replace('g','G'))
+		def bad2(): a = CoinAddr(proto, 'x' + self.aliases[0].cashaddr)
+		def bad3(): a = CoinAddr(proto, self.aliases[0].cashaddr[:-1])
+		def bad4(): a = CoinAddr(proto, self.aliases[0].cashaddr[:-1]+'i')
+		def bad5(): a = CoinAddr(proto, self.aliases[0].cashaddr[:-1]+'x')
+
+		ut.process_bad_data((
+			('case',     'ObjectInitError', 'mixed case',     bad1),
+			('prefix',   'ObjectInitError', 'invalid prefix', bad2),
+			('data',     'ObjectInitError', 'too short',      bad3),
+			('b32 char', 'ObjectInitError', 'substring',      bad4),
+			('chksum',   'ObjectInitError', 'checksum',       bad5),
+		))
+		return True

+ 70 - 64
test/unit_tests_d/ut_lockable.py

@@ -4,16 +4,12 @@
 test.unit_tests_d.ut_lockable: unit test for the MMGen suite's Lockable class
 """
 
-from ..include.common import qmsg,qmsg_r,vmsg
+from decimal import Decimal
+from mmgen.base_obj import AttrCtrl, Lockable
 
-class unit_test:
+class unit_tests:
 
-	def run_test(self,name,ut):
-
-		from mmgen.base_obj import AttrCtrl,Lockable
-		from decimal import Decimal
-
-		qmsg_r('Testing class AttrCtrl...')
+	def attrctl(self, name, ut, desc='AttrCtrl class'):
 
 		class MyAttrCtrl(AttrCtrl):
 			_autolock = False
@@ -52,8 +48,21 @@ class unit_test:
 		assert acdn.bar is None, f'{acdn.bar}'
 		assert acdn.baz is None, f'{acdn.baz}'
 
-		qmsg('OK')
-		qmsg_r('Testing class Lockable...')
+		def bad1(): ac.x = 1
+		def bad2(): acc.foo = 1
+		def bad3(): aca._lock()
+		def bad4(): acdn.baz = None
+
+		ut.process_bad_data((
+			('attr',            'AttributeError', 'has no attr', bad1),
+			('attr type',       'AttributeError', 'type',        bad2),
+			('call to _lock()', 'AssertionError', 'only once',   bad3),
+			('attr',            'AttributeError', 'has no attr', bad4),
+		))
+
+		return True
+
+	def base(self, name, ut, desc='Lockable class'):
 
 		class MyLockable(Lockable): # class without attrs
 			_autolock = False
@@ -83,6 +92,32 @@ class unit_test:
 
 		lc.epsilon = [0]
 
+		def bad1(): lc.foo = 'fooval3'
+		def bad2(): lc.baz = 'str'
+		def bad3(): lc.qux  = 2
+		def bad4(): lc.x = 1
+		def bad5(): lc.alpha = 0
+		def bad6(): lc.beta = False
+		def bad7(): lc.gamma = Decimal('0')
+		def bad8(): lc.delta = float(0)
+		def bad9(): lc.epsilon = [0]
+
+		ut.process_bad_data((
+			('attr (can’t reset)', 'AttributeError', 'reset',       bad1),
+			('attr type (2)',      'AttributeError', 'type',        bad2),
+			('attr (can’t set)',   'AttributeError', 'read-only',   bad3),
+			('attr (2)',           'AttributeError', 'has no attr', bad4),
+			('attr (can’t reset)', 'AttributeError', 'reset',       bad5),
+			('attr (can’t reset)', 'AttributeError', 'reset',       bad6),
+			('attr (can’t reset)', 'AttributeError', 'reset',       bad7),
+			('attr (can’t reset)', 'AttributeError', 'reset',       bad8),
+			('attr (can’t reset)', 'AttributeError', 'reset',       bad9),
+		))
+
+		return True
+
+	def classattr(self, name, ut, desc='Lockable class with class attrs'):
+
 		class MyLockableClsCheck(Lockable): # class with attrs
 			_autolock = False
 			_use_class_attr = True
@@ -102,8 +137,21 @@ class unit_test:
 		lcc.baz = 3.2
 		lcc.baz = 3.1 # baz is in both lists
 
-		qmsg('OK')
-		qmsg_r('Testing class Lockable with autolock...')
+		def bad1(): lcc.bar = 'str'
+		def bad2(): lcc.qux = 'quxval2'
+		def bad3(): lcc.foo = 'fooval3'
+		def bad4(): lcc.x = 1
+
+		ut.process_bad_data((
+			('attr type (3)',      'AttributeError', 'type',        bad1),
+			('attr (can’t set)',   'AttributeError', 'read-only',   bad2),
+			('attr (can’t reset)', 'AttributeError', 'reset',       bad3),
+			('attr (3)',           'AttributeError', 'has no attr', bad4),
+		))
+
+		return True
+
+	def autolock(self, name, ut, desc='Lockable class with autolock'):
 
 		class MyLockableAutolock(Lockable):
 			def __init__(self):
@@ -126,60 +174,18 @@ class unit_test:
 			_set_ok = ('foo','bar')
 			foo = 1
 
-		qmsg('OK')
-		qmsg_r('Checking error handling...')
-		vmsg('')
-
-		def bad1(): ac.x = 1
-		def bad2(): acc.foo = 1
-		def bad3(): lc.foo = 'fooval3'
-		def bad4(): lc.baz = 'str'
-		def bad5(): lcc.bar = 'str'
-		def bad6(): lc.qux  = 2
-		def bad7(): lcc.qux = 'quxval2'
-		def bad8(): lcc.foo = 'fooval3'
-		def bad9(): lc.x = 1
-		def bad10(): lcc.x = 1
-
-		def bad11(): lc.alpha = 0
-		def bad12(): lc.beta = False
-		def bad13(): lc.gamma = Decimal('0')
-		def bad14(): lc.delta = float(0)
-		def bad15(): lc.epsilon = [0]
-
-		def bad16(): lca.foo = None
-		def bad17(): MyLockableBad()
-		def bad18(): aca._lock()
-
-		def bad19(): acdn.baz = None
-		def bad20(): lcdn.foo = 1
-		def bad21(): lcdn.bar = None
-		def bad22(): del lcdn.foo
+		def bad1(): lca.foo = None
+		def bad2(): MyLockableBad()
+		def bad3(): lcdn.foo = 1
+		def bad4(): lcdn.bar = None
+		def bad5(): del lcdn.foo
 
 		ut.process_bad_data((
-			('attr (1)',           'AttributeError', 'has no attr', bad1 ),
-			('attr (2)',           'AttributeError', 'has no attr', bad9 ),
-			('attr (3)',           'AttributeError', 'has no attr', bad10 ),
-			('attr (4)',           'AttributeError', 'has no attr', bad19 ),
-			('attr (5)',           'AttributeError', 'has no attr', bad21 ),
-			('attr type (1)',      'AttributeError', 'type',        bad2 ),
-			("attr type (2)",      'AttributeError', 'type',        bad4 ),
-			("attr type (3)",      'AttributeError', 'type',        bad5 ),
-			("attr (can't set)",   'AttributeError', 'read-only',   bad6 ),
-			("attr (can't set)",   'AttributeError', 'read-only',   bad7 ),
-			("attr (can't set)",   'AttributeError', 'read-only',   bad20 ),
-			("attr (can't reset)", 'AttributeError', 'reset',       bad3 ),
-			("attr (can't reset)", 'AttributeError', 'reset',       bad8 ),
-			("attr (can't reset)", 'AttributeError', 'reset',       bad11 ),
-			("attr (can't reset)", 'AttributeError', 'reset',       bad12 ),
-			("attr (can't reset)", 'AttributeError', 'reset',       bad13 ),
-			("attr (can't reset)", 'AttributeError', 'reset',       bad14 ),
-			("attr (can't reset)", 'AttributeError', 'reset',       bad15 ),
-			("attr (can't set)",   'AttributeError', 'read-only',   bad16 ),
-			("attr (bad _set_ok)", 'AssertionError', 'not found in',bad17 ),
-			("attr (can’t delete)",'AttributeError', 'not be delet',bad22 ),
-			("call to _lock()",    'AssertionError', 'only once',   bad18 ),
+			('attr (can’t set)',    'AttributeError', 'read-only',    bad1),
+			('attr (bad _set_ok)',  'AssertionError', 'not found in', bad2),
+			('attr (can’t set)',    'AttributeError', 'read-only',    bad3),
+			('attr (5)',            'AttributeError', 'has no attr',  bad4),
+			('attr (can’t delete)', 'AttributeError', 'not be delet', bad5),
 		))
 
-		qmsg('OK')
 		return True

+ 3 - 3
test/unit_tests_d/ut_misc.py

@@ -12,7 +12,7 @@ from ..include.common import vmsg
 
 class unit_tests:
 
-	def format_elapsed_hr(self, name, ut):
+	def format_elapsed_hr(self, name, ut, desc='function util.format_elapsed_hr()'):
 		from mmgen.util2 import format_elapsed_hr
 
 		vectors = (
@@ -63,7 +63,7 @@ class unit_tests:
 
 		return True
 
-	def xmrwallet_uarg_info(self,name,ut): # WIP
+	def xmrwallet_uarg_info(self, name, ut, desc='dict xmrwallet.xmrwallet_uarg_info'): # WIP
 		from mmgen.xmrwallet import xmrwallet_uarg_info as uarg_info
 		vs = namedtuple('vector_data', ['text', 'groups'])
 		fs = '{:16} {}'
@@ -95,7 +95,7 @@ class unit_tests:
 
 		return True
 
-	def pyversion(self,name,ut):
+	def pyversion(self, name, ut, desc='class pyversion.PythonVersion'):
 		from mmgen.pyversion import PythonVersion,python_version
 
 		ver = {}

+ 7 - 16
test/unit_tests_d/ut_mn_entry.py

@@ -4,10 +4,10 @@
 test.unit_tests_d.ut_mn_entry: Mnemonic user entry unit test for the MMGen suite
 """
 
-from mmgen.util import msg,msg_r
-from ..include.common import cfg,qmsg
+from mmgen.mn_entry import mn_entry
+from ..include.common import cfg, vmsg
 
-class unit_test:
+class unit_tests:
 
 	vectors = {
 		'mmgen':   {
@@ -40,26 +40,19 @@ class unit_test:
 		'bip39':   { 'usl': 4, 'sw': 3, 'lw': 8 },
 	}
 
-	def run_test(self,name,ut):
-
-		msg_r('Testing MnemonicEntry methods...')
-
-		from mmgen.mn_entry import mn_entry
-
-		msg_r('\nTesting computed wordlist constants...')
+	def wl(self, name, ut, desc='MnemonicEntry - computed wordlist constants'):
 		for wl_id in self.vectors:
 			for j,k in (('uniq_ss_len','usl'),('shortest_word','sw'),('longest_word','lw')):
 				a = getattr(mn_entry( cfg, wl_id ),j)
 				b = self.vectors[wl_id][k]
 				assert a == b, f'{wl_id}:{j} {a} != {b}'
-		msg('OK')
+		return True
 
-		msg_r('Testing idx()...')
-		qmsg('')
+	def idx(self, name, ut, desc='MnemonicEntry - idx()'):
 		junk = 'a g z aa gg zz aaa ggg zzz aaaa gggg zzzz aaaaaaaaaaaaaa gggggggggggggg zzzzzzzzzzzzzz'
 		for wl_id in self.vectors:
 			m = mn_entry( cfg, wl_id )
-			qmsg('Wordlist: '+wl_id)
+			vmsg('Wordlist: '+wl_id)
 			for entry_mode in ('full','short'):
 				for a,word in enumerate(m.wl):
 					b = m.idx(word,entry_mode)
@@ -78,6 +71,4 @@ class unit_test:
 						assert type(b) is tuple, (type(b),tuple)
 					elif type(chk) is int:
 						assert b == chk, (b,chk)
-		msg('OK')
-
 		return True

+ 8 - 10
test/unit_tests_d/ut_msg.py

@@ -6,7 +6,7 @@ test.unit_tests_d.ut_msg: message signing unit tests for the MMGen suite
 
 import os
 
-from mmgen.util import msg,bmsg,pumsg,suf
+from mmgen.util import msg, pumsg, suf
 from mmgen.protocol import CoinProtocol
 from mmgen.msg import NewMsg,UnsignedMsg,SignedMsg,SignedOnlineMsg,ExportedMsgSigs
 from mmgen.addr import MMGenID
@@ -41,8 +41,6 @@ async def run_test(network_id,chksum,msghash_type='raw'):
 	if not cfg.verbose:
 		silence()
 
-	bmsg(f'\nTesting {coin.upper()} {network.upper()}:\n')
-
 	m = get_obj(coin,network,msghash_type)
 
 	if m.proto.sign_mode == 'daemon':
@@ -131,23 +129,23 @@ class unit_tests:
 
 	altcoin_deps = ('ltc','bch','eth','eth_raw')
 
-	def btc(self,name,ut):
+	def btc(self, name, ut, desc='Bitcoin mainnet'):
 		return run_test('btc','AA0DB5')
 
-	def btc_tn(self,name,ut):
+	def btc_tn(self, name, ut, desc='Bitcoin testnet'):
 		return run_test('btc_tn','A88E1D')
 
-	def btc_rt(self,name,ut):
+	def btc_rt(self, name, ut, desc='Bitcoin regtest'):
 		return run_test('btc_rt','578018')
 
-	def ltc(self,name,ut):
+	def ltc(self, name, ut, desc='Litecoin mainnet'):
 		return run_test('ltc','BA7549')
 
-	def bch(self,name,ut):
+	def bch(self, name, ut, desc='Bitcoin Cash mainnet'):
 		return run_test('bch','1B8065')
 
-	def eth(self,name,ut):
+	def eth(self, name, ut, desc='Ethereum mainnet'):
 		return run_test('eth','35BAD9',msghash_type='eth_sign')
 
-	def eth_raw(self,name,ut):
+	def eth_raw(self, name, ut, desc='Ethereum mainnet (raw message)'):
 		return run_test('eth','9D900C')

+ 6 - 8
test/unit_tests_d/ut_obj.py

@@ -6,11 +6,11 @@ test.unit_tests_d.ut_obj: data object unit tests for the MMGen suite
 
 from decimal import Decimal
 
-from ..include.common import qmsg,qmsg_r,vmsg
+from ..include.common import vmsg
 
 class unit_tests:
 
-	def coinamt(self,name,ut):
+	def coinamt(self, name, ut, desc='BTCAmt, LTCAmt, XMRAmt and ETHAmt classes'):
 
 		from mmgen.amt import BTCAmt,LTCAmt,XMRAmt,ETHAmt
 
@@ -27,8 +27,7 @@ class unit_tests:
 					assert res == chk, f'{res} != {chk}'
 					assert type(res) is cls, f'{type(res).__name__} != {cls.__name__}'
 
-			qmsg_r(f'Testing {cls.__name__} arithmetic operations...')
-			vmsg('')
+			vmsg(f'\nTesting {cls.__name__} arithmetic operations...')
 
 			A,B   = ( Decimal(aa), Decimal(bb) )
 			a,b   = ( cls(aa),  cls(bb) )
@@ -51,9 +50,7 @@ class unit_tests:
 			do('a * b / a', a * b / a, A * B / A)
 			do('a * b / b', a * b / b, A * B / B)
 
-			qmsg('OK')
-			qmsg_r(f'Checking {cls.__name__} error handling...')
-			vmsg('')
+			vmsg(f'\nChecking {cls.__name__} error handling...')
 
 			bad_data = (
 				('negation',          'NotImplementedError', 'not implemented',    lambda: -a ),
@@ -72,5 +69,6 @@ class unit_tests:
 
 			ut.process_bad_data(bad_data)
 
-			qmsg('OK')
+			vmsg('OK')
+
 		return True

+ 54 - 62
test/unit_tests_d/ut_xmrseed.py

@@ -6,11 +6,11 @@ test/unit_tests_d/ut_xmrseed: Monero mnemonic unit test for the MMGen suite
 
 altcoin_dep = True
 
-from mmgen.util import msg,msg_r,ymsg
+from mmgen.util import ymsg
+from mmgen.xmrseed import xmrseed
+from ..include.common import cfg, vmsg
 
-from ..include.common import cfg,qmsg,vmsg
-
-class unit_test:
+class unit_tests:
 
 	vectors = ( # private keys are reduced
 		(   '148d78d2aba7dbca5cd8f6abcfb0b3c009ffbdbea1ff373d50ed94d78286640e', # Monero repo
@@ -55,53 +55,49 @@ class unit_test:
 		),
 	)
 
-	def run_test(self,name,ut):
-
-		def test_fromhex(b):
-			vmsg('')
-			qmsg('Checking seed to mnemonic conversion:')
-			for privhex,chk in self.vectors:
-				vmsg(f'    {chk}')
-				chk = tuple(chk.split())
-				res = b.fromhex(privhex)
-				if use_monero_python:
-					mp_chk = tuple( wl.encode(privhex).split() )
-					assert res == mp_chk, f'check failed:\nres: {res}\nchk: {chk}'
-				assert res == chk, f'check failed:\nres: {res}\nchk: {chk}'
-
-		def test_tohex(b):
-			vmsg('')
-			qmsg('Checking mnemonic to seed conversion:')
-			for chk,words in self.vectors:
-				vmsg(f'    {chk}')
-				res = b.tohex( words.split() )
-				if use_monero_python:
-					mp_chk = wl.decode( words )
-					assert res == mp_chk, f'check failed:\nres: {res}\nchk: {mp_chk}'
-				assert res == chk, f'check failed:\nres: {res}\nchk: {chk}'
-
-		msg_r('Testing xmrseed conversion routines...')
-		qmsg('')
-
-		from mmgen.xmrseed import xmrseed
+	@property
+	def _use_monero_python(self):
+		if not hasattr(self,'_use_monero_python_'):
+			try:
+				from monero.wordlists.english import English
+				self.wl = English()
+			except ImportError:
+				self._use_monero_python_ = False
+				ymsg('Warning: unable to import monero-python, skipping external library checks')
+			else:
+				self._use_monero_python_ = True
+		return self._use_monero_python_
+
+	def wordlist(self, name, ut, desc='Monero wordlist'):
+		xmrseed().check_wordlist(cfg)
+		return True
 
+	def fromhex(self, name, ut, desc='fromhex() method'):
 		b = xmrseed()
-		b.check_wordlist(cfg)
-
-		try:
-			from monero.wordlists.english import English
-			wl = English()
-		except ImportError:
-			use_monero_python = False
-			ymsg('Warning: unable to import monero-python, skipping external library checks')
-		else:
-			use_monero_python = True
+		vmsg('Checking seed to mnemonic conversion:')
+		for privhex, chk in self.vectors:
+			vmsg(f'    {chk}')
+			chk = tuple(chk.split())
+			res = b.fromhex(privhex)
+			if self._use_monero_python:
+				mp_chk = tuple( self.wl.encode(privhex).split() )
+				assert res == mp_chk, f'check failed:\nres: {res}\nchk: {chk}'
+			assert res == chk, f'check failed:\nres: {res}\nchk: {chk}'
+		return True
 
-		test_fromhex(b)
-		test_tohex(b)
+	def tohex(self, name, ut, desc='tohex() method'):
+		b = xmrseed()
+		vmsg('Checking mnemonic to seed conversion:')
+		for chk, words in self.vectors:
+			vmsg(f'    {chk}')
+			res = b.tohex( words.split() )
+			if self._use_monero_python:
+				mp_chk = self.wl.decode( words )
+				assert res == mp_chk, f'check failed:\nres: {res}\nchk: {mp_chk}'
+			assert res == chk, f'check failed:\nres: {res}\nchk: {chk}'
+		return True
 
-		vmsg('')
-		qmsg('Checking error handling:')
+	def errors(self, name, ut, desc='error handling'):
 
 		bad_chksum_mn = ('abbey ' * 21 + 'bamboo jaws jerseys donuts').split()
 		bad_word_mn = "admire zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo".split()
@@ -110,6 +106,7 @@ class unit_test:
 		good_hex = self.vectors[0][0]
 		bad_len_mn = good_mn[:22]
 
+		b = xmrseed()
 		th = b.tohex
 		fh = b.fromhex
 
@@ -118,20 +115,15 @@ class unit_test:
 		ase = 'AssertionError'
 		mne = 'MnemonicError'
 
-		bad_data = (
-			('hex',               hse, 'not a hexadecimal',     lambda:fh('xx')),
-			('seed len',          sle, 'invalid seed byte len', lambda:fh(bad_seed)),
-			('mnemonic type',     ase, 'must be list',          lambda:th('string')),
-			('pad arg (fromhex)', ase, "invalid 'pad' arg",     lambda:fh(good_hex,pad=23)),
-			('pad arg (tohex)',   ase, "invalid 'pad' arg",     lambda:th(good_mn,pad=23)),
-			('word',              mne, "not in Monero",         lambda:th(bad_word_mn)),
-			('checksum',          mne, "checksum",              lambda:th(bad_chksum_mn)),
-			('seed phrase len',   mne, "phrase len",            lambda:th(bad_len_mn)),
-		)
-
-		ut.process_bad_data(bad_data)
-
-		vmsg('')
-		msg('OK')
+		ut.process_bad_data((
+			('hex',               hse, 'not a hexadecimal',     lambda: fh('xx')),
+			('seed len',          sle, 'invalid seed byte len', lambda: fh(bad_seed)),
+			('mnemonic type',     ase, 'must be list',          lambda: th('string')),
+			('pad arg (fromhex)', ase, "invalid 'pad' arg",     lambda: fh(good_hex,pad=23)),
+			('pad arg (tohex)',   ase, "invalid 'pad' arg",     lambda: th(good_mn,pad=23)),
+			('word',              mne, "not in Monero",         lambda: th(bad_word_mn)),
+			('checksum',          mne, "checksum",              lambda: th(bad_chksum_mn)),
+			('seed phrase len',   mne, "phrase len",            lambda: th(bad_len_mn)),
+		))
 
 		return True