8 Commits 851335106f ... bdd7dd3393

Author SHA1 Message Date
  The MMGen Project bdd7dd3393 XMR compat: support tracking wallet views 1 week ago
  The MMGen Project 937f0af9c9 mmgen-xmrwallet label: prompt user to add timestamp 1 week ago
  The MMGen Project 040671bb06 minor cleanups 1 week ago
  The MMGen Project bfddd5b8ae TwCtl.set_comment(): return the comment 1 week ago
  The MMGen Project 8a750259b9 mmgen-xmrwallet: new `dump-json` operation 1 week ago
  The MMGen Project d5e142d475 xmrwallet.ops.wallet: support returning data from `main()` 1 week ago
  The MMGen Project c7a75b684f tw.view: support key mapping removal, multiline prompt str edits 1 week ago
  The MMGen Project fd65ae6660 whitespace, minor fixes 1 week ago

+ 13 - 2
mmgen/addr.py

@@ -145,14 +145,25 @@ class MMGenID(HiliteStr, InitErrors, MMGenObject):
 				assert id_str.count(':') == 2, 'mmtype letter required for extended MMGen IDs'
 				me = str.__new__(cls, id_str)
 				idx, ext = idx.split('-', 1)
-				me.acct_num, me.acct_addr_num = [MoneroIdx(e) for e in ext.split('/', 1)]
+				me.acct_idx, me.addr_idx = [MoneroIdx(e) for e in ext.split('/', 1)]
 			else:
+				ext = None
 				me = str.__new__(cls, f'{sid}:{mmtype}:{idx}')
 			me.sid = SeedID(sid=sid)
 			me.mmtype = proto.addr_type(mmtype)
 			me.idx = AddrIdx(idx)
 			me.al_id = str.__new__(AddrListID, me.sid + ':' + me.mmtype) # checks already done
-			me.sort_key = f'{me.sid}:{me.mmtype}:{me.idx:0{me.idx.max_digits}}'
+			if ext:
+				me.sort_key = '{}:{}:{:0{w1}}:{:0{w2}}:{:0{w2}}'.format(
+					me.sid,
+					me.mmtype,
+					me.idx,
+					me.acct_idx,
+					me.addr_idx,
+					w1 = me.idx.max_digits,
+					w2 = MoneroIdx.max_digits)
+			else:
+				me.sort_key = '{}:{}:{:0{w}}'.format(me.sid, me.mmtype, me.idx, w=me.idx.max_digits)
 			me.proto = proto
 			return me
 		except Exception as e:

+ 1 - 0
mmgen/cfg.py

@@ -240,6 +240,7 @@ class Config(Lockable):
 	# Monero:
 	monero_wallet_rpc_user     = 'monero'
 	monero_wallet_rpc_password = ''
+	monero_daemon              = ''
 	xmrwallet_compat           = False
 	priority                   = 0
 

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-16.1.dev14
+16.1.dev15

+ 6 - 3
mmgen/help/xmrwallet.py

@@ -49,7 +49,7 @@ transfer  - transfer specified XMR amount from specified wallet:account to
             specified address
 sweep     - sweep funds in specified wallet:account to new address in same
             account, or new or specified account in another wallet
-sweep_all - same as above, but sweep balances of all addresses in the account
+sweep-all - same as above, but sweep balances of all addresses in the account
 relay     - relay a transaction from a transaction file created using ‘sweep’
             or ‘transfer’ with the --no-relay option
 submit    - submit an autosigned transaction to a wallet and the network
@@ -59,8 +59,11 @@ abort     - abort the current transaction created with --autosign.  The
             transaction may be signed or unsigned
 txview    - display detailed information about a transaction file or files
 txlist    - same as above, but display terse information in tabular format
-dump      - produce JSON dumps of wallet metadata (accounts, addresses and
-            labels) for a list or range of wallets
+dump-json - dump wallet metadata (accounts, addresses, labels), plus address
+            balances, for a list or range of wallets, to standard output in
+            JSON format
+dump      - same as above, but dump metadata only and save the dumps to
+            separate files for each wallet
 restore   - same as ‘create’, but additionally restore wallet metadata from
             the corresponding JSON dump files created with ‘dump’
 export-outputs      - export outputs of watch-only wallets for import into

+ 1 - 0
mmgen/main_tool.py

