16 Commits 1132d0ff6b ... be17d06708

Author SHA1 Message Date
  The MMGen Project be17d06708 proto.xmr.tw.view: add age sort, locked balance display 1 week ago
  The MMGen Project ba732b4610 cmdtest.py xmr_compat: cleanups 1 week ago
  The MMGen Project 01cdc5ebab proto.xmr.tw.unspent: cleanups 1 week ago
  The MMGen Project acc71466ad CoinAmt: amt formatting cleanups 1 week ago
  The MMGen Project fcbd5b4fa3 xmrwallet.rpc: whitespace 1 week ago
  The MMGen Project 0ae18743c9 tw.view: display Seed ID if applicable 1 week ago
  The MMGen Project 78fa526a96 proto.xmr.tw.unspent: check for out-of-order accounts 1 week ago
  The MMGen Project 4f3595c0a1 proto.xmr.tw.unspent: reimplement `accts_data` as tuple 1 week ago
  The MMGen Project c4abd4060f xmrwallet: increase truncated address width 1 week ago
  The MMGen Project e3e215f0c6 xmrwallet: silence messages for `compat_call` 1 week ago
  The MMGen Project f86c478cda xmrwallet: whitespace 1 week ago
  The MMGen Project 9d655ed467 autosign.Signable: relocate methods, improve class inheritance 1 week ago
  The MMGen Project 461c2db27e autosign: cleanups 1 week ago
  The MMGen Project 7edef50ed4 whitespace, string formatting 1 week ago
  The MMGen Project f078dbfd8a tw.view: variable rename 1 week ago
  The MMGen Project e658cb91df tw.view: make `get_idx()` and `get_idx_from_user()` into methods 1 week ago

+ 24 - 7
mmgen/amt.py

@@ -88,19 +88,36 @@ class CoinAmt(Decimal, Hilite, InitErrors): # abstract class
 					a.rjust(iwidth) + ' ' + ''.ljust(prec or self.max_prec),
 					a.rjust(iwidth) + ' ' + ''.ljust(prec or self.max_prec),
 					color = color)
 					color = color)
 
 
+	# same as fmt(), only with color override:
+	def fmt2(self, iwidth=1, /, *, color=False, prec=None, color_override=''):
+		match str(self).split('.', 1):
+			case [a, b]:
+				return self.colorize2(
+					a.rjust(iwidth) + '.' + b.ljust(prec or self.max_prec)[:prec or self.max_prec],
+					color = color,
+					color_override = color_override)
+			case [a]:
+				return self.colorize2(
+					a.rjust(iwidth) + ' ' + ''.ljust(prec or self.max_prec),
+					color = color,
+					color_override = color_override)
+
 	def hl(self, *, color=True):
 	def hl(self, *, color=True):
 		return self.colorize(str(self), color=color)
 		return self.colorize(str(self), color=color)
 
 
+	def hl2(self, *, color=True, color_override=''):
+		return self.colorize2(str(self), color=color, color_override=color_override)
+
 	# fancy highlighting with coin unit, enclosure, formatting
 	# fancy highlighting with coin unit, enclosure, formatting
-	def hl2(self, *, color=True, unit=False, fs='{}', encl=''):
+	def hl3(self, *, color=True, unit=False, fs='{}', encl='', color_override=''):
 		res = fs.format(self)
 		res = fs.format(self)
-		return (
+		return self.colorize2(
 			encl[:-1]
 			encl[:-1]
-			+ self.colorize(
-				(res.rstrip('0').rstrip('.') if '.' in res else res) +
-				(' ' + self.coin if unit else ''),
-				color = color)
-			+ encl[1:])
+			+ (res.rstrip('0').rstrip('.') if '.' in res else res)
+			+ (' ' + self.coin if unit else '')
+			+ encl[1:],
+			color = color,
+			color_override = color_override)
 
 
 	def __str__(self): # format simply, with no exponential notation
 	def __str__(self): # format simply, with no exponential notation
 		return str(int(self)) if int(self) == self else self.normalize().__format__('f')
 		return str(int(self)) if int(self) == self else self.normalize().__format__('f')

+ 124 - 109
mmgen/autosign.py

@@ -9,7 +9,7 @@
 #   https://gitlab.com/mmgen/mmgen-wallet
 #   https://gitlab.com/mmgen/mmgen-wallet
 
 
 """
 """
-autosign: Auto-sign MMGen transactions, message files and XMR wallet output files
+autosign: Autosign MMGen transactions, message files and XMR wallet output files
 """
 """
 
 
 import sys, os, asyncio
 import sys, os, asyncio
@@ -142,6 +142,7 @@ class Signable:
 		clean_all = False
 		clean_all = False
 		multiple_ok = True
 		multiple_ok = True
 		action_desc = 'signed'
 		action_desc = 'signed'
+		fail_msg = 'failed to sign'
 
 
 		def __init__(self, parent):
 		def __init__(self, parent):
 			self.parent = parent
 			self.parent = parent
@@ -149,30 +150,10 @@ class Signable:
 			self.dir = getattr(parent, self.dir_name)
 			self.dir = getattr(parent, self.dir_name)
 			self.name = type(self).__name__
 			self.name = type(self).__name__
 
 
-		@property
-		def submitted(self):
-			return self._processed('_submitted', self.subext)
-
-		def _processed(self, attrname, ext):
-			if not hasattr(self, attrname):
-				setattr(self, attrname, tuple(f for f in sorted(self.dir.iterdir()) if f.name.endswith('.'+ext)))
-			return getattr(self, attrname)
-
 		@property
 		@property
 		def unsigned(self):
 		def unsigned(self):
 			return self._unprocessed('_unsigned', self.rawext, self.sigext)
 			return self._unprocessed('_unsigned', self.rawext, self.sigext)
 
 
-		@property
-		def unsubmitted(self):
-			return self._unprocessed('_unsubmitted', self.sigext, self.subext)
-
-		@property
-		def unsubmitted_raw(self):
-			return self._unprocessed('_unsubmitted_raw', self.rawext, self.subext)
-
-		unsent = unsubmitted
-		unsent_raw = unsubmitted_raw
-
 		def _unprocessed(self, attrname, rawext, sigext):
 		def _unprocessed(self, attrname, rawext, sigext):
 			if not hasattr(self, attrname):
 			if not hasattr(self, attrname):
 				dirlist = sorted(self.dir.iterdir())
 				dirlist = sorted(self.dir.iterdir())
@@ -191,81 +172,23 @@ class Signable:
 				b = '  {}\n'.format('\n  '.join(
 				b = '  {}\n'.format('\n  '.join(
 					self.gen_bad_list(sorted(bad_files, key=lambda f: f.name))))))
 					self.gen_bad_list(sorted(bad_files, key=lambda f: f.name))))))
 
 
-		def die_wrong_num_txs(self, tx_type, *, msg=None, desc=None, show_dir=False):
-			num_txs = len(getattr(self, tx_type))
-			die('AutosignTXError', "{m}{a} {b} transaction{c} {d} {e}!".format(
-				m = msg + '\n' if msg else '',
-				a = 'One' if num_txs == 1 else 'More than one' if num_txs else 'No',
-				b = desc or tx_type,
-				c = suf(num_txs),
-				d = 'already present' if num_txs else 'present',
-				e = f'in ‘{getattr(self.parent, self.dir_name)}’' if show_dir else 'on removable device'))
-
-		def check_create_ok(self):
-			if len(self.unsigned):
-				self.die_wrong_num_txs('unsigned', msg='Cannot create transaction')
-			if len(self.unsent):
-				die('AutosignTXError', 'Cannot create transaction: you have an unsent transaction')
-
-		def get_unsubmitted(self, tx_type='unsubmitted'):
-			if len(self.unsubmitted) == 1:
-				return self.unsubmitted[0]
-			else:
-				self.die_wrong_num_txs(tx_type)
-
-		def get_unsent(self):
-			return self.get_unsubmitted('unsent')
-
-		def get_submitted(self):
-			if len(self.submitted) == 0:
-				self.die_wrong_num_txs('submitted')
-			else:
-				return self.submitted
-
-		def get_abortable(self):
-			if len(self.unsent_raw) != 1:
-				self.die_wrong_num_txs('unsent_raw', desc='unsent')
-			if len(self.unsent) > 1:
-				self.die_wrong_num_txs('unsent')
-			if self.unsent:
-				if self.unsent[0].stem != self.unsent_raw[0].stem:
-					die(1, f'{self.unsent[0]}, {self.unsent_raw[0]}: file mismatch')
-			return self.unsent_raw + self.unsent
-
-		def shred_abortable(self):
-			files = self.get_abortable() # raises AutosignTXError if no unsent TXs available
-			keypress_confirm(
-				self.cfg,
-				'The following file{} will be securely deleted:\n{}\nOK?'.format(
-					suf(files),
-					fmt_list(map(str, files), fmt='col', indent='  ')),
-					do_exit = True)
-			for fn in files:
-				msg(f'Shredding file ‘{fn}’')
-				shred_file(self.cfg, fn, iterations=15)
-			sys.exit(0)
-
-		async def get_last_created(self):
-			from .tx import CompletedTX
-			ext = '.' + Signable.automount_transaction.subext
-			files = [f for f in self.dir.iterdir() if f.name.endswith(ext)]
-			return sorted(
-				[await CompletedTX(cfg=self.cfg, filename=str(txfile), quiet_open=True) for txfile in files],
-				key = lambda x: x.timestamp)[-1]
+		def gen_bad_list(self, bad_files):
+			for f in bad_files:
+				yield red(f.name)
 
 
 	class transaction(base):
 	class transaction(base):
 		desc = 'non-automount transaction'
 		desc = 'non-automount transaction'
 		rawext = 'rawtx'
 		rawext = 'rawtx'
 		sigext = 'sigtx'
 		sigext = 'sigtx'
 		dir_name = 'tx_dir'
 		dir_name = 'tx_dir'
-		fail_msg = 'failed to sign'
+		automount = False
 
 
 		async def sign(self, f):
 		async def sign(self, f):
 			from .tx import UnsignedTX
 			from .tx import UnsignedTX
 			tx1 = UnsignedTX(
 			tx1 = UnsignedTX(
 					cfg       = self.cfg,
 					cfg       = self.cfg,
 					filename  = f,
 					filename  = f,
-					automount = self.name=='automount_transaction')
+					automount = self.automount)
 			if tx1.proto.sign_mode == 'daemon':
 			if tx1.proto.sign_mode == 'daemon':
 				from .rpc import rpc_init
 				from .rpc import rpc_init
 				tx1.rpc = await rpc_init(self.cfg, tx1.proto, ignore_wallet=True)
 				tx1.rpc = await rpc_init(self.cfg, tx1.proto, ignore_wallet=True)
@@ -318,19 +241,101 @@ class Signable:
 			else:
 			else:
 				msg('\nNo non-MMGen outputs')
 				msg('\nNo non-MMGen outputs')
 
 
-		def gen_bad_list(self, bad_files):
-			for f in bad_files:
-				yield red(f.name)
-
 	class automount_transaction(transaction):
 	class automount_transaction(transaction):
-		desc   = 'automount transaction'
+		desc = 'automount transaction'
 		dir_name = 'txauto_dir'
 		dir_name = 'txauto_dir'
 		rawext = 'arawtx'
 		rawext = 'arawtx'
 		sigext = 'asigtx'
 		sigext = 'asigtx'
 		subext = 'asubtx'
 		subext = 'asubtx'
 		multiple_ok = False
 		multiple_ok = False