@@ -34,6 +34,7 @@ opts_data = {
 			-- -d, --outdir=       d  Specify an alternate directory 'd' for output
 			-- -h, --help             Print this help message
 			-- --, --longhelp         Print help message for long (global) options
+			x- -a, --autosign         Operate on an autosigned transaction
 			-- -e, --echo-passphrase  Echo passphrase or mnemonic to screen upon entry
 			-- -k, --use-internal-keccak-module Force use of the internal keccak module
 			-- -K, --keygen-backend=n Use backend 'n' for public key generation.  Options

+ 8 - 8
mmgen/main_xmrwallet.py

@@ -28,7 +28,7 @@ opts_data = {
 		'desc': """Perform various Monero wallet and transacting operations for
                    addresses in an MMGen XMR key-address file""",
 		'usage2': [
-			'[opts] create | sync | list | view | listview | dump | restore [xmr_keyaddrfile] [wallets]',
+			'[opts] create | sync | list | view | listview | dump-json | dump | restore [xmr_keyaddrfile] [wallets]',
 			'[opts] label    [xmr_keyaddrfile] LABEL_SPEC',
 			'[opts] new      [xmr_keyaddrfile] NEW_ADDRESS_SPEC',
 			'[opts] transfer [xmr_keyaddrfile] TRANSFER_SPEC',
@@ -62,7 +62,7 @@ opts_data = {
 -b, --rescan-blockchain          Rescan the blockchain if wallet fails to sync
 -d, --outdir=D                   Save transaction files to directory 'D'
                                  instead of the working directory
--D, --daemon=H:P                 Connect to the monerod at {D}
+-D, --daemon=H:P                 Connect to the monerod at {dhp}
 -e, --skip-empty-accounts        Skip display of empty accounts in wallets
                                  where applicable
 -E, --skip-empty-addresses       Skip display of used empty addresses in
@@ -73,7 +73,7 @@ opts_data = {
 -P, --rescan-spent               Perform a rescan of spent outputs.  Used only
                                  with the ‘export-outputs-sign’ operation
 -R, --tx-relay-daemon=H:P[:H:P]  Relay transactions via a monerod specified by
-                                 {R}
+                                 {rdhp}
 -r, --restore-height=H           Scan from height 'H' when creating wallets.
                                  Use special value ‘current’ to create empty
                                  wallet at current blockchain height.
@@ -93,8 +93,8 @@ opts_data = {
 	},
 	'code': {
 		'options': lambda cfg, help_notes, s: s.format(
-			D   = xmrwallet.uarg_info['daemon'].annot,
-			R   = xmrwallet.uarg_info['tx_relay_daemon'].annot,
+			dhp = xmrwallet.uarg_info['daemon'].annot,
+			rdhp = xmrwallet.uarg_info['tx_relay_daemon'].annot,
 			cfg = cfg,
 			gc  = gc,
 			tw_dir = help_notes('tw_dir'),
@@ -111,9 +111,9 @@ cfg = Config(opts_data=opts_data, init_opts={'coin':'xmr'})
 cmd_args = cfg._args
 
 if cmd_args and cfg.autosign and (
-		cmd_args[0] in (
+		cmd_args[0].replace('-', '_') in (
 			xmrwallet.kafile_arg_ops
-			+ ('export-outputs', 'export-outputs-sign', 'import-key-images', 'txview', 'txlist')
+			+ ('export_outputs', 'export_outputs_sign', 'import_key_images', 'txview', 'txlist')
 		)
 		or len(cmd_args) == 1 and cmd_args[0] in ('submit', 'resubmit', 'abort')
 	):
@@ -133,7 +133,7 @@ match op:
 			cfg._usage()
 	case 'txview' | 'txlist':
 		infile = [infile] + cmd_args
-	case 'create' | 'sync' | 'list' | 'view' | 'listview' | 'dump' | 'restore': # kafile_arg_ops
+	case 'create' | 'sync' | 'list' | 'view' | 'listview' | 'dump_json' | 'dump' | 'restore':
 		if len(cmd_args) > 1:
 			cfg._usage()
 		wallets = cmd_args.pop(0) if cmd_args else None

+ 5 - 0
mmgen/opts.py

@@ -308,6 +308,9 @@ class UserOpts(Opts):
 			br --rpc-user=USER        Authenticate to coin daemon using username USER
 			br --rpc-password=PASS    Authenticate to coin daemon using password PASS
 			Rr --rpc-backend=backend  Use backend 'backend' for JSON-RPC communications
+			mr --monero-wallet-rpc-user=USER Monero wallet RPC username
+			mr --monero-wallet-rpc-password=USER Monero wallet RPC password
+			mr --monero-daemon=HOST:PORT Connect to the monerod at HOST:PORT
 			Rr --aiohttp-rpc-queue-len=N Use N simultaneous RPC connections with aiohttp
 			-p --regtest=0|1          Disable or enable regtest mode
 			-- --testnet=0|1          Disable or enable testnet
@@ -361,6 +364,7 @@ class UserOpts(Opts):
 		  'r' - local RPC coin
 		  'X' - remote RPC coin
 		  'x' - local or remote RPC coin
+		  'm' - Monero
 		  '-' - any coin
 		Cmd codes:
 		  'p' - proto required
@@ -378,6 +382,7 @@ class UserOpts(Opts):
 					['-', 'r', 'R', 'b', 'h', 'x'] if coin == 'bch' else
 					['-', 'r', 'R', 'b', 'x'] if coin in gc.btc_fork_rpc_coins else
 					['-', 'r', 'R', 'e', 'x'] if coin in gc.eth_fork_coins else
+					['-', 'r', 'x', 'm'] if coin == 'xmr' else
 					['-', 'r', 'x'] if coin in gc.local_rpc_coins else
 					['-', 'X', 'x'] if coin in gc.remote_rpc_coins else
 					['-']),

+ 22 - 0
mmgen/proto/xmr/tw/addresses.py

@@ -0,0 +1,22 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 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.xmr.tw.addresses: Monero protocol tracking wallet address list class
+"""
+
+from ....tw.addresses import TwAddresses
+
+from .view import MoneroTwView
+
+class MoneroTwAddresses(MoneroTwView, TwAddresses):
+
+	include_empty = True
+	has_used = True

+ 26 - 0
mmgen/proto/xmr/tw/ctl.py

@@ -12,8 +12,34 @@
 proto.xmr.tw.ctl: Monero tracking wallet control class
 """
 
+from ....tw.ctl import write_mode
 from ....tw.store import TwCtlWithStore
 
 class MoneroTwCtl(TwCtlWithStore):
 
 	tw_subdir = 'tracking-wallets'
+	use_cached_balances = True
+
+	@write_mode
+	async def set_comment(
+			self,
+			addrspec,
+			comment      = '',
+			*,
+			trusted_pair = None,
+			silent       = False):
+
+		from ....ui import keypress_confirm
+		add_timestr = keypress_confirm(self.cfg, 'Add timestamp to label?')
+
+		m = trusted_pair[0].obj
+		from ....xmrwallet import op as xmrwallet_op
+		op = xmrwallet_op(
+			'label',
+			self.cfg,
+			None,
+			None,
+			spec = f'{m.idx}:{m.acct_idx}:{m.addr_idx},{comment}',
+			compat_call = True)
+		await op.restart_wallet_daemon()
+		return await op.main(add_timestr=add_timestr, auto=True)

+ 23 - 0
mmgen/proto/xmr/tw/unspent.py

@@ -0,0 +1,23 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 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.xmr.tw.unspent: Monero protocol tracking wallet unspent outputs class
+"""
+
+from ....tw.addresses import TwAddresses
+
+from .view import MoneroTwView
+
+class MoneroTwUnspentOutputs(MoneroTwView, TwAddresses):
+
+	hdr_lbl = 'spendable accounts'
+	desc    = 'address balances'
+	include_empty = False

+ 81 - 0
mmgen/proto/xmr/tw/view.py

@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 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.xmr.tw.view: Monero protocol base class for tracking wallet view classes
+"""
+
+from ....xmrwallet import op as xmrwallet_op
+from ....tw.view import TwView
+
+class MoneroTwView:
+
+	class rpc:
+		caps = ()
+		is_remote = False
+
+	prompt_fs_repl = {'XMR': (
+		(1, 'Filters: show [E]mpty addrs, [u]sed addrs, all [L]abels'),
+		(3, 'Actions: [q]uit menu, add [l]abel, [R]efresh balances:'))}
+	extra_key_mappings = {
+		'u': 'd_showused',
+		'R': 'a_sync_wallets'}
+	removed_key_mappings = {
+		'D': 'i_addr_delete'}
+
+	async def get_rpc_data(self):
+		from mmgen.tw.shared import TwMMGenID, TwLabel
+
+		op = xmrwallet_op('dump_data', self.cfg, None, None, compat_call=True)
+		await op.restart_wallet_daemon()
+		wallets_data = await op.main()
+
+		self.total = self.proto.coin_amt('0')
+
+		def gen_addrs():
+			for wdata in wallets_data:
+				bals_data = {i: {} for i in range(len(wdata['data'].accts_data['subaddress_accounts']))}
+
+				for d in wdata['data'].bals_data.get('per_subaddress', []):
+					bals_data[d['account_index']].update({d['address_index']: d['unlocked_balance']})
+
+				for acct_idx, acct_data in enumerate(wdata['data'].addrs_data):
+					for addr_data in acct_data['addresses']:
+						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:
+							mmid = '{}:M:{}-{}/{}'.format(
+								wdata['seed_id'],
+								wdata['wallet_num'],
+								acct_idx,
+								addr_idx)
+							yield (TwMMGenID(self.proto, mmid), {
+								'addr':    addr_data['address'],
+								'amt':     bal,
+								'recvd':   bal,
+								'is_used': addr_data['used'],
+								'confs':   1,
+								'lbl':     TwLabel(self.proto, mmid + ' ' + addr_data['label'])})
+
+		return dict(gen_addrs())
+
+	class action(TwView.action):
+
+		async def a_sync_wallets(self, parent):
+			from ....util import msg, msg_r
+			from ....tw.view import CUR_HOME, ERASE_ALL
+			msg('')
+			op = xmrwallet_op('sync', parent.cfg, None, None, compat_call=True)
+			await op.restart_wallet_daemon()
+			await op.main()
+			await parent.get_data()
+			msg_r(CUR_HOME + ERASE_ALL)

+ 4 - 3
mmgen/tool/rpc.py

@@ -157,13 +157,14 @@ class tool_cmd(tool_cmd_base):
 
 	async def add_label(self, mmgen_or_coin_addr: str, label: str):
 		"add descriptive label for address in tracking wallet"
+		from ..obj import TwComment
 		from ..tw.ctl import TwCtl
-		return await (await TwCtl(self.cfg, self.proto, mode='w')).set_comment(mmgen_or_coin_addr, label)
+		ret = await (await TwCtl(self.cfg, self.proto, mode='w')).set_comment(mmgen_or_coin_addr, label)
+		return True if isinstance(ret, TwComment) else False
 
 	async def remove_label(self, mmgen_or_coin_addr: str):
 		"remove descriptive label for address in tracking wallet"
-		await self.add_label(mmgen_or_coin_addr, '')
-		return True
+		return await self.add_label(mmgen_or_coin_addr, '')
 
 	async def remove_address(self, mmgen_or_coin_addr: str):
 		"remove an address from tracking wallet"

+ 1 - 5
mmgen/tw/ctl.py

@@ -141,16 +141,12 @@ class TwCtl(MMGenObject, metaclass=AsyncInit):
 				msg(
 					'Added label {} to {}'.format(comment.hl2(encl='‘’'), desc) if comment else
 					'Removed label from {}'.format(desc))
-			return True
+			return comment
 		else:
 			if not silent:
 				msg('Label could not be {}'.format('added' if comment else 'removed'))
 			return False
 
-	@write_mode
-	async def remove_comment(self, mmaddr):
-		await self.set_comment(mmaddr, '')
-
 	def check_import_mmid(self, addr, old_mmid, new_mmid):
 		'returns True if mmid needs update, None otherwise'
 		if new_mmid != old_mmid:

+ 26 - 17
mmgen/tw/view.py

@@ -96,6 +96,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 	display_hdr = ()
 	display_body = ()
 	prompt_fs_repl = {}
+	removed_key_mappings = {}
 	nodata_msg = '[no data for requested parameters]'
 	cols = 0
 	term_height = 0
@@ -191,14 +192,21 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 	async def __init__(self, cfg, proto):
 		self.cfg = cfg
 		self.proto = proto
-		self.rpc = await rpc_init(cfg, proto)
+		if have_rpc := 'rpc_init' in proto.mmcaps:
+			self.rpc = await rpc_init(cfg, proto)
 		if self.has_wallet:
 			from .ctl import TwCtl
-			self.twctl = await TwCtl(cfg, proto, mode='w')
+			self.twctl = await TwCtl(cfg, proto, mode='w', no_rpc=not have_rpc)
 		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]
+		if repl_data := self.prompt_fs_repl.get(self.proto.coin):
+			for repl in [repl_data] if isinstance(repl_data[0], int) else repl_data:
+				self.prompt_fs_in[repl[0]] = repl[1]
 		self.prompt_fs = '\n'.join(self.prompt_fs_in)
+		for k, v in self.removed_key_mappings.items():
+			if k in self.key_mappings and self.key_mappings[k] == v:
+				del self.key_mappings[k]
+			else:
+				raise ValueError(f'{k}: {v}: unrecognized or invalid key mapping')
 		self.key_mappings.update(self.extra_key_mappings)
 		if self.proto.coin == 'BCH':
 			self.key_mappings.update({'h': 'd_addr_view_pref'})
@@ -434,7 +442,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 				yield 'Network: {}'.format(Green(
 					self.proto.coin + ' ' + self.proto.chain_name.upper()))
 
-				if not self.rpc.is_remote:
+				if hasattr(self.rpc, 'blockcount'):
 					yield 'Block {} [{}]'.format(
 						self.rpc.blockcount.hl(color=color),
 						make_timestr(self.rpc.cur_date))
@@ -474,8 +482,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 				tuple(gen_hdr(spc='' if line_processing == 'print' else ' ')),
 				tuple(
 					get_body(getattr(self, dt.fmt_method)) if data else
-					[(nocolor, yellow)[color](self.nodata_msg.ljust(self.term_width))])
-			)
+					[(nocolor, yellow)[color](self.nodata_msg.ljust(self.term_width))]))
 
 		if not gv.stdout.isatty():
 			line_processing = 'print'
@@ -536,8 +543,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 			'\n'.join(display_hdr) + '\n'
 			+ dt.item_separator.join(display_body[top:bot])
 			+ fill
-			+ footer
-		)
+			+ footer)
 
 	async def view_filter_and_sort(self):
 
@@ -713,12 +719,13 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 					#  None:   action aborted by user or no action performed
 					#  False:  an error occurred
 					#  'redo': user will be re-prompted for item number
+					#  'redraw': action successfully performed, screen will be redrawn
 					ret = await action_method(parent, idx)
 					if ret != 'redo':
 						break
 					await asyncio.sleep(0.5)
 
-			if parent.scroll and ret is False:
+			if parent.scroll and ret is False or ret == 'redraw':
 				# error messages could leave screen in messy state, so do complete redraw:
 				msg_r(
 					CUR_HOME + ERASE_ALL +
@@ -739,7 +746,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 				parent.oneshot_msg = yellow(
 					f'{parent.proto.dcoin} balance for {parent.item_desc} #{idx} refreshed')
 				if res == 0:
-					return False # zeroing balance may mess up display
+					return 'redraw' # zeroing balance may mess up display
 
 		async def i_addr_delete(self, parent, idx):
 			if not parent.keypress_confirm(
@@ -757,20 +764,22 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 
 		async def i_comment_add(self, parent, idx):
 
-			async def do_comment_add(comment):
-
-				if await parent.twctl.set_comment(
+			async def do_comment_add(comment_in):
+				from ..obj import TwComment
+				comment = await parent.twctl.set_comment(
 						addrspec     = None,
-						comment      = comment,
+						comment      = comment_in,
 						trusted_pair = (entry.twmmid, entry.addr),
-						silent       = parent.scroll):
+						silent       = parent.scroll)
+
+				if isinstance(comment, TwComment):
 					entry.comment = comment
 					edited = cur_comment and comment
 					parent.oneshot_msg = (green if comment else yellow)('Label {a} {b}{c}'.format(
 						a = 'for' if edited else 'added to' if comment else 'removed from',
 						b = desc,
 						c = ' edited' if edited else ''))
-					return True
+					return 'redraw' if parent.cfg.coin == 'XMR' else True
 				else:
 					await asyncio.sleep(3)
 					parent.oneshot_msg = red('Label for {desc} could not be {action}'.format(

+ 9 - 2
mmgen/xmrwallet/__init__.py

@@ -64,6 +64,8 @@ op_names = {
 	'submit':              'submit',
 	'resubmit':            'submit',
 	'abort':               'submit',
+	'dump_data':           'dump',
+	'dump_json':           'dump',
 	'dump':                'dump',
 	'restore':             'restore',
 	'export_outputs':      'export',
@@ -84,6 +86,8 @@ kafile_arg_ops = (
 	'transfer',
 	'sweep',
 	'sweep_all',
+	'dump_data',
+	'dump_json',
 	'dump',
 	'restore')
 
@@ -112,8 +116,8 @@ def op_cls(op_name):
 	cls.name = op_name
 	return cls
 
-def op(op, cfg, infile, wallets, *, spec=None):
-	if cfg.compat if cfg.compat is not None else cfg.xmrwallet_compat:
+def op(op, cfg, infile, wallets, *, spec=None, compat_call=False):
+	if compat_call or (cfg.compat if cfg.compat is not None else cfg.xmrwallet_compat):
 		if cfg.wallet_dir:
 			die(1, '--wallet-dir can not be specified in xmrwallet compatibility mode')
 		from ..tw.ctl import TwCtl
@@ -122,5 +126,8 @@ def op(op, cfg, infile, wallets, *, spec=None):
 		cfg = Config({
 			'_clone': cfg,
 			'compat': True,
+			'no_start_wallet_daemon': cfg.no_start_wallet_daemon or compat_call,
+			'daemon': cfg.daemon or cfg.monero_daemon,
+			'watch_only': cfg.watch_only or cfg.autosign or bool(cfg.autosign_mountpoint),
 			'wallet_dir': twctl_cls.get_tw_dir(cfg, cfg._proto)})
 	return op_cls(op)(cfg, uargs(infile, wallets, spec))

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

@@ -29,6 +29,7 @@ class OpBase:
 	trust_monerod = False
 	do_umount = True
 	name = None
+	return_data = False
 
 	def __init__(self, cfg, uarg_tuple):
 
@@ -110,12 +111,13 @@ class OpBase:
 			  Proxy: {blue(m[2] or 'None')}
 			""", strip_char='\t', indent=indent))
 
-	def mount_removable_device(self):
+	def mount_removable_device(self, registered=[]):
 		if self.cfg.autosign:
 			if not self.asi.device_inserted:
 				die(1, 'Removable device not present!')
-			if self.do_umount:
+			if self.do_umount and not registered:
 				atexit.register(lambda: self.asi.do_umount())
+				registered.append(None)
 			self.asi.do_mount()
 			self.post_mount_action()
 

+ 29 - 1
mmgen/xmrwallet/ops/dump.py

@@ -12,7 +12,7 @@
 xmrwallet.ops.dump: Monero wallet ops for the MMGen Suite
 """
 
-from ...util import msg
+from ...util import msg, Msg
 
 from ..file.outputs import MoneroWalletDumpFile
 from ..rpc import MoneroWalletRPC
@@ -33,3 +33,31 @@ class OpDump(OpWallet):
 			data      = {'wallet_metadata': wallet_data.addrs_data}
 		).write()
 		return True
+
+class OpDumpDataCommon(OpWallet):
+	wallet_offline = True
+	return_data = True
+
+	async def process_wallet(self, d, fn, last):
+		h = MoneroWalletRPC(self, d)
+		h.open_wallet('source', refresh=False)
+		return {
+			'seed_id': self.kal.al_id.sid,
+			'wallet_num': d.idx,
+			'data': h.get_wallet_data(print=False)}
+
+	def post_main_success(self):
+		pass
+
+class OpDumpData(OpDumpDataCommon):
+	start_daemon = False
+	stem = 'load'
+
+class OpDumpJson(OpDumpDataCommon):
+	stem = 'dump'
+
+	async def main(self):
+		import json
+		data = await super().main()
+		Msg(json.dumps(data))
+		return sum(map(bool, data))

+ 21 - 14
mmgen/xmrwallet/ops/label.py

@@ -15,6 +15,7 @@ xmrwallet.ops.label: Monero wallet ops for the MMGen Suite
 from ...color import pink, cyan, gray
 from ...util import msg, ymsg, gmsg, die, make_timestr
 from ...ui import keypress_confirm
+from ...obj import TwComment
 from ...addr import CoinAddr
 
 from ..rpc import MoneroWalletRPC
@@ -28,7 +29,7 @@ class OpLabel(OpMixinSpec, OpWallet):
 	opts     = ()
 	wallet_offline = True
 
-	async def main(self):
+	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',
@@ -39,13 +40,13 @@ class OpLabel(OpMixinSpec, OpWallet):
 		h = MoneroWalletRPC(self, self.source)
 
 		h.open_wallet('source')
-		wallet_data = h.get_wallet_data()
+		wallet_data = h.get_wallet_data(print=not auto)
 
 		max_acct = len(wallet_data.accts_data['subaddress_accounts']) - 1
 		if self.account > max_acct:
 			die(2, f'{self.account}: requested account index out of bounds (>{max_acct})')
 
-		ret = h.print_acct_addrs(wallet_data, self.account)
+		ret = h.print_acct_addrs(wallet_data, self.account, silent=auto)
 
 		if self.address_idx > len(ret) - 1:
 			die(2, '{}: requested address index out of bounds (>{})'.format(
@@ -53,23 +54,28 @@ class OpLabel(OpMixinSpec, OpWallet):
 				len(ret) - 1))
 
 		addr = ret[self.address_idx]
-		new_label = f'{self.label} [{make_timestr()}]' if self.label else ''
+		if self.label and add_timestr == 'ask':
+			add_timestr = keypress_confirm(self.cfg, '\n  Add timestamp to label?')
+		new_label = TwComment(
+			(self.label + (f' [{make_timestr()}]' if add_timestr else '')) if self.label
+			else '')
 
-		ca = CoinAddr(self.proto, addr['address'])
-		from . import addr_width
-		msg('\n  {a} {b}\n  {c} {d}\n  {e} {f}'.format(
-				a = 'Address:       ',
-				b = ca.hl(0) if self.cfg.full_address else ca.fmt(0, addr_width, color=True),
-				c = 'Existing label:',
-				d = pink(addr['label']) if addr['label'] else gray('[none]'),
-				e = 'New label:     ',
-				f = pink(new_label) if new_label else gray('[none]')))
+		if not auto:
+			ca = CoinAddr(self.proto, addr['address'])
+			from . import addr_width
+			msg('\n  {a} {b}\n  {c} {d}\n  {e} {f}'.format(
+					a = 'Address:       ',
+					b = ca.hl(0) if self.cfg.full_address else ca.fmt(0, addr_width, color=True),
+					c = 'Existing label:',
+					d = pink(addr['label']) if addr['label'] else gray('[none]'),
+					e = 'New label:     ',
+					f = pink(new_label) if new_label else gray('[none]')))
 
 		op = 'remove' if not new_label else 'update' if addr['label'] else 'set'
 
 		if addr['label'] == new_label:
 			ymsg('\nLabel is unchanged, operation cancelled')
-		elif keypress_confirm(self.cfg, f'  {op.capitalize()} label?'):
+		elif auto or keypress_confirm(self.cfg, f'  {op.capitalize()} label?'):
 			h.set_label(self.account, self.address_idx, new_label)
 			ret = h.print_acct_addrs(h.get_wallet_data(print=False), self.account)
 			label_chk = ret[self.address_idx]['label']
@@ -78,5 +84,6 @@ class OpLabel(OpMixinSpec, OpWallet):
 				return False
 			else:
 				msg(cyan('\nLabel successfully {}'.format('set' if op == 'set' else op+'d')))
+				return new_label
 		else:
 			ymsg('\nOperation cancelled by user request')

+ 2 - 2
mmgen/xmrwallet/ops/new.py

@@ -14,7 +14,7 @@ xmrwallet.ops.new: Monero wallet ops for the MMGen Suite
 
 from ...color import red, pink
 from ...util import msg, ymsg, make_timestr
-
+from ...obj import TwComment
 from ...ui import keypress_confirm
 
 from ..rpc import MoneroWalletRPC
@@ -32,7 +32,7 @@ class OpNew(OpMixinSpec, OpWallet):
 		h.open_wallet('Monero')
 
 		desc = 'account' if self.account is None else 'address'
-		label = (
+		label = TwComment(
 			None if self.label == '' else
 			'{} [{}]'.format(self.label or f'xmrwallet new {desc}', make_timestr()))
 

+ 3 - 4
mmgen/xmrwallet/ops/txview.py

@@ -53,8 +53,7 @@ class OpTxview(OpBase):
 			(self.hdr if len(files) > 1 else '')
 			+ self.col_hdr
 			+ '\n'.join(getattr(tx, self.view_method)(addr_w=addr_w) for tx in txs)
-			+ self.footer
-		)
+			+ self.footer)
 
 class OpTxlist(OpTxview):
 	view_method = 'get_info_oneline'
@@ -89,6 +88,6 @@ class OpTxlist(OpTxview):
 			from ...term import get_terminal_size
 			cols = self.cfg.columns or get_terminal_size().width
 			if cols < self.fixed_cols_w + self.min_addr_w:
-				die(1, f'A terminal at least {self.fixed_cols_w + self.min_addr_w} columns wide is required '
-						'to display this output (or use --columns or --pager)')
+				die(1, f'A terminal at least {self.fixed_cols_w + self.min_addr_w} columns wide is'
+						' required to display this output (or use --columns or --pager)')
 		await super().main(cols=cols)

+ 29 - 27
mmgen/xmrwallet/ops/wallet.py

@@ -32,8 +32,7 @@ class OpWallet(OpBase):
 		'no_start_wallet_daemon',
 		'no_stop_wallet_daemon',
 		'autosign',
-		'watch_only',
-	)
+		'watch_only')
 	wallet_offline = False
 	wallet_exists = True
 	start_daemon = True
@@ -70,8 +69,7 @@ class OpWallet(OpBase):
 			test_suite  = self.cfg.test_suite,
 			monerod_addr = self.cfg.daemon or None,
 			trust_monerod = self.trust_monerod,
-			test_monerod = not self.wallet_offline,
-		)
+			test_monerod = not self.wallet_offline)
 
 		if self.wallet_offline:
 			self.wd.usr_daemon_args = ['--offline']
@@ -79,8 +77,7 @@ class OpWallet(OpBase):
 		self.c = MoneroWalletRPCClient(
 			cfg             = self.cfg,
 			daemon          = self.wd,
-			test_connection = False,
-		)
+			test_connection = False)
 
 		if self.cfg.offline:
 			from ...wallet import Wallet
@@ -100,11 +97,14 @@ class OpWallet(OpBase):
 			self.mount_removable_device()
 			# with watch_only, make a second attempt to open the file as KeyAddrList:
 			for first_try in (True, False):
+				addr_list = ViewKeyAddrList if (self.cfg.watch_only and first_try) else KeyAddrList
 				try:
-					self.kal = (ViewKeyAddrList if (self.cfg.watch_only and first_try) else KeyAddrList)(
+					self.kal = addr_list(
 						cfg    = cfg,
 						proto  = self.proto,
-						infile = str(self.autosign_viewkey_addr_file) if self.cfg.autosign else self.uargs.infile,
+						infile =
+							str(self.autosign_viewkey_addr_file) if self.cfg.autosign else
+							self.uargs.infile,
 						key_address_validity_check = True,
 						skip_chksum_msg = True)
 					break
@@ -134,7 +134,9 @@ class OpWallet(OpBase):
 
 	def get_coin_daemon_rpc(self):
 
-		host, port = self.cfg.daemon.split(':') if self.cfg.daemon else ('localhost', self.wd.monerod_port)
+		host, port = (
+			self.cfg.daemon.split(':') if self.cfg.daemon else
+			('localhost', self.wd.monerod_port))
 
 		from ...daemon import CoinDaemon
 		return MoneroRPCClient(
@@ -154,10 +156,9 @@ class OpWallet(OpBase):
 			die(2,
 				'{a} viewkey-address files found in autosign mountpoint directory ‘{b}’!\n'.format(
 					a = 'Multiple' if flist else 'No',
-					b = self.asi.xmr_dir
-				)
-				+ 'Have you run ‘mmgen-autosign setup’ on your offline machine with the --xmrwallets option?'
-			)
+					b = self.asi.xmr_dir)
+				+ 'Have you run ‘mmgen-autosign setup’ on your offline machine'
+				' with the --xmrwallets option?')
 		else:
 			return flist[0]
 