+		automount = True
 
 
-	class xmr_signable(transaction): # mixin class
+		@property
+		def unsubmitted(self):
+			return self._unprocessed('_unsubmitted', self.sigext, self.subext)
+
+		@property
+		def unsubmitted_raw(self):
+			return self._unprocessed('_unsubmitted_raw', self.rawext, self.subext)
+
+		unsent = unsubmitted
+		unsent_raw = unsubmitted_raw
+
+		@property
+		def submitted(self):
+			return self._processed('_submitted', self.subext)
+
+		def _processed(self, attrname, ext):
+			if not hasattr(self, attrname):
+				setattr(self, attrname, tuple(f for f in sorted(self.dir.iterdir())
+					if f.name.endswith('.' + ext)))
+			return getattr(self, attrname)
+
+		def die_wrong_num_txs(self, tx_type, *, msg=None, desc=None, show_dir=False):
+			num_txs = len(getattr(self, tx_type))
+			die('AutosignTXError', '{m}{a} {b} transaction{c} {d} {e}!'.format(
+				m = msg + '\n' if msg else '',
+				a = 'One' if num_txs == 1 else 'More than one' if num_txs else 'No',
+				b = desc or tx_type,
+				c = suf(num_txs),
+				d = 'already present' if num_txs else 'present',
+				e = f'in ‘{getattr(self.parent, self.dir_name)}’'
+					if show_dir else 'on removable device'))
+
+		def check_create_ok(self):
+			if len(self.unsigned):
+				self.die_wrong_num_txs('unsigned', msg='Cannot create transaction')
+			if len(self.unsent):
+				die('AutosignTXError', 'Cannot create transaction: you have an unsent transaction')
+
+		def get_unsubmitted(self, tx_type='unsubmitted'):
+			if len(self.unsubmitted) == 1:
+				return self.unsubmitted[0]
+			else:
+				self.die_wrong_num_txs(tx_type)
+
+		def get_unsent(self):
+			return self.get_unsubmitted('unsent')
+
+		def get_submitted(self):
+			if len(self.submitted) == 0:
+				self.die_wrong_num_txs('submitted')
+			else:
+				return self.submitted
+
+		def get_abortable(self):
+			if len(self.unsent_raw) != 1:
+				self.die_wrong_num_txs('unsent_raw', desc='unsent')
+			if len(self.unsent) > 1:
+				self.die_wrong_num_txs('unsent')
+			if self.unsent:
+				if self.unsent[0].stem != self.unsent_raw[0].stem:
+					die(1, f'{self.unsent[0]}, {self.unsent_raw[0]}: file mismatch')
+			return self.unsent_raw + self.unsent
+
+		def shred_abortable(self):
+			files = self.get_abortable() # raises AutosignTXError if no unsent TXs available
+			keypress_confirm(
+				self.cfg,
+				'The following file{} will be securely deleted:\n{}\nOK?'.format(
+					suf(files),
+					fmt_list(map(str, files), fmt='col', indent='  ')),
+					do_exit = True)
+			for fn in files:
+				msg(f'Shredding file ‘{fn}’')
+				shred_file(self.cfg, fn, iterations=15)
+			sys.exit(0)
+
+		async def get_last_created(self):
+			from .tx import CompletedTX
+			files = [f for f in self.dir.iterdir() if f.name.endswith(self.subext)]
+			return sorted(
+				[await CompletedTX(cfg=self.cfg, filename=str(txfile), quiet_open=True)
+					for txfile in files],
+				key = lambda x: x.timestamp)[-1]
+
+	class xmr_signable: # mixin class
+		automount = True
 
 
 		def need_daemon_restart(self, m, new_idx):
 		def need_daemon_restart(self, m, new_idx):
 			old_idx = self.parent.xmr_cur_wallet_idx
 			old_idx = self.parent.xmr_cur_wallet_idx
@@ -341,11 +346,12 @@ class Signable:
 			bmsg('\nAutosign summary:')
 			bmsg('\nAutosign summary:')
 			msg('\n'.join(s.get_info(indent='  ') for s in signables) + self.summary_footer)
 			msg('\n'.join(s.get_info(indent='  ') for s in signables) + self.summary_footer)
 
 
-	class xmr_transaction(xmr_signable):
+	class xmr_transaction(xmr_signable, automount_transaction):
 		dir_name = 'xmr_tx_dir'
 		dir_name = 'xmr_tx_dir'
 		desc = 'Monero transaction'
 		desc = 'Monero transaction'
+		rawext = 'rawtx'
+		sigext = 'sigtx'
 		subext = 'subtx'
 		subext = 'subtx'
-		multiple_ok = False
 		summary_footer = ''
 		summary_footer = ''
 
 
 		async def sign(self, f):
 		async def sign(self, f):
@@ -361,7 +367,7 @@ class Signable:
 			tx2.write(ask_write=False)
 			tx2.write(ask_write=False)
 			return tx2
 			return tx2
 
 
-	class xmr_wallet_outputs_file(xmr_signable):
+	class xmr_wallet_outputs_file(xmr_signable, base):
 		desc = 'Monero wallet outputs file'
 		desc = 'Monero wallet outputs file'
 		rawext = 'raw'
 		rawext = 'raw'
 		sigext = 'sig'
 		sigext = 'sig'
@@ -405,7 +411,9 @@ class Signable:
 				outdir = self.dir.resolve(),
 				outdir = self.dir.resolve(),
 				ask_overwrite = False)
 				ask_overwrite = False)
 			if m.data.get('failed_sids'):
 			if m.data.get('failed_sids'):
-				die('MsgFileFailedSID', f'Failed Seed IDs: {fmt_list(m.data["failed_sids"], fmt="bare")}')
+				die(
+					'MsgFileFailedSID',
+					f'Failed Seed IDs: {fmt_list(m.data["failed_sids"], fmt="bare")}')
 			return m
 			return m
 
 
 		def print_summary(self, signables):
 		def print_summary(self, signables):
@@ -575,7 +583,9 @@ class Autosign:
 			try:
 			try:
 				dirlist = self.wallet_dir.iterdir()
 				dirlist = self.wallet_dir.iterdir()
 			except:
 			except:
-				die(1, f"Cannot open wallet directory '{self.wallet_dir}'. Did you run ‘mmgen-autosign setup’?")
+				die(1,
+					f'Cannot open wallet directory ‘{self.wallet_dir}’. '
+					'Did you run ‘mmgen-autosign setup’?')
 
 
 			self._wallet_files = [f for f in dirlist if f.suffix == '.mmdat']
 			self._wallet_files = [f for f in dirlist if f.suffix == '.mmdat']
 
 
@@ -611,9 +621,9 @@ class Autosign:
 			redir = None if verbose else DEVNULL
 			redir = None if verbose else DEVNULL
 			if run(self.mount_cmd.split(), stderr=redir, stdout=redir).returncode == 0:
 			if run(self.mount_cmd.split(), stderr=redir, stdout=redir).returncode == 0:
 				if not silent:
 				if not silent:
-					msg(f"Mounting '{self.mountpoint}'")
+					msg(f'Mounting ‘{self.mountpoint}’')
 			else:
 			else:
-				die(1, f'Unable to mount device {self.dev_label} at {self.mountpoint}')
+				die(1, f'Unable to mount device {self.dev_label} at {self.mountpoint}')
 
 
 		for dirname in self.dirs:
 		for dirname in self.dirs:
 			check_or_create(dirname)
 			check_or_create(dirname)
@@ -622,14 +632,14 @@ class Autosign:
 		if self.mountpoint.is_mount():
 		if self.mountpoint.is_mount():
 			run(['sync'], check=True)
 			run(['sync'], check=True)
 			if not silent:
 			if not silent:
-				msg(f"Unmounting '{self.mountpoint}'")
+				msg(f'Unmounting ‘{self.mountpoint}’')
 			redir = None if verbose else DEVNULL
 			redir = None if verbose else DEVNULL
 			run(self.umount_cmd.split(), stdout=redir, check=True)
 			run(self.umount_cmd.split(), stdout=redir, check=True)
 		if not silent:
 		if not silent:
 			bmsg('It is now safe to extract the removable device')
 			bmsg('It is now safe to extract the removable device')
 
 
 	def decrypt_wallets(self):
 	def decrypt_wallets(self):
-		msg(f"Unlocking wallet{suf(self.wallet_files)} with key from ‘{self.keyfile}’")
+		msg(f'Unlocking wallet{suf(self.wallet_files)} with key from ‘{self.keyfile}’')
 		fails = 0
 		fails = 0
 		for wf in self.wallet_files:
 		for wf in self.wallet_files:
 			try:
 			try:
@@ -654,9 +664,10 @@ class Autosign:
 				try:
 				try:
 					ret = await target.sign(f)
 					ret = await target.sign(f)
 				except Exception as e:
 				except Exception as e:
-					ymsg(f"An error occurred with {target.desc} '{f.name}':\n    {type(e).__name__}: {e!s}")
+					ymsg('An error occurred with {} ‘{}’:\n    {}: ‘{}’'.format(
+						target.desc, f.name, type(e).__name__, e))
 				except:
 				except:
-					ymsg(f"An error occurred with {target.desc} '{f.name}'")
+					ymsg('An error occurred with {} ‘{}’'.format(target.desc, f.name))
 				good.append(ret) if ret else bad.append(f)
 				good.append(ret) if ret else bad.append(f)
 				self.cfg._util.qmsg('')
 				self.cfg._util.qmsg('')
 			await asyncio.sleep(0.3)
 			await asyncio.sleep(0.3)
@@ -704,7 +715,7 @@ class Autosign:
 			gmsg('No wallet encryption key on removable device')
 			gmsg('No wallet encryption key on removable device')
 
 
 	def create_key(self):
 	def create_key(self):
-		desc = f"key file '{self.keyfile}'"
+		desc = f'key file ‘{self.keyfile}’'
 		msg('Creating ' + desc)
 		msg('Creating ' + desc)
 		try:
 		try:
 			self.keyfile.write_text(os.urandom(32).hex())
 			self.keyfile.write_text(os.urandom(32).hex())
@@ -747,7 +758,7 @@ class Autosign:
 	def setup(self):
 	def setup(self):
 
 
 		def remove_wallet_dir():
 		def remove_wallet_dir():
-			msg(f"Deleting '{self.wallet_dir}'")
+			msg(f'Deleting ‘{self.wallet_dir}’')
 			import shutil
 			import shutil
 			try:
 			try:
 				shutil.rmtree(self.wallet_dir)
 				shutil.rmtree(self.wallet_dir)
@@ -762,7 +773,7 @@ class Autosign:
 			try:
 			try:
 				self.wallet_dir.stat()
 				self.wallet_dir.stat()
 			except:
 			except:
-				die(2, f"Unable to create wallet directory '{self.wallet_dir}'")
+				die(2, f'Unable to create wallet directory ‘{self.wallet_dir}’')
 
 
 		self.gen_key(no_unmount=True)
 		self.gen_key(no_unmount=True)
 
 
@@ -777,7 +788,7 @@ class Autosign:
 		wf = find_file_in_dir(get_wallet_cls('mmgen'), self.cfg.data_dir)
 		wf = find_file_in_dir(get_wallet_cls('mmgen'), self.cfg.data_dir)
 		if wf and keypress_confirm(
 		if wf and keypress_confirm(
 				cfg         = self.cfg,
 				cfg         = self.cfg,
-				prompt      = f"Default wallet '{wf}' found.\nUse default wallet for autosigning?",
+				prompt      = f'Default wallet ‘{wf}’ found.\nUse default wallet for autosigning?',
 				default_yes = True):
 				default_yes = True):
 			ss_in = Wallet(Config(), fn=wf)
 			ss_in = Wallet(Config(), fn=wf)
 		else:
 		else:
@@ -810,7 +821,9 @@ class Autosign:
 		def create_signing_wallets():
 		def create_signing_wallets():
 			from . import xmrwallet
 			from . import xmrwallet
 			if len(self.wallet_files) > 1:
 			if len(self.wallet_files) > 1:
-				ymsg(f'Warning: more than one wallet file, using the first ({self.wallet_files[0]}) for xmrwallet generation')
+				ymsg(
+					'Warning: more than one wallet file, using the first '
+					f'({self.wallet_files[0]}) for xmrwallet generation')
 			m = xmrwallet.op(
 			m = xmrwallet.op(
 				'create_offline',
 				'create_offline',
 				self.xmrwallet_cfg,
 				self.xmrwallet_cfg,
@@ -844,7 +857,7 @@ class Autosign:
 
 
 			s = getattr(Signable, s_name)(self)
 			s = getattr(Signable, s_name)(self)
 
 
-			msg_r(f"Cleaning directory '{s.dir}'..")
+			msg_r(f'Cleaning directory ‘{s.dir}’..')
 
 
 			if s.dir.is_dir():
 			if s.dir.is_dir():
 				clean_files(s.rawext, s.sigext)
 				clean_files(s.rawext, s.sigext)
@@ -875,7 +888,9 @@ class Autosign:
 				if self.cfg.test_suite_root_pfx:
 				if self.cfg.test_suite_root_pfx:
 					return self.mountpoint.exists()
 					return self.mountpoint.exists()
 				else:
 				else:
-					return run(['diskutil', 'info', self.dev_label], stdout=DEVNULL, stderr=DEVNULL).returncode == 0
+					return run(
+						['diskutil', 'info', self.dev_label],
+						stdout=DEVNULL, stderr=DEVNULL).returncode == 0
 
 
 	async def main_loop(self):
 	async def main_loop(self):
 		if not self.cfg.stealth_led:
 		if not self.cfg.stealth_led:
@@ -890,7 +905,7 @@ class Autosign:
 				await self.do_sign()
 				await self.do_sign()
 			prev_status = status
 			prev_status = status
 			if not n % 10:
 			if not n % 10:
-				msg_r(f"\r{' '*17}\rWaiting")
+				msg_r(f'\r{" "*17}\rWaiting')
 			await asyncio.sleep(0.2 if threaded else 1)
 			await asyncio.sleep(0.2 if threaded else 1)
 			if not threaded:
 			if not threaded:
 				msg_r('.')
 				msg_r('.')

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-16.1.dev16
+16.1.dev17

+ 60 - 33
mmgen/proto/xmr/tw/unspent.py

@@ -14,9 +14,10 @@ proto.xmr.tw.unspent: Monero protocol tracking wallet unspent outputs class
 
 
 from collections import namedtuple
 from collections import namedtuple
 
 
+from ....obj import ImmutableAttr
+from ....addr import MoneroIdx
+from ....amt import CoinAmtChk
 from ....tw.unspent import TwUnspentOutputs
 from ....tw.unspent import TwUnspentOutputs
-from ....addr import MMGenID, MoneroIdx
-from ....color import red, green
 
 
 from .view import MoneroTwView
 from .view import MoneroTwView
 
 
@@ -25,16 +26,18 @@ class MoneroTwUnspentOutputs(MoneroTwView, TwUnspentOutputs):
 	hdr_lbl = 'spendable accounts'
 	hdr_lbl = 'spendable accounts'
 	desc = 'spendable accounts'
 	desc = 'spendable accounts'
 	item_desc = 'account'
 	item_desc = 'account'
-	account_based = True
 	include_empty = False
 	include_empty = False
 	total = None
 	total = None
 	nice_addr_w = {'addr': 20}
 	nice_addr_w = {'addr': 20}
 
 
-	prompt_fs_repl = {'XMR': (
-		(1, 'Display options: r[e]draw screen'),
-		(3, 'Actions: [q]uit menu, add [l]abel, [R]efresh balances:'))}
+	prompt_fs_in = [
+		'Sort options: [a]mount, [A]ge, a[d]dr, [M]mgen addr, [r]everse',
+		'Display options: r[e]draw screen',
+		'View/Print: pager [v]iew, [w]ide pager view, [p]rint to file{s}',
+		'Actions: [q]uit menu, add [l]abel, [R]efresh balances:']
 	extra_key_mappings = {
 	extra_key_mappings = {
-		'R': 'a_sync_wallets'}
+		'R': 'a_sync_wallets',
+		'A': 's_age'}
 
 
 	sort_disp = {
 	sort_disp = {
 		'addr':   'Addr',
 		'addr':   'Addr',
@@ -42,42 +45,56 @@ class MoneroTwUnspentOutputs(MoneroTwView, TwUnspentOutputs):
 		'amt':    'Amt',
 		'amt':    'Amt',
 		'twmmid': 'MMGenID'}
 		'twmmid': 'MMGenID'}
 
 
+	# NB: For account-based views, ALL sort keys MUST begin with acct_sort_key!
 	sort_funcs = {
 	sort_funcs = {
 		'addr':   lambda i: '{}:{}'.format(i.twmmid.obj.acct_sort_key, i.addr),
 		'addr':   lambda i: '{}:{}'.format(i.twmmid.obj.acct_sort_key, i.addr),
-		'age':    lambda i: i.twmmid.sort_key, # dummy (age sort not supported)
+		'age':    lambda i: '{}:{:020}'.format(i.twmmid.obj.acct_sort_key, 0 - i.confs),
 		'amt':    lambda i: '{}:{:050}'.format(i.twmmid.obj.acct_sort_key, i.amt.to_unit('atomic')),
 		'amt':    lambda i: '{}:{:050}'.format(i.twmmid.obj.acct_sort_key, i.amt.to_unit('atomic')),
-		'twmmid': lambda i: i.twmmid.sort_key}
+		'twmmid': lambda i: i.twmmid.sort_key} # sort_key begins with acct_sort_key
+
+	class MoneroMMGenTwUnspentOutput(TwUnspentOutputs.MMGenTwUnspentOutput):
+		valid_attrs = {'amt', 'unlocked_amt', 'comment', 'twmmid', 'addr', 'confs', 'skip'}
+		unlocked_amt = ImmutableAttr(CoinAmtChk, include_proto=True)
 
 
 	def gen_data(self, rpc_data, lbl_id):
 	def gen_data(self, rpc_data, lbl_id):
 		return (
 		return (
-			self.MMGenTwUnspentOutput(
+			self.MoneroMMGenTwUnspentOutput(
 					self.proto,
 					self.proto,
 					twmmid  = twmmid,
 					twmmid  = twmmid,
 					addr    = data['addr'],
 					addr    = data['addr'],
 					confs   = data['confs'],
 					confs   = data['confs'],
 					comment = data['lbl'].comment,
 					comment = data['lbl'].comment,
-					amt     = data['amt'])
+					amt     = data['amt'],
+					unlocked_amt = data['unlocked_amt'])
 				for twmmid, data in rpc_data.items())
 				for twmmid, data in rpc_data.items())
 
 
 	def get_disp_data(self):
 	def get_disp_data(self):
-		ad = namedtuple('accts_data', ['total', 'data'])
+		chk_fail_msg = 'For account-based views, ALL sort keys MUST begin with acct_sort_key!'
+		ad = namedtuple('accts_data', ['idx', 'acct_idx', 'total', 'unlocked_total', 'data'])
 		bd = namedtuple('accts_data_data', ['disp_data_idx', 'data'])
 		bd = namedtuple('accts_data_data', ['disp_data_idx', 'data'])
 		def gen_accts_data():
 		def gen_accts_data():
-			acct_id_save, total, d_acc = (None, 0, {})
+			idx, acct_idx = (None, None)
+			total, unlocked_total, d_acc = (0, 0, {})
+			chk_acc = [] # check for out-of-order accounts (developer idiot-proofing)
 			for n, d in enumerate(self.data):
 			for n, d in enumerate(self.data):
 				m = d.twmmid.obj
 				m = d.twmmid.obj
-				if acct_id_save != m.acct_id:
-					if acct_id_save:
-						yield (acct_id_save, ad(total, d_acc))
-					acct_id_save = m.acct_id
+				if idx != m.idx or acct_idx != m.acct_idx:
+					if idx:
+						yield ad(idx, acct_idx, total, unlocked_total, d_acc)
+					chk_acc.append((m.idx, m.acct_idx))
+					idx = m.idx
+					acct_idx = m.acct_idx
 					total = d.amt
 					total = d.amt
+					unlocked_total = d.unlocked_amt
 					d_acc = {m.addr_idx: bd(n, d)}
 					d_acc = {m.addr_idx: bd(n, d)}
 				else:
 				else:
 					total += d.amt
 					total += d.amt
+					unlocked_total += d.unlocked_amt
 					d_acc[m.addr_idx] = bd(n, d)
 					d_acc[m.addr_idx] = bd(n, d)
-			if acct_id_save:
-				yield (acct_id_save, ad(total, d_acc))
-		self.accts_data = dict(gen_accts_data())
+			if idx:
+				assert len(set(chk_acc)) == len(chk_acc), chk_fail_msg
+				yield ad(idx, acct_idx, total, unlocked_total, d_acc)
+		self.accts_data = tuple(gen_accts_data())
 		return super().get_disp_data()
 		return super().get_disp_data()
 
 
 	class display_type(TwUnspentOutputs.display_type):
 	class display_type(TwUnspentOutputs.display_type):
@@ -110,24 +127,34 @@ class MoneroTwUnspentOutputs(MoneroTwView, TwUnspentOutputs):
 			interactive = interactive)
 			interactive = interactive)
 
 
 	def gen_display(self, data, cw, fs, color, fmt_method):
 	def gen_display(self, data, cw, fs, color, fmt_method):