@@ -166,12 +167,16 @@ class OpWallet(OpBase):
 			idxs = AddrIdxList(fmt_str=self.uargs.wallets)
 			self.addr_data = [d for d in self.kal.data if d.idx in idxs]
 			if len(self.addr_data) != len(idxs):
-				die(1, f'List {self.uargs.wallets!r} contains addresses not present in supplied key-address file')
+				die(1,
+					f'List {self.uargs.wallets!r} contains addresses not present'
+					' in supplied key-address file')
 		else:
 			self.addr_data = self.kal.data
 
-	async def restart_wallet_daemon(self):
-		atexit.register(lambda: asyncio.run(self.stop_wallet_daemon()))
+	async def restart_wallet_daemon(self, registered=[]):
+		if not registered:
+			atexit.register(lambda: asyncio.run(self.stop_wallet_daemon()))
+			registered.append(None)
 		await self.c.restart_daemon()
 
 	async def stop_wallet_daemon(self):
@@ -179,7 +184,7 @@ class OpWallet(OpBase):
 			try:
 				await self.c.stop_daemon()
 			except KeyboardInterrupt:
-				ymsg('\nForce killing wallet daemon')
+				ymsg('\nForce-killing wallet daemon')
 				self.c.daemon.force_kill = True
 				self.c.daemon.stop()
 
@@ -192,8 +197,7 @@ class OpWallet(OpBase):
 				a = self.kal.al_id.sid,
 				b = data.idx,
 				c = 'WatchOnly' if watch_only else '',
-				d = f'.{self.cfg.network}' if self.cfg.network != 'mainnet' else '')
-		)
+				d = f'.{self.cfg.network}' if self.cfg.network != 'mainnet' else ''))
 
 	@property
 	def add_wallet_desc(self):
@@ -205,23 +209,21 @@ class OpWallet(OpBase):
 			b = len(self.addr_data),
 			c = self.add_wallet_desc,
 			d = suf(self.addr_data)))
-		processed = 0
+		data = []
 		for n, d in enumerate(self.addr_data): # [d.sec,d.addr,d.wallet_passwd,d.viewkey]
 			fn = self.get_wallet_fn(d)
 			gmsg('\n{a}ing wallet {b}/{c} ({d})'.format(
 				a = self.stem.capitalize(),
 				b = n + 1,
 				c = len(self.addr_data),
-				d = fn.name,
-			))
-			processed += await self.process_wallet(d, fn, last=n==len(self.addr_data)-1)
-		gmsg(f'\n{processed} wallet{suf(processed)} {self.stem}ed\n')
-		return processed
+				d = fn.name))
+			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')
+		return data if self.return_data else sum(map(bool, data))
 
 	def head_msg(self, wallet_idx, fn):
 		gmsg('\n{a} {b}wallet #{c} ({d})'.format(
 			a = self.action.capitalize(),
 			b = self.add_wallet_desc,
 			c = wallet_idx,
-			d = fn.name
-		))
+			d = fn.name))