-		yes, no = (red('Yes '), green('No  ')) if color else ('Yes ', 'No  ')
-		fs_acct = '{:>4} {} {:%s}  {}' % MoneroIdx.max_digits
-		wallet_wid = 15
-		yield '     Wallet        Account  Balance'.ljust(self.term_width)
-		for n, (k, v) in enumerate(self.accts_data.items()):
-			mmid, acct_idx = k.rsplit(':', 1)
-			m = list(v.data.values())[0].data.twmmid.obj
+		fs_acct = '{:>4} {:6} {:7}  {}'
+		yield fs_acct.format('', 'Wallet', 'Account', 'Balance').ljust(self.term_width)
+		for n, d in enumerate(self.accts_data):
 			yield fs_acct.format(
 			yield fs_acct.format(
 				str(n + 1) + ')',
 				str(n + 1) + ')',
-				MMGenID.hlc(mmid.ljust(wallet_wid), color=color),
-				m.acct_idx.fmt(MoneroIdx.max_digits, color=color),
-				v.total.hl(color=color)).ljust(self.term_width)
-			for v in v.data.values():
-				yield fmt_method(None, v.data, cw, fs, color, yes, no)
+				d.idx.fmt(6, color=color),
+				d.acct_idx.fmt(7, color=color),
+				d.total.hl2(
+					color = color,
+					color_override = None if d.total == d.unlocked_total else 'orange'
+				)).ljust(self.term_width)
+			for v in d.data.values():
+				yield fmt_method(None, v.data, cw, fs, color, None, None)
 
 
 	def squeezed_format_line(self, n, d, cw, fs, color, yes, no):
 	def squeezed_format_line(self, n, d, cw, fs, color, yes, no):
 		return fs.format(
 		return fs.format(
 			I = d.twmmid.obj.addr_idx.fmt(cw.addr_idx, color=color),
 			I = d.twmmid.obj.addr_idx.fmt(cw.addr_idx, color=color),
 			a = d.addr.fmt(self.addr_view_pref, cw.addr, color=color),
 			a = d.addr.fmt(self.addr_view_pref, cw.addr, color=color),
 			c = d.comment.fmt2(cw.comment, color=color, nullrepl='-'),
 			c = d.comment.fmt2(cw.comment, color=color, nullrepl='-'),
-			A = d.amt.fmt(cw.iwidth, color=color, prec=self.disp_prec))
+			A = d.amt.fmt2(
+				cw.iwidth,
+				color = color,
+				color_override = None if d.amt == d.unlocked_amt else 'orange',
+				prec = self.disp_prec))
+
+	async def get_idx_from_user(self):
+		if res := await self.get_idx(f'{self.item_desc} number', self.accts_data):
+			return await self.get_idx(
+				'address index',
+				self.accts_data[res.idx - 1].data,
+				is_addr_idx = True)

+ 28 - 7
mmgen/proto/xmr/tw/view.py

@@ -12,7 +12,10 @@
 proto.xmr.tw.view: Monero protocol base class for tracking wallet view classes
 proto.xmr.tw.view: Monero protocol base class for tracking wallet view classes
 """
 """
 
 
+from collections import namedtuple
+
 from ....xmrwallet import op as xmrwallet_op
 from ....xmrwallet import op as xmrwallet_op
+from ....seed import SeedID
 from ....tw.view import TwView
 from ....tw.view import TwView
 
 
 class MoneroTwView:
 class MoneroTwView:
@@ -28,33 +31,51 @@ class MoneroTwView:
 		await op.restart_wallet_daemon()
 		await op.restart_wallet_daemon()
 		wallets_data = await op.main()
 		wallets_data = await op.main()
 
 
-		self.total = self.proto.coin_amt('0')
+		if wallets_data:
+			self.sid = SeedID(sid=wallets_data[0]['seed_id'])
+
+		self.total = self.unlocked_total = self.proto.coin_amt('0')
 
 
 		def gen_addrs():
 		def gen_addrs():
+			bd = namedtuple('address_balance_data', ['bal', 'unlocked_bal', 'blocks_to_unlock'])
 			for wdata in wallets_data:
 			for wdata in wallets_data:
 				bals_data = {i: {} for i in range(len(wdata['data'].accts_data['subaddress_accounts']))}
 				bals_data = {i: {} for i in range(len(wdata['data'].accts_data['subaddress_accounts']))}
 
 
 				for d in wdata['data'].bals_data.get('per_subaddress', []):
 				for d in wdata['data'].bals_data.get('per_subaddress', []):
-					bals_data[d['account_index']].update({d['address_index']: d['unlocked_balance']})
+					bals_data[d['account_index']].update({
+						d['address_index']: bd(
+							d['balance'],
+							d['unlocked_balance'],
+							d['blocks_to_unlock'])})
 
 
 				for acct_idx, acct_data in enumerate(wdata['data'].addrs_data):
 				for acct_idx, acct_data in enumerate(wdata['data'].addrs_data):
 					for addr_data in acct_data['addresses']:
 					for addr_data in acct_data['addresses']:
 						addr_idx = addr_data['address_index']
 						addr_idx = addr_data['address_index']
-						self.total += (bal := self.proto.coin_amt(
-							bals_data[acct_idx].get(addr_idx, 0),
-							from_unit = 'atomic'))
-						if self.include_empty or bal:
+						addr_bals = bals_data[acct_idx].get(addr_idx)
+						bal = self.proto.coin_amt(
+							addr_bals.bal if addr_bals else 0,
+							from_unit = 'atomic')
+						unlocked_bal = self.proto.coin_amt(
+							addr_bals.unlocked_bal if addr_bals else 0,
+							from_unit = 'atomic')
+						if bal or self.include_empty:
+							self.total += bal
+							self.unlocked_total += unlocked_bal
 							mmid = '{}:M:{}-{}/{}'.format(
 							mmid = '{}:M:{}-{}/{}'.format(
 								wdata['seed_id'],
 								wdata['seed_id'],
 								wdata['wallet_num'],
 								wdata['wallet_num'],
 								acct_idx,
 								acct_idx,
 								addr_idx)
 								addr_idx)
+							btu = addr_bals.blocks_to_unlock if addr_bals else 0
+							if not btu and bal != unlocked_bal:
+								btu = 12
 							yield (TwMMGenID(self.proto, mmid), {
 							yield (TwMMGenID(self.proto, mmid), {
 								'addr':    addr_data['address'],
 								'addr':    addr_data['address'],
 								'amt':     bal,
 								'amt':     bal,
+								'unlocked_amt': unlocked_bal,
 								'recvd':   bal,
 								'recvd':   bal,
 								'is_used': addr_data['used'],
 								'is_used': addr_data['used'],
-								'confs':   1,
+								'confs':   11 - btu,
 								'lbl':     TwLabel(self.proto, mmid + ' ' + addr_data['label'])})
 								'lbl':     TwLabel(self.proto, mmid + ' ' + addr_data['label'])})
 
 
 		return dict(gen_addrs())
 		return dict(gen_addrs())

+ 1 - 1
mmgen/tw/prune.py