+ 6 - 5
mmgen/xmrwallet/rpc.py

@@ -110,11 +110,12 @@ class MoneroWalletRPC:
 		msg('      Address: {}'.format(cyan(ret['base_address'])))
 		return (ret['account_index'], ret['base_address'])
 
-	def print_acct_addrs(self, wallet_data, account):
-		msg('\n      Addresses of account #{} ({}):'.format(
-			account,
-			wallet_data.accts_data['subaddress_accounts'][account]['label']))
-		msg('\n'.join(gen_acct_addr_info(self, wallet_data, account, indent='        ')))
+	def print_acct_addrs(self, wallet_data, account, silent=False):
+		if not silent:
+			msg('\n      Addresses of account #{} ({}):'.format(
+				account,
+				wallet_data.accts_data['subaddress_accounts'][account]['label']))
+			msg('\n'.join(gen_acct_addr_info(self, wallet_data, account, indent='        ')))
 		return wallet_data.addrs_data[account]['addresses']
 
 	def create_new_addr(self, account, label):

+ 1 - 0
test/cmdtest_d/include/cfg.py

@@ -49,6 +49,7 @@ cmd_groups_dfl = {
 	'runeswap':           gd('CmdTestRuneSwap',          {}),
 	'xmrwallet':          gd('CmdTestXMRWallet',         {}),
 	'xmr_autosign':       gd('CmdTestXMRAutosign',       {}),
+	'xmr_compat':         gd('CmdTestXMRCompat',         {'modname': 'xmr_autosign'}),
 }
 
 cmd_groups_extra = {

+ 1 - 1
test/cmdtest_d/include/runner.py

@@ -168,7 +168,7 @@ class CmdTestRunner:
 
 		if self.logging:
 			self.log_fd.write('[{}][{}:{}] {}\n'.format(
-				(self.proto.coin.lower() if 'coin' in self.tg.passthru_opts else 'NONE'),
+				self.proto.coin.lower(),
 				self.tg.group_name,
 				self.tg.test_name,
 				cmd_disp))

+ 78 - 11
test/cmdtest_d/xmr_autosign.py

@@ -17,8 +17,8 @@ import re, asyncio
 
 from mmgen.color import blue, cyan, brown
 
-from ..include.common import imsg, silence, end_silence
-from .include.common import get_file_with_ext
+from ..include.common import imsg, silence, end_silence, strip_ansi_escapes
+from .include.common import get_file_with_ext, cleanup_env
 
 from .xmrwallet import CmdTestXMRWallet
 from .autosign import CmdTestAutosignThreaded
@@ -50,18 +50,19 @@ class CmdTestXMRAutosign(CmdTestXMRWallet, CmdTestAutosignThreaded):
 
 	cmd_group = (
 		('daemon_version',           'checking daemon version'),
-		('gen_kafile_miner',         'generating key-address file for Miner'),
-		('create_wallet_miner',      'creating Monero wallet for Miner'),
-		('mine_initial_coins',       'mining initial coins'),
 		('create_tmp_wallets',       'creating temporary online wallets for Alice'),
 		('new_account_alice',        'adding an account to Alice’s tmp wallet'),
 		('new_address_alice',        'adding an address to Alice’s tmp wallet'),
 		('new_address_alice_label',  'adding an address to Alice’s tmp wallet (with label)'),
 		('dump_tmp_wallets',         'dumping Alice’s tmp wallets'),
+		('dump_tmp_wallets_json',    'dumping Alice’s tmp wallets to JSON format'),
 		('delete_tmp_wallets',       'deleting Alice’s tmp wallets'),
+		('gen_kafile_miner',         'generating key-address file for Miner'),
+		('create_wallet_miner',      'creating Monero wallet for Miner'),
+		('mine_initial_coins',       'mining initial coins'),
 		('autosign_setup',           'autosign setup with Alice’s seed'),
 		('autosign_xmr_setup',       'autosign setup (creation of Monero signing wallets)'),
-		('create_watchonly_wallets', 'creating watch-only wallets from Alice’s wallet dumps'),
+		('restore_watchonly_wallets', 'creating watch-only wallets from Alice’s wallet dumps'),
 		('delete_tmp_dump_files',    'deleting Alice’s dump files'),
 		('fund_alice1',              'sending funds to Alice (wallet #1)'),
 		('check_bal_alice1',         'mining, checking balance (wallet #1)'),
@@ -174,10 +175,13 @@ class CmdTestXMRAutosign(CmdTestXMRWallet, CmdTestAutosignThreaded):
 	def dump_tmp_wallets(self):
 		return self._dump_wallets(autosign=False)
 
+	def dump_tmp_wallets_json(self):
+		return self._dump_wallets(autosign=False, op='dump_json')
+
 	def dump_wallets(self):
 		return self._dump_wallets(autosign=True)
 
-	def _dump_wallets(self, autosign):
+	def _dump_wallets(self, autosign, op='dump'):
 		data = self.users['alice']
 		self.insert_device_online()
 		t = self.spawn(
@@ -186,10 +190,14 @@ class CmdTestXMRAutosign(CmdTestXMRWallet, CmdTestAutosignThreaded):
 			+ (['--alice', '--compat'] if self.compat else [f'--wallet-dir={data.udir}'])
 			+ [f'--daemon=localhost:{data.md.rpc_port}']
 			+ (self.autosign_opts if autosign else [])
-			+ ['dump']
-			+ ([] if autosign else [get_file_with_ext(data.udir, 'akeys')]))
+			+ [op]
+			+ ([] if autosign else [get_file_with_ext(data.udir, 'akeys')]),
+			env = cleanup_env(self.cfg))
 		t.expect('2 wallets dumped')
-		t.read()
+		res = t.read()
+		if op == 'dump_json':
+			import json
+			data = json.loads(re.sub('Stopping.*', '', strip_ansi_escapes(res)).strip())
 		self.remove_device_online()
 		return t
 
@@ -222,6 +230,8 @@ class CmdTestXMRAutosign(CmdTestXMRWallet, CmdTestAutosignThreaded):
 	async def fund_alice1(self):
 		return await self.fund_alice(wallet=1)
 
+	fund_alice1b = fund_alice1
+
 	async def check_bal_alice1(self):
 		return await self.check_bal_alice(wallet=1)
 
@@ -253,7 +263,7 @@ class CmdTestXMRAutosign(CmdTestXMRWallet, CmdTestAutosignThreaded):
 		self.remove_device()
 		return t
 
-	def create_watchonly_wallets(self):
+	def restore_watchonly_wallets(self):
 		return self._create_wallets('restore')
 
 	def restore_wallets(self):
@@ -484,3 +494,60 @@ class CmdTestXMRAutosignNoCompat(CmdTestXMRAutosign):
 	Monero autosigning operations (non-xmrwallet compat mode)
 	"""
 	compat = False
+
+class CmdTestXMRCompat(CmdTestXMRAutosign):
+	"""
+	Monero autosigning operations (compat mode)
+	"""
+	menu_prompt = 'efresh balances:\b'
+
+	cmd_group = (
+		('autosign_setup',           'autosign setup with Alice’s seed'),
+		('autosign_xmr_setup',       'autosign setup (creation of Monero signing wallets)'),
+		('create_watchonly_wallets', 'creating Alice’s watch-only wallets'),
+		('gen_kafile_miner',         'generating key-address file for Miner'),
+		('create_wallet_miner',      'creating Monero wallet for Miner'),
+		('mine_initial_coins',       'mining initial coins'),
+		('fund_alice1',              'sending funds to Alice (wallet #1)'),
+		('mine_blocks',              'mining some blocks'),
+		('alice_listaddresses',      'performing operations on Alice’s tracking wallets (listaddresses)'),
+		('fund_alice1b',             'sending funds to Alice (wallet #1)'),
+		('mine_blocks',              'mining some blocks'),
+		('alice_twview',             'performing operations on Alice’s tracking wallets (twview)'),
+	)
+
+	def __init__(self, cfg, trunner, cfgs, spawn):
+		super().__init__(cfg, trunner, cfgs, spawn)
+		if trunner is None:
+			return
+		self.alice_opts = [
+			'--alice',
+			'--coin=xmr',
+			'--monero-wallet-rpc-password=passwOrd',
+			f'--monero-daemon=localhost:{self.users["alice"].md.rpc_port}']
+
+	def create_watchonly_wallets(self):
+		return self._create_wallets()
+
+	async def mine_blocks(self):
+		self.spawn(msg_only=True)
+		return await self.mine(10)
+
+	def alice_listaddresses(self):
+		return self._alice_twops('listaddresses', 2, 'y', r'Primary account.*1\.234567891234')
+
+	def alice_twview(self):
+		return self._alice_twops('twview', 1, 'n', r'New Label.*2\.469135782468')
+
+	def _alice_twops(self, op, addr_num, add_timestr_resp, expect_str):
+		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))
+		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')
+		self.remove_device_online()
+		return t

+ 19 - 5
test/cmdtest_d/xmrwallet.py

@@ -59,7 +59,6 @@ class CmdTestXMRWallet(CmdTestBase):
 	"""
 
 	networks = ('xmr',)
-	passthru_opts = ()
 	tmpdir_nums = [29]
 	dfl_random_txs = 3
 	color = True
@@ -377,15 +376,28 @@ class CmdTestXMRWallet(CmdTestBase):
 			random_txs = self.dfl_random_txs)
 
 	def set_label_miner(self):