@@ -91,7 +91,7 @@ class TwAddressesPrune(TwAddresses):
 					red('Address #{a} ({b}) has a balance of {c}!'.format(
 					red('Address #{a} ({b}) has a balance of {c}!'.format(
 						a = addrnum,
 						a = addrnum,
 						b = e.twmmid.addr,
 						b = e.twmmid.addr,
-						c = e.amt.hl2(color=False, unit=True))),
+						c = e.amt.hl3(color=False, unit=True))),
 					'[p]rune anyway, [P]rune all with balance, [s]kip, [S]kip all with balance: '),
 					'[p]rune anyway, [P]rune all with balance, [s]kip, [S]kip all with balance: '),
 				'used': md(
 				'used': md(
 					yellow('Address #{a} ({b}) is used!'.format(
 					yellow('Address #{a} ({b}) is used!'.format(

+ 53 - 51
mmgen/tw/view.py

@@ -80,7 +80,6 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 			def do(method, data, cw, fs, color, fmt_method):
 			def do(method, data, cw, fs, color, fmt_method):
 				return [l.rstrip() for l in method(data, cw, fs, color, fmt_method)]
 				return [l.rstrip() for l in method(data, cw, fs, color, fmt_method)]
 
 
-	account_based = False
 	has_wallet  = True
 	has_wallet  = True
 	has_amt2    = False
 	has_amt2    = False
 	dates_set   = False
 	dates_set   = False
@@ -442,6 +441,9 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 					Blue(sort_info),
 					Blue(sort_info),
 					spc * (self.cols - len(f'{self.hdr_lbl} (sort order: {sort_info})')))
 					spc * (self.cols - len(f'{self.hdr_lbl} (sort order: {sort_info})')))
 
 
+				if hasattr(self, 'sid'):
+					yield f'Seed ID: {self.sid.hl()}'
+
 				if self.filters:
 				if self.filters:
 					yield 'Filters: {}{}'.format(
 					yield 'Filters: {}{}'.format(
 						' '.join(map(fmt_filter, self.filters)),
 						' '.join(map(fmt_filter, self.filters)),
@@ -456,7 +458,13 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 						make_timestr(self.rpc.cur_date))
 						make_timestr(self.rpc.cur_date))
 
 
 				if hasattr(self, 'total'):
 				if hasattr(self, 'total'):
-					yield 'Total {}: {}'.format(self.proto.dcoin, self.total.hl(color=color))
+					if hasattr(self, 'unlocked_total') and self.total != self.unlocked_total:
+						yield 'Total {}: {} {}'.format(
+							self.proto.dcoin,
+							self.unlocked_total.hl(color=color),
+							self.total.hl3(color_override='orange', encl='[]'))
+					else:
+						yield 'Total {}: {}'.format(self.proto.dcoin, self.total.hl(color=color))
 
 
 				yield from getattr(self, dt.subhdr_fmt_method)(cw, color)
 				yield from getattr(self, dt.subhdr_fmt_method)(cw, color)
 
 
@@ -639,6 +647,41 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 				msg_r('\r'+''.ljust(self.term_width)+'\r'+yellow('Canceling! '))
 				msg_r('\r'+''.ljust(self.term_width)+'\r'+yellow('Canceling! '))
 			return False
 			return False
 
 
+	async def get_idx_from_user(self):
+		return await self.get_idx(f'{self.item_desc} number', self.disp_data)
+
+	async def get_idx(self, desc, data, *, is_addr_idx=False):
+
+		async def do_error_msg():
+			msg_r(
+				'Choice must be a single number between {n} and {m} inclusive{s}'.format(
+					n = list(data.keys())[0] if is_addr_idx else 1,
+					m = list(data.keys())[-1] if is_addr_idx else len(data),
+					s = ' ' if self.scroll else ''))
+			if self.scroll:
+				await asyncio.sleep(1.5)
+				msg_r(CUR_UP(1) + '\r' + ERASE_ALL)
+
+		from ..ui import line_input
+		ur = namedtuple('usr_idx_data', ['idx', 'acct_addr_idx'])
+		while True:
+			msg_r(self.blank_prompt if self.scroll else '\n')
+			usr_ret = line_input(
+				self.cfg,
+				f'Enter {desc} (or ENTER to return to main menu): ')
+			if usr_ret == '':
+				if self.scroll:
+					msg_r(CUR_UP(1) + '\r' + ''.ljust(self.term_width))
+				return None
+			if is_addr_idx:
+				if is_int(usr_ret) and int(usr_ret) in data:
+					return ur(MMGenIdx(data[int(usr_ret)].disp_data_idx + 1), int(usr_ret))
+			else:
+				idx = get_obj(MMGenIdx, n=usr_ret, silent=True)
+				if idx and idx <= len(data):
+					return ur(idx, None)
+			await do_error_msg()
+
 	class action:
 	class action:
 
 
 		@enable_echo
 		@enable_echo
@@ -702,47 +745,6 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 			if not parent.disp_data:
 			if not parent.disp_data:
 				return
 				return
 
 
-			async def do_error_msg(data, is_addr_idx):
-				msg_r(
-					'Choice must be a single number between {n} and {m} inclusive{s}'.format(
-						n = list(data.keys())[0] if is_addr_idx else 1,
-						m = list(data.keys())[-1] if is_addr_idx else len(data),
-						s = ' ' if parent.scroll else ''))
-				if parent.scroll:
-					await asyncio.sleep(1.5)
-					msg_r(CUR_UP(1) + '\r' + ERASE_ALL)
-
-			async def get_idx(desc, data, *, is_addr_idx=False):
-				from ..ui import line_input
-				ur = namedtuple('usr_idx_data', ['idx', 'addr_idx'])
-				while True:
-					msg_r(parent.blank_prompt if parent.scroll else '\n')
-					usr_ret = line_input(
-						parent.cfg,
-						f'Enter {desc} (or ENTER to return to main menu): ')
-					if usr_ret == '':
-						if parent.scroll:
-							msg_r(CUR_UP(1) + '\r' + ''.ljust(parent.term_width))
-						return None
-					if is_addr_idx:
-						if is_int(usr_ret) and int(usr_ret) in data:
-							return ur(MMGenIdx(data[int(usr_ret)].disp_data_idx + 1), int(usr_ret))
-					else:
-						idx = get_obj(MMGenIdx, n=usr_ret, silent=True)
-						if idx and idx <= len(data):
-							return ur(idx, None)
-					await do_error_msg(data, is_addr_idx)
-
-			async def get_idx_from_user():
-				if parent.account_based:
-					if res := await get_idx(f'{parent.item_desc} number', parent.accts_data):
-						return await get_idx(
-							'address index',
-							list(parent.accts_data.values())[res.idx - 1].data,
-							is_addr_idx = True)
-				else:
-					return await get_idx(f'{parent.item_desc} number', parent.disp_data)
-
 			while True:
 			while True:
 				# action_method return values:
 				# action_method return values:
 				#  True:   action successfully performed
 				#  True:   action successfully performed
@@ -750,8 +752,8 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 				#  None:   action aborted by user or no action performed
 				#  None:   action aborted by user or no action performed
 				#  'redo': user will be re-prompted for item number
 				#  'redo': user will be re-prompted for item number
 				#  'redraw': action successfully performed, screen will be redrawn
 				#  'redraw': action successfully performed, screen will be redrawn
-				if usr_ret := await get_idx_from_user():
-					ret = await action_method(parent, usr_ret.idx, usr_ret.addr_idx)
+				if usr_ret := await parent.get_idx_from_user():
+					ret = await action_method(parent, usr_ret.idx, usr_ret.acct_addr_idx)
 				else:
 				else:
 					ret = None
 					ret = None
 				if ret != 'redo':
 				if ret != 'redo':
@@ -764,7 +766,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 					CUR_HOME + ERASE_ALL +
 					CUR_HOME + ERASE_ALL +
 					await parent.format(display_type='squeezed', interactive=True, scroll=True))
 					await parent.format(display_type='squeezed', interactive=True, scroll=True))
 
 
-		async def i_balance_refresh(self, parent, idx, addr_idx=None):
+		async def i_balance_refresh(self, parent, idx, acct_addr_idx=None):
 			if not parent.keypress_confirm(
 			if not parent.keypress_confirm(
 					f'Refreshing tracking wallet {parent.item_desc} #{idx}. OK?'):
 					f'Refreshing tracking wallet {parent.item_desc} #{idx}. OK?'):
 				return 'redo'
 				return 'redo'
@@ -781,7 +783,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 				if res == 0:
 				if res == 0:
 					return 'redraw' # zeroing balance may mess up display
 					return 'redraw' # zeroing balance may mess up display
 
 
-		async def i_addr_delete(self, parent, idx, addr_idx=None):
+		async def i_addr_delete(self, parent, idx, acct_addr_idx=None):
 			if not parent.keypress_confirm(
 			if not parent.keypress_confirm(
 					'Removing {} {} from tracking wallet. OK?'.format(
 					'Removing {} {} from tracking wallet. OK?'.format(
 						parent.item_desc, red(f'#{idx}'))):
 						parent.item_desc, red(f'#{idx}'))):
@@ -795,7 +797,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 				parent.oneshot_msg = red('Address could not be removed')
 				parent.oneshot_msg = red('Address could not be removed')
 				return False
 				return False
 
 
-		async def i_comment_add(self, parent, idx, addr_idx=None):
+		async def i_comment_add(self, parent, idx, acct_addr_idx=None):
 
 
 			async def do_comment_add(comment_in):
 			async def do_comment_add(comment_in):
 				from ..obj import TwComment
 				from ..obj import TwComment
@@ -824,12 +826,12 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 					return False
 					return False
 
 
 			entry = parent.disp_data[idx-1]
 			entry = parent.disp_data[idx-1]
-			if addr_idx is None:
+			if acct_addr_idx is None:
 				desc       = f'{parent.item_desc} #{idx}'
 				desc       = f'{parent.item_desc} #{idx}'
 				color_desc = f'{parent.item_desc} {red("#" + str(idx))}'
 				color_desc = f'{parent.item_desc} {red("#" + str(idx))}'
 			else:
 			else:
-				desc       = f'address #{addr_idx}'
-				color_desc = f'address {red("#" + str(addr_idx))}'
+				desc       = f'address #{acct_addr_idx}'
+				color_desc = f'address {red("#" + str(acct_addr_idx))}'
 
 
 			cur_comment = parent.disp_data[idx-1].comment
 			cur_comment = parent.disp_data[idx-1].comment
 			msg('Current label: {}'.format(cur_comment.hl() if cur_comment else '(none)'))
 			msg('Current label: {}'.format(cur_comment.hl() if cur_comment else '(none)'))

+ 1 - 2
mmgen/tx/new.py

@@ -488,8 +488,7 @@ class New(Base):
 		outputs_sum = self.sum_outputs()
 		outputs_sum = self.sum_outputs()
 
 
 		msg('Total amount to spend: {}'.format(
 		msg('Total amount to spend: {}'.format(
-			f'{outputs_sum.hl()} {self.dcoin}' if outputs_sum else 'Unknown'
-		))
+			f'{outputs_sum.hl()} {self.dcoin}' if outputs_sum else 'Unknown'))
 
 
 		while True:
 		while True:
 			if not await self.get_inputs(outputs_sum):
 			if not await self.get_inputs(outputs_sum):

+ 3 - 1
mmgen/xmrwallet/__init__.py

@@ -130,4 +130,6 @@ def op(op, cfg, infile, wallets, *, spec=None, compat_call=False):
 			'daemon': cfg.daemon or cfg.monero_daemon,
 			'daemon': cfg.daemon or cfg.monero_daemon,
 			'watch_only': cfg.watch_only or cfg.autosign or bool(cfg.autosign_mountpoint),
 			'watch_only': cfg.watch_only or cfg.autosign or bool(cfg.autosign_mountpoint),
 			'wallet_dir': twctl_cls.get_tw_dir(cfg, cfg._proto)})
 			'wallet_dir': twctl_cls.get_tw_dir(cfg, cfg._proto)})
-	return op_cls(op)(cfg, uargs(infile, wallets, spec))
+	ret = op_cls(op)(cfg, uargs(infile, wallets, spec))
+	ret.compat_call = compat_call
+	return ret

+ 27 - 25
mmgen/xmrwallet/file/tx.py

@@ -74,8 +74,7 @@ class MoneroMMGenTX:
 			'metadata',
 			'metadata',
 			'unsigned_txset',
 			'unsigned_txset',
 			'signed_txset',
 			'signed_txset',
-			'complete',
-		])
+			'complete'])
 
 
 		def __init__(self):
 		def __init__(self):
 			self.name = type(self).__name__
 			self.name = type(self).__name__
@@ -93,11 +92,11 @@ class MoneroMMGenTX:
 					d = orange(self.file_id),
 					d = orange(self.file_id),
 					e = purple(d.op.ljust(9)),
 					e = purple(d.op.ljust(9)),
 					f = red('{}:{}'.format(d.source.wallet, d.source.account).ljust(6)),
 					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   '),
+					g = red('{}:{}'.format(d.dest.wallet, d.dest.account).ljust(6))
+						if d.dest else cyan('ext   '),
 					h = d.amount.fmt(4, color=True, prec=12),
 					h = d.amount.fmt(4, color=True, prec=12),
 					j = d.dest_address.fmt(0, addr_w, color=True) if addr_w else d.dest_address.hl(0),
 					j = d.dest_address.fmt(0, addr_w, color=True) if addr_w else d.dest_address.hl(0),
-					x = '->'
-				)
+					x = '->')
 
 
 		def get_info(self, *, indent='', addr_w=None):
 		def get_info(self, *, indent='', addr_w=None):
 			d = self.data
 			d = self.data
@@ -116,8 +115,7 @@ class MoneroMMGenTX:
 				['  Fee:       {n} XMR'],
 				['  Fee:       {n} XMR'],
 				['  Dest:      {o}'],
 				['  Dest:      {o}'],
 				['  Size:      {Z} bytes', d.signed_txset],
 				['  Size:      {Z} bytes', d.signed_txset],
-				['  Payment ID: {P}', pmt_id],
-			))
+				['  Payment ID: {P}', pmt_id]))
 
 
 			from ...util2 import format_elapsed_hr
 			from ...util2 import format_elapsed_hr
 			from ..ops import addr_width
 			from ..ops import addr_width
@@ -135,19 +133,20 @@ class MoneroMMGenTX:
 					j = d.source.wallet.hl(),
 					j = d.source.wallet.hl(),
 					k = red(f'#{d.source.account}'),
 					k = red(f'#{d.source.account}'),
 					m = d.amount.hl(),
 					m = d.amount.hl(),
-					F = (Int(d.priority).hl() + f' [{tx_priorities[d.priority]}]') if d.priority else None,
+					F = (Int(d.priority).hl() + f' [{tx_priorities[d.priority]}]')
+						if d.priority else None,
 					n = d.fee.hl(),
 					n = d.fee.hl(),
-					o = d.dest_address.hl(0) if self.cfg.full_address
-						else d.dest_address.fmt(0, addr_width, color=True),
+					o = d.dest_address.hl(0)
+						if self.cfg.full_address else d.dest_address.fmt(0, addr_width, color=True),
 					P = pink(pmt_id.hex()) if pmt_id else None,
 					P = pink(pmt_id.hex()) if pmt_id else None,
 					s = make_timestr(d.submit_time) if d.submit_time 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 '',
+					S = pink(f" [cold signed{', submitted' if d.complete else ''}]")
+						if d.signed_txset else '',
 					t = format_elapsed_hr(d.submit_time) if d.submit_time else None,
 					t = format_elapsed_hr(d.submit_time) if d.submit_time else None,
 					x = d.dest.wallet.hl() if d.dest else None,
 					x = d.dest.wallet.hl() if d.dest else None,
 					y = red(f'#{d.dest.account}') if d.dest else None,
 					y = red(f'#{d.dest.account}') if d.dest else None,
 					z = red(f'#{d.dest.account_address}') if d.dest else None,
 					z = red(f'#{d.dest.account_address}') if d.dest else None,
-					Z = Int(len(d.signed_txset) // 2).hl() if d.signed_txset else None,
-				)
+					Z = Int(len(d.signed_txset) // 2).hl() if d.signed_txset else None)
 
 
 		@property
 		@property
 		def file_id(self):
 		def file_id(self):
@@ -162,8 +161,7 @@ class MoneroMMGenTX:
 				a = self.file_id,
 				a = self.file_id,
 				b = self.data.amount,
 				b = self.data.amount,
 				c = '' if self.data.network == 'mainnet' else f'.{self.data.network}',
 				c = '' if self.data.network == 'mainnet' else f'.{self.data.network}',
-				d = self.ext
-			)
+				d = self.ext)
 
 
 			if self.cfg.autosign:
 			if self.cfg.autosign:
 				fn = get_autosign_obj(self.cfg).xmr_tx_dir / fn
 				fn = get_autosign_obj(self.cfg).xmr_tx_dir / fn