-		return self.set_label_user('miner', '1:0:0,"Miner’s new primary account label [1:0:0]"', 'updated')
+		return self.set_label_user(
+			'miner',
+			'1:0:0,"Miner’s new primary account label [1:0:0]"',
+			'y',
+			'updated')
 
 	def remove_label_alice(self):
-		return self.set_label_user('alice', '4:2:2,""', 'removed', add_opts=['--full-address'])
+		return self.set_label_user(
+			'alice',
+			'4:2:2,""',
+			None,
+			'removed',
+			add_opts = ['--full-address'])
 
 	def set_label_alice(self):
-		return self.set_label_user('alice', '4:2:2,"Alice’s new subaddress label [4:2:2]"', 'set')
+		return self.set_label_user(
+			'alice',
+			'4:2:2,"Alice’s new subaddress label [4:2:2]"',
+			'n',
+			'set')
 
-	def set_label_user(self, user, label_spec, expect, add_opts=[]):
+	def set_label_user(self, user, label_spec, add_timestr_resp, expect, add_opts=[]):
 		data = self.users[user]
 		cmd_opts = [f'--wallet-dir={data.udir}', f'--daemon=localhost:{data.md.rpc_port}']
 		t = self.spawn(
@@ -394,6 +406,8 @@ class CmdTestXMRWallet(CmdTestBase):
 			+ add_opts
 			+ cmd_opts
 			+ ['label', data.kafile, label_spec])
+		if add_timestr_resp:
+			t.expect('(y/N): ', add_timestr_resp)
 		t.expect('(y/N): ', 'y')
 		t.expect(f'Label successfully {expect}')
 		return t