@@ -202,8 +200,10 @@ class MoneroMMGenTX:
 
 
 			self.data = self.xmrwallet_tx_data(
 			self.data = self.xmrwallet_tx_data(
 				op             = d.op,
 				op             = d.op,
-				create_time    = now if self.name in ('NewSigned', 'NewUnsigned') else getattr(d, 'create_time', None),
-				sign_time      = now if self.name in ('NewSigned', 'NewColdSigned') else getattr(d, 'sign_time', None),
+				create_time    = now if self.name in ('NewSigned', 'NewUnsigned')
+					else getattr(d, 'create_time', None),
+				sign_time      = now if self.name in ('NewSigned', 'NewColdSigned')
+					else getattr(d, 'sign_time', None),
 				submit_time    = now if self.name == 'NewSubmitted' else None,
 				submit_time    = now if self.name == 'NewSubmitted' else None,
 				network        = d.network,
 				network        = d.network,
 				seed_id        = SeedID(sid=d.seed_id),
 				seed_id        = SeedID(sid=d.seed_id),
@@ -212,14 +212,14 @@ class MoneroMMGenTX:
 				dest_address   = CoinAddr(proto, d.dest_address),
 				dest_address   = CoinAddr(proto, d.dest_address),
 				txid           = CoinTxID(d.txid),
 				txid           = CoinTxID(d.txid),
 				amount         = d.amount,
 				amount         = d.amount,
-				priority       = self.cfg.priority if self.name in ('NewSigned', 'NewUnsigned') else d.priority,
+				priority       = self.cfg.priority if self.name in ('NewSigned', 'NewUnsigned')
+					else d.priority,
 				fee            = d.fee,
 				fee            = d.fee,
 				blob           = d.blob,
 				blob           = d.blob,
 				metadata       = d.metadata,
 				metadata       = d.metadata,
 				unsigned_txset = d.unsigned_txset,
 				unsigned_txset = d.unsigned_txset,
 				signed_txset   = getattr(d, 'signed_txset', None),
 				signed_txset   = getattr(d, 'signed_txset', None),
-				complete       = self.name in ('NewSigned', 'NewSubmitted'),
-			)
+				complete       = self.name in ('NewSigned', 'NewSubmitted'))
 
 
 	class NewUnsigned(New):
 	class NewUnsigned(New):
 		desc = 'unsigned transaction'
 		desc = 'unsigned transaction'
@@ -252,7 +252,8 @@ class MoneroMMGenTX:
 			try:
 			try:
 				d_wrap = self.extract_data_from_file(cfg, fn)
 				d_wrap = self.extract_data_from_file(cfg, fn)
 			except Exception as e:
 			except Exception as e:
-				die('MoneroMMGenTXFileParseError', f'{type(e).__name__}: {e}\nCould not load transaction file')
+				die('MoneroMMGenTXFileParseError',
+					f'{type(e).__name__}: {e}\nCould not load transaction file')
 
 
 			if 'unsigned_txset' in d_wrap['data']: # post-autosign
 			if 'unsigned_txset' in d_wrap['data']: # post-autosign
 				self.full_chksum_fields &= set(d_wrap['data']) # allow for added chksum fields in future
 				self.full_chksum_fields &= set(d_wrap['data']) # allow for added chksum fields in future
@@ -266,8 +267,10 @@ class MoneroMMGenTX:
 			d = self.xmrwallet_tx_data(**d_wrap['data'])
 			d = self.xmrwallet_tx_data(**d_wrap['data'])
 
 
 			if self.name not in ('View', 'Completed'):
 			if self.name not in ('View', 'Completed'):
-				assert fn.name.endswith('.'+self.ext), 'TX file {fn} has incorrect extension (not {self.ext!r})'
-				assert getattr(d, self.req_field), f'{self.name} TX missing required field {self.req_field!r}'
+				assert fn.name.endswith('.' + self.ext), (
+					'TX file {fn} has incorrect extension (not {self.ext!r})')
+				assert getattr(d, self.req_field), (
+					f'{self.name} TX missing required field {self.req_field!r}')
 				assert bool(d.sign_time) == self.signed, '{a} has {b}sign time!'.format(
 				assert bool(d.sign_time) == self.signed, '{a} has {b}sign time!'.format(
 					a = self.desc,
 					a = self.desc,
 					b = 'no ' if self.signed else'')
 					b = 'no ' if self.signed else'')
@@ -294,8 +297,7 @@ class MoneroMMGenTX:
 				metadata       = d.metadata,
 				metadata       = d.metadata,
 				unsigned_txset = d.unsigned_txset,
 				unsigned_txset = d.unsigned_txset,
 				signed_txset   = d.signed_txset,
 				signed_txset   = d.signed_txset,
-				complete       = d.complete,
-			)
+				complete       = d.complete)
 
 
 			self.check_checksums(d_wrap)
 			self.check_checksums(d_wrap)
 
 

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

@@ -56,7 +56,7 @@ class OpBase:
 		def hl_amt(amt):
 		def hl_amt(amt):
 			return self.proto.coin_amt(amt, from_unit='atomic').hl()
 			return self.proto.coin_amt(amt, from_unit='atomic').hl()
 
 
-		addr_width = 95 if self.cfg.full_address else 17
+		addr_width = 95 if self.cfg.full_address else 24
 
 
 		self.proto = init_proto(cfg, 'xmr', network=self.cfg.network, need_amt=True)
 		self.proto = init_proto(cfg, 'xmr', network=self.cfg.network, need_amt=True)
 
 

+ 7 - 6
mmgen/xmrwallet/ops/label.py

@@ -31,12 +31,13 @@ class OpLabel(OpMixinSpec, OpWallet):
 
 
 	async def main(self, add_timestr='ask', auto=False):
 	async def main(self, add_timestr='ask', auto=False):
 
 
-		gmsg('\n{a} label for wallet {b}, account #{c}, address #{d}'.format(
-			a = 'Setting' if self.label else 'Removing',
-			b = self.source.idx,
-			c = self.account,
-			d = self.address_idx
-		))
+		if not self.compat_call:
+			gmsg('\n{a} label for wallet {b}, account #{c}, address #{d}'.format(
+				a = 'Setting' if self.label else 'Removing',
+				b = self.source.idx,
+				c = self.account,
+				d = self.address_idx))
+
 		h = MoneroWalletRPC(self, self.source)
 		h = MoneroWalletRPC(self, self.source)
 
 
 		h.open_wallet('source')
 		h.open_wallet('source')

+ 21 - 8
mmgen/xmrwallet/ops/sweep.py

@@ -85,7 +85,10 @@ class OpSweep(OpMixinSpec, OpWallet):
 				h, self.account, f'{self.name} from this account [{make_timestr()}]')
 				h, self.account, f'{self.name} from this account [{make_timestr()}]')
 			if dest_addr_chk:
 			if dest_addr_chk:
 				wallet_data = h.get_wallet_data(print=False)
 				wallet_data = h.get_wallet_data(print=False)
-			dest_addr, dest_addr_idx = h.get_last_addr(self.account, wallet_data, display=not dest_addr_chk)
+			dest_addr, dest_addr_idx = h.get_last_addr(
+				self.account,
+				wallet_data,
+				display = not dest_addr_chk)
 			if dest_addr_chk:
 			if dest_addr_chk:
 				h.print_acct_addrs(wallet_data, self.account)
 				h.print_acct_addrs(wallet_data, self.account)
 		elif self.dest_acct is None: # sweep to wallet
 		elif self.dest_acct is None: # sweep to wallet
@@ -118,7 +121,10 @@ class OpSweep(OpMixinSpec, OpWallet):
 				dest_addr_chk = create_new_addr_maybe(h, dest_acct, label)
 				dest_addr_chk = create_new_addr_maybe(h, dest_acct, label)
 				if dest_addr_chk:
 				if dest_addr_chk:
 					wallet_data = h.get_wallet_data(print=False)
 					wallet_data = h.get_wallet_data(print=False)
-				dest_addr, dest_addr_idx = h.get_last_addr(dest_acct, wallet_data, display=not dest_addr_chk)
+				dest_addr, dest_addr_idx = h.get_last_addr(
+					dest_acct,
+					wallet_data,
+					display = not dest_addr_chk)
 				if dest_addr_chk:
 				if dest_addr_chk:
 					h.print_acct_addrs(wallet_data, dest_acct)
 					h.print_acct_addrs(wallet_data, dest_acct)
 				return dest_addr, dest_addr_idx, dest_addr_chk
 				return dest_addr, dest_addr_idx, dest_addr_chk
@@ -143,7 +149,12 @@ class OpSweep(OpMixinSpec, OpWallet):
 			f'dest_addr: ({dest_addr}) != dest_addr_chk: ({dest_addr_chk})')
 			f'dest_addr: ({dest_addr}) != dest_addr_chk: ({dest_addr_chk})')
 
 
 		msg(f'\n    Creating {self.name} transaction...')
 		msg(f'\n    Creating {self.name} transaction...')
-		return (h, h.make_sweep_tx(self.account, dest_acct, dest_addr_idx, dest_addr, wallet_data.addrs_data))
+		return (h, h.make_sweep_tx(
+			self.account,
+			dest_acct,
+			dest_addr_idx,
+			dest_addr,
+			wallet_data.addrs_data))
 
 
 	@property
 	@property
 	def add_desc(self):
 	def add_desc(self):
@@ -158,10 +169,10 @@ class OpSweep(OpMixinSpec, OpWallet):
 			die(2, f'{self.account}: requested account index out of bounds (>{max_acct})')
 			die(2, f'{self.account}: requested account index out of bounds (>{max_acct})')
 
 
 	async def main(self):
 	async def main(self):
-
-		gmsg(
-			f'\n{self.stem.capitalize()}ing account #{self.account}'
-			f' of wallet {self.source.idx}{self.add_desc}')
+		if not self.compat_call:
+			gmsg(
+				f'\n{self.stem.capitalize()}ing account #{self.account}'
+				f' of wallet {self.source.idx}{self.add_desc}')
 
 
 		h = MoneroWalletRPC(self, self.source)
 		h = MoneroWalletRPC(self, self.source)
 
 
@@ -180,7 +191,9 @@ class OpSweep(OpMixinSpec, OpWallet):
 		if self.cfg.tx_relay_daemon:
 		if self.cfg.tx_relay_daemon:
 			self.display_tx_relay_info(indent='    ')
 			self.display_tx_relay_info(indent='    ')
 
 
-		msg('Saving TX data to file')
+		if not self.compat_call:
+			msg('Saving TX data to file')
+
 		new_tx.write(delete_metadata=True)
 		new_tx.write(delete_metadata=True)
 
 
 		if self.cfg.no_relay or self.cfg.autosign:
 		if self.cfg.no_relay or self.cfg.autosign:

+ 4 - 2
mmgen/xmrwallet/ops/sync.py

@@ -25,7 +25,8 @@ class OpSync(OpWallet):
 
 
 	def check_uopts(self):
 	def check_uopts(self):
 		if self.cfg.rescan_blockchain and self.cfg.watch_only:
 		if self.cfg.rescan_blockchain and self.cfg.watch_only:
-			die(1, f'Operation ‘{self.name}’ does not support --rescan-blockchain with watch-only wallets')
+			die(1,
+				f'Operation ‘{self.name}’ does not support --rescan-blockchain with watch-only wallets')
 
 
 	def __init__(self, cfg, uarg_tuple):
 	def __init__(self, cfg, uarg_tuple):
 
 
@@ -71,7 +72,8 @@ class OpSync(OpWallet):
 			wallet_height = self.c.call('get_height')['height']
 			wallet_height = self.c.call('get_height')['height']
 			if wallet_height >= chain_height:
 			if wallet_height >= chain_height:
 				break
 				break
-			ymsg(f'  Wallet failed to sync (wallet height [{wallet_height}] < chain height [{chain_height}])')
+			ymsg('  Wallet failed to sync '
+				f'(wallet height [{wallet_height}] < chain height [{chain_height}])')
 			if i or not self.cfg.rescan_blockchain:
 			if i or not self.cfg.rescan_blockchain:
 				break
 				break
 			msg_r('  Rescanning blockchain, please be patient...')
 			msg_r('  Rescanning blockchain, please be patient...')

+ 8 - 6
mmgen/xmrwallet/ops/wallet.py

@@ -204,11 +204,12 @@ class OpWallet(OpBase):
 		return 'offline signing ' if self.cfg.offline else 'watch-only ' if self.cfg.watch_only else ''
 		return 'offline signing ' if self.cfg.offline else 'watch-only ' if self.cfg.watch_only else ''
 
 
 	async def main(self):
 	async def main(self):
-		gmsg('\n{a}ing {b} {c}wallet{d}'.format(
-			a = self.stem.capitalize(),
-			b = len(self.addr_data),
-			c = self.add_wallet_desc,
-			d = suf(self.addr_data)))
+		if not self.compat_call:
+			gmsg('\n{a}ing {b} {c}wallet{d}'.format(
+				a = self.stem.capitalize(),
+				b = len(self.addr_data),
+				c = self.add_wallet_desc,
+				d = suf(self.addr_data)))
 		data = []
 		data = []
 		for n, d in enumerate(self.addr_data): # [d.sec,d.addr,d.wallet_passwd,d.viewkey]
 		for n, d in enumerate(self.addr_data): # [d.sec,d.addr,d.wallet_passwd,d.viewkey]
 			fn = self.get_wallet_fn(d)
 			fn = self.get_wallet_fn(d)
@@ -218,7 +219,8 @@ class OpWallet(OpBase):
 				c = len(self.addr_data),
 				c = len(self.addr_data),
 				d = fn.name))
 				d = fn.name))
 			data.append(await self.process_wallet(d, fn, last=n == len(self.addr_data) - 1))
 			data.append(await self.process_wallet(d, fn, last=n == len(self.addr_data) - 1))
-		gmsg(f'\n{len(data)} wallet{suf(len(data))} {self.stem}ed\n')
+		if not self.compat_call:
+			gmsg(f'\n{len(data)} wallet{suf(len(data))} {self.stem}ed\n')
 		return data if self.return_data else sum(map(bool, data))
 		return data if self.return_data else sum(map(bool, data))
 
 
 	def head_msg(self, wallet_idx, fn):
 	def head_msg(self, wallet_idx, fn):

+ 11 - 13
mmgen/xmrwallet/rpc.py

@@ -88,10 +88,12 @@ class MoneroWalletRPC:
 		accts_data = self.c.call('get_accounts')
 		accts_data = self.c.call('get_accounts')
 		addrs_data = [
 		addrs_data = [
 			self.c.call('get_address', account_index=i)
 			self.c.call('get_address', account_index=i)
-				for i in range(len(accts_data['subaddress_accounts']))
-		]
-		if print:
-			msg('\n' + '\n'.join(self.gen_accts_info(accts_data, addrs_data, skip_empty_ok=skip_empty_ok)))
+				for i in range(len(accts_data['subaddress_accounts']))]
+		if print and not self.parent.compat_call:
+			msg('\n' + '\n'.join(self.gen_accts_info(
+				accts_data,
+				addrs_data,
+				skip_empty_ok = skip_empty_ok)))
 		bals_data = self.c.call('get_balance', all_accounts=True)
 		bals_data = self.c.call('get_balance', all_accounts=True)
 		return namedtuple('wallet_data', ['accts_data', 'addrs_data', 'bals_data'])(
 		return namedtuple('wallet_data', ['accts_data', 'addrs_data', 'bals_data'])(
 			accts_data, addrs_data, bals_data)
 			accts_data, addrs_data, bals_data)
@@ -111,7 +113,7 @@ class MoneroWalletRPC:
 		return (ret['account_index'], ret['base_address'])
 		return (ret['account_index'], ret['base_address'])
 
 
 	def print_acct_addrs(self, wallet_data, account, silent=False):
 	def print_acct_addrs(self, wallet_data, account, silent=False):
-		if not silent:
+		if not (self.parent.compat_call or silent):
 			msg('\n      Addresses of account #{} ({}):'.format(
 			msg('\n      Addresses of account #{} ({}):'.format(
 				account,
 				account,
 				wallet_data.accts_data['subaddress_accounts'][account]['label']))
 				wallet_data.accts_data['subaddress_accounts'][account]['label']))
@@ -149,8 +151,7 @@ class MoneroWalletRPC:
 			priority = self.cfg.priority or None,
 			priority = self.cfg.priority or None,
 			do_not_relay = True,
 			do_not_relay = True,
 			get_tx_hex = True,
 			get_tx_hex = True,
-			get_tx_metadata = True
-		)
+			get_tx_metadata = True)
 		return self.new_tx_cls(
 		return self.new_tx_cls(
 			cfg            = self.cfg,
 			cfg            = self.cfg,
 			op             = self.parent.name,
 			op             = self.parent.name,
@@ -164,8 +165,7 @@ class MoneroWalletRPC:
 			fee            = self.proto.coin_amt(res['fee'], from_unit='atomic'),
 			fee            = self.proto.coin_amt(res['fee'], from_unit='atomic'),
 			blob           = res['tx_blob'],
 			blob           = res['tx_blob'],
 			metadata       = res['tx_metadata'],
 			metadata       = res['tx_metadata'],
-			unsigned_txset = res['unsigned_txset'] if self.cfg.watch_only else None,
-		)
+			unsigned_txset = res['unsigned_txset'] if self.cfg.watch_only else None)
 
 
 	def make_sweep_tx(self, account, dest_acct, dest_addr_idx, addr, addrs_data):
 	def make_sweep_tx(self, account, dest_acct, dest_addr_idx, addr, addrs_data):
 		res = self.c.call(
 		res = self.c.call(
@@ -177,8 +177,7 @@ class MoneroWalletRPC:
 			priority = self.cfg.priority or None,
 			priority = self.cfg.priority or None,
 			do_not_relay = True,
 			do_not_relay = True,
 			get_tx_hex = True,
 			get_tx_hex = True,
-			get_tx_metadata = True
-		)
+			get_tx_metadata = True)
 
 
 		if len(res['tx_hash_list']) > 1:
 		if len(res['tx_hash_list']) > 1:
 			die(3, 'More than one TX required.  Cannot perform this sweep')
 			die(3, 'More than one TX required.  Cannot perform this sweep')
@@ -199,8 +198,7 @@ class MoneroWalletRPC:
 			fee            = self.proto.coin_amt(res['fee_list'][0], from_unit='atomic'),
 			fee            = self.proto.coin_amt(res['fee_list'][0], from_unit='atomic'),
 			blob           = res['tx_blob_list'][0],
 			blob           = res['tx_blob_list'][0],
 			metadata       = res['tx_metadata_list'][0],
 			metadata       = res['tx_metadata_list'][0],
-			unsigned_txset = res['unsigned_txset'] if self.cfg.watch_only else None,
-		)
+			unsigned_txset = res['unsigned_txset'] if self.cfg.watch_only else None)
 
 
 	def relay_tx(self, tx_hex):
 	def relay_tx(self, tx_hex):
 		ret = self.c.call('relay_tx', hex=tx_hex)
 		ret = self.c.call('relay_tx', hex=tx_hex)

+ 121 - 27
test/cmdtest_d/xmr_autosign.py

@@ -13,11 +13,11 @@
 test.cmdtest_d.xmr_autosign: xmr autosigning tests for the cmdtest.py test suite
 test.cmdtest_d.xmr_autosign: xmr autosigning tests for the cmdtest.py test suite
 """
 """
 
 
-import re, asyncio
+import os, re, asyncio, json
 
 
 from mmgen.color import blue, cyan, brown
 from mmgen.color import blue, cyan, brown
 
 
-from ..include.common import imsg, silence, end_silence, strip_ansi_escapes
+from ..include.common import imsg, silence, end_silence, strip_ansi_escapes, read_from_file
 from .include.common import get_file_with_ext, cleanup_env
 from .include.common import get_file_with_ext, cleanup_env
 
 
 from .xmrwallet import CmdTestXMRWallet
 from .xmrwallet import CmdTestXMRWallet
@@ -158,19 +158,22 @@ class CmdTestXMRAutosign(CmdTestXMRWallet, CmdTestAutosignThreaded):
 		return self._new_addr_alice(
 		return self._new_addr_alice(
 			'2',
 			'2',
 			'start',
 			'start',
-			r'Creating new account for wallet .*2.* with label .*‘xmrwallet new account .*y/N\): ')
+			r'Creating new account for wallet .*2.* with label '
+			r'.*‘xmrwallet new account .*y/N\): ')
 
 
 	def new_address_alice(self):
 	def new_address_alice(self):
 		return self._new_addr_alice(
 		return self._new_addr_alice(
 			'2:1',
 			'2:1',
 			'continue',
 			'continue',
-			r'Creating new address for wallet .*2.*, account .*#1.* with label .*‘xmrwallet new address .*y/N\): ')
+			r'Creating new address for wallet .*2.*, account .*#1.* with label '
+			r'.*‘xmrwallet new address .*y/N\): ')
 
 
 	def new_address_alice_label(self):
 	def new_address_alice_label(self):
 		return self._new_addr_alice(
 		return self._new_addr_alice(
 			'2:1,Alice’s new address',
 			'2:1,Alice’s new address',
 			'stop',
 			'stop',
-			r'Creating new address for wallet .*2.*, account .*#1.* with label .*‘Alice’s new address .*y/N\): ')
+			r'Creating new address for wallet .*2.*, account .*#1.* with label '
+			r'.*‘Alice’s new address .*y/N\): ')
 
 
 	def dump_tmp_wallets(self):
 	def dump_tmp_wallets(self):
 		return self._dump_wallets(autosign=False)
 		return self._dump_wallets(autosign=False)
@@ -196,7 +199,6 @@ class CmdTestXMRAutosign(CmdTestXMRWallet, CmdTestAutosignThreaded):
 		t.expect('2 wallets dumped')
 		t.expect('2 wallets dumped')
 		res = t.read()
 		res = t.read()
 		if op == 'dump_json':
 		if op == 'dump_json':
-			import json
 			data = json.loads(re.sub('Stopping.*', '', strip_ansi_escapes(res)).strip())
 			data = json.loads(re.sub('Stopping.*', '', strip_ansi_escapes(res)).strip())
 		self.remove_device_online()
 		self.remove_device_online()
 		return t
 		return t
@@ -511,17 +513,32 @@ class CmdTestXMRCompat(CmdTestXMRAutosign):
 		('fund_alice2',              'sending funds to Alice (wallet #2)'),
 		('fund_alice2',              'sending funds to Alice (wallet #2)'),
 		('check_bal_alice2',         'mining, checking balance (wallet #2)'),
 		('check_bal_alice2',         'mining, checking balance (wallet #2)'),
 		('fund_alice1',              'sending funds to Alice (wallet #1)'),
 		('fund_alice1',              'sending funds to Alice (wallet #1)'),
-		('mine_blocks',              'mining some blocks'),
-		('alice_listaddresses',      'performing operations on Alice’s tracking wallets (listaddresses)'),
+		('mine_blocks_10',           'mining some blocks'),
+		('alice_listaddresses1',     'adding label to Alice’s tracking wallets (listaddresses)'),
 		('fund_alice1b',             'sending funds to Alice (wallet #1)'),
 		('fund_alice1b',             'sending funds to Alice (wallet #1)'),
-		('mine_blocks',              'mining some blocks'),
-		('alice_twview',             'performing operations on Alice’s tracking wallets (twview)'),
+		('mine_blocks_10',           'mining some blocks'),
+		('alice_twview1',            'adding label to Alice’s tracking wallets (twview)'),
+		('new_account_alice',        'adding an account to Alice’s wallet'),
+		('new_address_alice',        'adding an address to Alice’s wallet'),
+		('new_address_alice_label',  'adding an address to Alice’s wallet (with label)'),
+		('alice_dump',               'dumping alice’s wallets to JSON format'),
+		('fund_alice_sub1',          'sending funds to Alice’s subaddress #1 (wallet #2)'),
+		('mine_blocks_1',            'mining a block'),
+		('fund_alice_sub2',          'sending funds to Alice’s subaddress #2 (wallet #2)'),
+		('mine_blocks_1',            'mining a block'),
+		('fund_alice_sub3',          'sending funds to Alice’s subaddress #3 (wallet #2)'),
+		('alice_twview2',            'viewing Alice’s tracking wallets (reload, sort options)'),
+		('alice_twview3',            'viewing Alice’s tracking wallets (check balances)'),
 	)
 	)
 
 
 	def __init__(self, cfg, trunner, cfgs, spawn):
 	def __init__(self, cfg, trunner, cfgs, spawn):
 		super().__init__(cfg, trunner, cfgs, spawn)
 		super().__init__(cfg, trunner, cfgs, spawn)
 		if trunner is None:
 		if trunner is None:
 			return
 			return
+		self.alice_tw_dir = os.path.join(self.tr.data_dir, 'alice', 'altcoins', 'xmr', 'tracking-wallets')
+		self.alice_dump_file = os.path.join(
+			self.alice_tw_dir,
+			'{}-2-MoneroWatchOnlyWallet.dump'.format(self.users['alice'].sid))
 		self.alice_opts = [
 		self.alice_opts = [
 			'--alice',
 			'--alice',
 			'--coin=xmr',
 			'--coin=xmr',
@@ -531,27 +548,104 @@ class CmdTestXMRCompat(CmdTestXMRAutosign):
 	def create_watchonly_wallets(self):
 	def create_watchonly_wallets(self):
 		return self._create_wallets()
 		return self._create_wallets()
 
 
-	async def mine_blocks(self):
+	async def mine_blocks_1(self):
+		return await self._mine_blocks(1)
+
+	async def mine_blocks_10(self):
+		return await self._mine_blocks(10)
+
+	async def _mine_blocks(self, n):
 		self.spawn(msg_only=True)
 		self.spawn(msg_only=True)
-		return await self.mine(10)
+		return await self.mine(n)
 
 
-	def alice_listaddresses(self):
-		return self._alice_twops('listaddresses', 2, 'y', r'Primary account.*1\.234567891234')
+	def _new_addr_alice(self, *args):
+		return self.new_addr_alice(*args, do_autosign=True)
 
 
-	def alice_twview(self):
-		return self._alice_twops('twview', 1, 'n', r'New Label.*2\.469135782468', addr_idx_num=0)
+	async def alice_dump(self):
+		t = self._xmr_autosign_op('dump')
+		t.read()
+		self.remove_device_online() # device was inserted by _xmr_autosign_op()
+		return t
+
+	async def fund_alice_sub1(self):
+		return await self._fund_alice(1, 9876543210)
+
+	async def fund_alice_sub2(self):
+		return await self._fund_alice(2, 8765432109)
 
 
-	def _alice_twops(self, op, addr_num, add_timestr_resp, expect_str, *, addr_idx_num=None):
+	async def fund_alice_sub3(self):
+		return await self._fund_alice(3, 7654321098)
+
+	async def _fund_alice(self, addr_num, amt):
+		data = json.loads(read_from_file(self.alice_dump_file))
+		addr_data = data['MoneroMMGenWalletDumpFile']['data']['wallet_metadata'][1]['addresses']
+		return await self.fund_alice(addr=addr_data[addr_num-1]['address'], amt=amt)
+
+	def alice_listaddresses1(self):
+		return self._alice_twops(
+			'listaddresses',
+			lbl_addr_num = 2,
+			lbl_add_timestr = True,
+			menu = 'R',
+			expect_str = r'Primary account.*1\.234567891234')
+
+	def alice_twview(self):
+		return self._alice_twops('twview')
+
+	def alice_twview1(self):
+		return self._alice_twops(
+			'twview',
+			lbl_addr_num = 1,
+			lbl_addr_idx_num = 0,
+			menu = 'R',
+			expect_str = r'New Label.*2\.469135782468')
+
+	def alice_twview2(self):
+		return self._alice_twops('twview', menu='RaAdMraAdMe')
+
+	def alice_twview3(self):
+		return self._alice_twops(
+			'twview',
+			expect_arr = [
+				'Total XMR: 3.722345649021 [3.729999970119]',
+				'1  0.026296296417',
+				'0.007654321098'])
+
+	def _alice_twops(
+			self,
+			op,
+			*,
+			lbl_addr_num = None,
+			lbl_addr_idx_num = None,
+			lbl_add_timestr = False,
+			menu = '',
+			expect_str = '',
+			expect_arr = []):
+
+		interactive = not expect_arr
 		self.insert_device_online()
 		self.insert_device_online()
-		t = self.spawn('mmgen-tool', self.alice_opts + self.autosign_opts + [op, 'interactive=1'])
-		t.expect(self.menu_prompt, 'l')
-		t.expect('main menu): ', str(addr_num))
-		if addr_idx_num is not None:
-			t.expect('main menu): ', str(addr_idx_num))
-		t.expect(': ', 'New Label\n')
-		t.expect('(y/N): ', add_timestr_resp)
-		t.expect(self.menu_prompt, 'R')
-		t.expect(expect_str, regex=True)
-		t.expect(self.menu_prompt, 'q')
+		t = self.spawn(
+			'mmgen-tool',
+			self.alice_opts
+			+ self.autosign_opts
+			+ [op]
+			+ (['interactive=1'] if interactive else []))
+		if interactive:
+			if lbl_addr_num:
+				t.expect(self.menu_prompt, 'l')
+				t.expect('main menu): ', str(lbl_addr_num))
+				if lbl_addr_idx_num is not None:
+					t.expect('main menu): ', str(lbl_addr_idx_num))
+				t.expect(': ', 'New Label\n')
+				t.expect('(y/N): ', 'y' if lbl_add_timestr else 'n')
+			for ch in menu:
+				t.expect(self.menu_prompt, ch)
+			if expect_str:
+				t.expect(expect_str, regex=True)
+			t.expect(self.menu_prompt, 'q')
+		elif expect_arr:
+			text = strip_ansi_escapes(t.read())
+			for s in expect_arr:
+				assert s in text
 		self.remove_device_online()
 		self.remove_device_online()
 		return t
 		return t

+ 12 - 4
test/cmdtest_d/xmrwallet.py

@@ -315,17 +315,25 @@ class CmdTestXMRWallet(CmdTestBase):
 				quiet = True)
 				quiet = True)
 		return t
 		return t
 
 
-	def new_addr_alice(self, spec, cfg, expect, kafile=None):
+	def new_addr_alice(self, spec, cfg, expect, kafile=None, do_autosign=False):
 		data = self.users['alice']
 		data = self.users['alice']
+		if do_autosign:
+			self.insert_device_online()
 		t = self.spawn(
 		t = self.spawn(
 			'mmgen-xmrwallet',
 			'mmgen-xmrwallet',
 			self.extra_opts
 			self.extra_opts
+			+ (self.autosign_opts if do_autosign else [])
 			+ (['--alice', '--compat'] if self.compat else [f'--wallet-dir={data.udir}'])
 			+ (['--alice', '--compat'] if self.compat else [f'--wallet-dir={data.udir}'])
 			+ [f'--daemon=localhost:{data.md.rpc_port}']
 			+ [f'--daemon=localhost:{data.md.rpc_port}']
 			+ (['--no-start-wallet-daemon'] if cfg in ('continue', 'stop') else [])
 			+ (['--no-start-wallet-daemon'] if cfg in ('continue', 'stop') else [])
 			+ (['--no-stop-wallet-daemon'] if cfg in ('start', 'continue') else [])
 			+ (['--no-stop-wallet-daemon'] if cfg in ('start', 'continue') else [])
-			+ ['new', (kafile or data.kafile), spec])
+			+ ['new']
+			+ ([] if do_autosign else [kafile or data.kafile])
+			+ [spec])
 		t.expect(expect, 'y', regex=True)
 		t.expect(expect, 'y', regex=True)
+		if do_autosign:
+			t.read()
+			self.remove_device_online()
 		return t
 		return t
 
 
 	na_idx = 1
 	na_idx = 1
@@ -361,12 +369,12 @@ class CmdTestXMRWallet(CmdTestBase):
 		# NB: a large balance is required to avoid ‘insufficient outputs’ error
 		# NB: a large balance is required to avoid ‘insufficient outputs’ error
 		return await self.mine_chk('miner', 1, 0, lambda x: x.ub > 2000, 'unlocked balance > 2000')
 		return await self.mine_chk('miner', 1, 0, lambda x: x.ub > 2000, 'unlocked balance > 2000')
 
 
-	async def fund_alice(self, wallet=1, amt=1234567891234):
+	async def fund_alice(self, wallet=1, amt=1234567891234, addr=None):
 		self.spawn(msg_only=True, extra_desc='(transferring funds from Miner wallet)')
 		self.spawn(msg_only=True, extra_desc='(transferring funds from Miner wallet)')
 		await self.transfer(
 		await self.transfer(
 			'miner',
 			'miner',
 			amt,
 			amt,
-			read_from_file(self.users['alice'].addrfile_fs.format(wallet)))
+			addr or read_from_file(self.users['alice'].addrfile_fs.format(wallet)))
 		return 'ok'
 		return 'ok'
 
 
 	async def check_bal_alice(self, wallet=1, bal='1.234567891234'):
 	async def check_bal_alice(self, wallet=1, bal='1.234567891234'):

+ 2 - 2
test/include/common.py

@@ -143,7 +143,7 @@ def cleandir(d, do_msg=False):
 	if files:
 	if files:
 		from shutil import rmtree
 		from shutil import rmtree
 		if do_msg:
 		if do_msg:
-			gmsg(f'Cleaning directory {d!r}')
+			gmsg(f'Cleaning directory {d!r}')
 		for f in files:
 		for f in files:
 			try:
 			try:
 				os.unlink(os.path.join(d_enc, f))
 				os.unlink(os.path.join(d_enc, f))
@@ -159,7 +159,7 @@ def mk_tmpdir(d):
 		if e.errno != 17:
 		if e.errno != 17:
 			raise
 			raise
 	else:
 	else:
-		vmsg(f'Created directory {d!r}')
+		vmsg(f'Created directory {d!r}')
 
 
 def clean(cfgs, tmpdir_ids=None, extra_dirs=[]):
 def clean(cfgs, tmpdir_ids=None, extra_dirs=[]):