Browse Source

Monero offline transaction autosigning

Cold signing of Monero transactions with maximum convenience.

For general information about Monero offline signing, see:

    https://monerodocs.org/cold-storage/offline-transaction-signing/

The MMGen implementation automates the workflow described in that document,
with some minor changes.

A unique security feature is that signing wallets are created afresh in
volatile memory for each transacting session and thus disappear when the
signing machine is powered down.

Documentation:

    $ mmgen-xmrwallet --help # scroll to OFFLINE AUTOSIGNING

Testing:

    $ test/test.py --coin=xmr -e xmr_autosign
The MMGen Project 1 year ago
parent
commit
de77f9c27d

+ 122 - 5
mmgen/autosign.py

@@ -9,7 +9,7 @@
 #   https://gitlab.com/mmgen/mmgen
 
 """
-autosign: Auto-sign MMGen transactions and message files
+autosign: Auto-sign MMGen transactions, message files and XMR wallet output files
 """
 
 import sys,os,asyncio
@@ -17,7 +17,7 @@ from subprocess import run,PIPE,DEVNULL
 from collections import namedtuple
 
 from .cfg import Config
-from .util import msg,msg_r,ymsg,rmsg,gmsg,bmsg,die,suf,fmt,fmt_list
+from .util import msg,msg_r,ymsg,rmsg,gmsg,bmsg,die,suf,fmt,fmt_list,async_run
 from .color import yellow,red,orange
 from .wallet import Wallet,get_wallet_cls
 from .filename import find_file_in_dir
@@ -39,6 +39,10 @@ class Signable:
 		def unsigned(self):
 			return self._unprocessed( '_unsigned', self.rawext, self.sigext )
 
+		@property
+		def unsubmitted(self):
+			return self._unprocessed( '_unsubmitted', self.sigext, self.subext )
+
 		def _unprocessed(self,attrname,rawext,sigext):
 			if not hasattr(self,attrname):
 				dirlist = tuple(os.scandir(self.dir))
@@ -116,6 +120,58 @@ class Signable:
 			for f in bad_files:
 				yield red(f.path)
 
+	class xmr_transaction(transaction):
+		dir_name = 'xmr_tx_dir'
+		desc = 'Monero transaction'
+		subext = 'subtx'
+
+		def __init__(self,*args,**kwargs):
+			super().__init__(*args,**kwargs)
+			if len(self.unsigned) > 1:
+				die('AutosignTXError', 'Only one unsigned XMR transaction allowed at a time!')
+
+		async def sign(self,f):
+			from .xmrwallet import MoneroMMGenTX,MoneroWalletOps,xmrwallet_uargs
+			tx1 = MoneroMMGenTX.Completed( self.parent.xmrwallet_cfg, f.path )
+			m = MoneroWalletOps.sign(
+				self.parent.xmrwallet_cfg,
+				xmrwallet_uargs(
+					infile  = self.parent.wallet_files[0], # MMGen wallet file
+					wallets = str(tx1.src_wallet_idx),
+					spec    = None ),
+			)
+			tx2 = await m.main(f.path) # TODO: stop wallet daemon?
+			tx2.write(ask_write=False)
+			return tx2
+
+		def print_summary(self,txs):
+			bmsg('\nAutosign summary:\n')
+			msg_r('\n'.join(tx.get_info() for tx in txs))
+
+	class xmr_wallet_outputs_file(transaction):
+		desc = 'Monero wallet outputs file'
+		rawext = 'raw'
+		sigext = 'sig'
+		dir_name = 'xmr_outputs_dir'
+
+		async def sign(self,f):
+			from .xmrwallet import MoneroWalletOps,xmrwallet_uargs
+			wallet_idx = MoneroWalletOps.wallet.get_idx_from_fn(f.name)
+			m = MoneroWalletOps.export_key_images(
+				self.parent.xmrwallet_cfg,
+				xmrwallet_uargs(
+					infile  = self.parent.wallet_files[0], # MMGen wallet file
+					wallets = str(wallet_idx),
+					spec    = None ),
+			)
+			obj = await m.main( f, wallet_idx )
+			obj.write()
+			return obj
+
+		def print_summary(self,txs):
+			bmsg('\nAutosign summary:')
+			msg('  ' + '\n  '.join(tx.get_info() for tx in txs) + '\n')
+
 	class message(base):
 		desc = 'message file'
 		rawext = 'rawmsg.json'
@@ -200,10 +256,18 @@ class Autosign:
 
 		self.coins = cfg.coins.upper().split(',') if cfg.coins else []
 
+		if cfg.xmrwallets and not 'XMR' in self.coins:
+			self.coins.append('XMR')
+
 		if not self.coins:
 			ymsg('Warning: no coins specified, defaulting to BTC')
 			self.coins = ['BTC']
 
+		if 'XMR' in self.coins:
+			self.xmr_dir = os.path.join( self.mountpoint, 'xmr' )
+			self.xmr_tx_dir = os.path.join( self.mountpoint, 'xmr', 'tx' )
+			self.xmr_outputs_dir = os.path.join( self.mountpoint, 'xmr', 'outputs' )
+
 	async def check_daemons_running(self):
 		from .protocol import init_proto
 		for coin in self.coins:
@@ -239,7 +303,7 @@ class Autosign:
 
 		return self._wallet_files
 
-	def do_mount(self):
+	def do_mount(self,no_xmr_chk=False):
 
 		from stat import S_ISDIR,S_IWUSR,S_IRUSR
 
@@ -272,6 +336,9 @@ class Autosign:
 		if self.have_msg_dir:
 			check_dir(self.msg_dir)
 
+		if 'XMR' in self.coins and not no_xmr_chk:
+			check_dir(self.xmr_tx_dir)
+
 	def do_umount(self):
 		if os.path.ismount(self.mountpoint):
 			run( ['sync'], check=True )
@@ -330,7 +397,10 @@ class Autosign:
 				self.led.set('busy')
 			ret1 = await self.sign_all('transaction')
 			ret2 = await self.sign_all('message') if self.have_msg_dir else True
-			ret = ret1 and ret2
+			# import XMR wallet outputs BEFORE signing transactions:
+			ret3 = await self.sign_all('xmr_wallet_outputs_file') if 'XMR' in self.coins else True
+			ret4 = await self.sign_all('xmr_transaction') if 'XMR' in self.coins else True
+			ret = ret1 and ret2 and ret3 and ret4
 			self.do_umount()
 			self.led.set(('standby','off','error')[(not ret)*2 or bool(self.cfg.stealth_led)])
 			return ret
@@ -365,7 +435,7 @@ class Autosign:
 		self.create_wallet_dir()
 		if not self.get_insert_status():
 			die(1,'Removable device not present!')
-		self.do_mount()
+		self.do_mount(no_xmr_chk=True)
 		self.wipe_existing_key()
 		self.create_key()
 		if not no_unmount:
@@ -398,6 +468,53 @@ class Autosign:
 		ss_out = Wallet( self.cfg, ss=ss_in )
 		ss_out.write_to_file( desc='autosign wallet', outdir=self.wallet_dir )
 
+	@property
+	def xmrwallet_cfg(self):
+		if not hasattr(self,'_xmrwallet_cfg'):
+			from .cfg import Config
+			self._xmrwallet_cfg = Config({
+				'coin': 'xmr',
+				'wallet_rpc_user': 'autosigner',
+				'wallet_rpc_password': 'my very secret password',
+				'passwd_file': self.cfg.passwd_file,
+				'wallet_dir': self.wallet_dir,
+				'autosign': True,
+				'autosign_mountpoint': self.mountpoint,
+				'outdir': self.xmr_dir, # required by vkal.write()
+			})
+		return self._xmrwallet_cfg
+
+	def xmr_setup(self):
+
+		import shutil
+		try: shutil.rmtree(self.xmr_outputs_dir)
+		except: pass
+
+		os.makedirs(self.xmr_outputs_dir)
+
+		os.makedirs(self.xmr_tx_dir,exist_ok=True)
+
+		from .addrfile import ViewKeyAddrFile
+		from .fileutil import shred_file
+		for f in os.scandir(self.xmr_dir):
+			if f.name.endswith(ViewKeyAddrFile.ext):
+				msg(f'Shredding old viewkey-address file {f.name!r}')
+				shred_file(f.path)
+
+		if len(self.wallet_files) > 1:
+			ymsg(f'Warning: more that one wallet file, using the first ({self.wallet_files[0]}) for xmrwallet generation')
+
+		from .xmrwallet import MoneroWalletOps,xmrwallet_uargs
+		m = MoneroWalletOps.create_offline(
+			self.xmrwallet_cfg,
+			xmrwallet_uargs(
+				infile  = self.wallet_files[0], # MMGen wallet file
+				wallets = self.cfg.xmrwallets,  # XMR wallet idxs
+				spec    = None ),
+		)
+		async_run(m.main())
+		async_run(m.stop_wallet_daemon())
+
 	def get_insert_status(self):
 		if self.cfg.no_insert_check:
 			return True

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-13.3.dev47
+13.3.dev48

+ 1 - 0
mmgen/exception.py

@@ -66,6 +66,7 @@ class ObjectInitError(Exception):         mmcode = 2
 class ClassFlagsError(Exception):         mmcode = 2
 class ExtensionModuleError(Exception):    mmcode = 2
 class MoneroMMGenTXFileParseError(Exception): mmcode = 2
+class AutosignTXError(Exception):         mmcode = 2
 
 # 3: yellow hl, 'MMGen Error' + exception + message
 class RPCFailure(Exception):              mmcode = 3

+ 264 - 6
mmgen/help/xmrwallet.py

@@ -46,8 +46,17 @@ sweep     - sweep funds in specified wallet:account to new address in same
             account or new account in another wallet
 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
 txview    - view a transaction file or files created using ‘sweep’ or
             ‘transfer’ with the --no-relay option
+dump      - produce JSON dumps of wallet metadata (accounts, addresses and
+            labels) for a list or range of wallets
+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 later import
+                    into their corresponding offline wallets
+import-key-images - import key images signed by offline wallets into their
+                    corresponding watch-only wallets
 
 
                            ‘LABEL’ OPERATION NOTES
@@ -103,24 +112,42 @@ Note that multiple sweep operations may be required to sweep all the funds
 in an account.
 
 
-                           ‘RELAY’ OPERATION NOTES
+                    ‘SUBMIT’ AND ‘RELAY’ OPERATION NOTES
 
 By default, transactions are relayed to a monerod running on localhost at the
 default RPC port.  To relay transactions to a remote or non-default monerod
 via optional SOCKS proxy, use the --tx-relay-daemon option described above.
 
+When ‘submit’ is used with --autosign, the transaction filename must be
+omitted.
+
+
+                    ‘DUMP’ AND ‘RESTORE’ OPERATION NOTES
+
+These commands produce and read JSON wallet dump files with the same
+filenames as their source wallets, plus a .dump extension.
+
+It’s highly advisable to make regular dumps of your Monero wallets and back
+up the dump files, which can be used to easily regenerate the wallets using
+the ‘restore’ operation, should the need arise.  For watch-only autosigning
+wallets, creating the dumps is as easy as executing ‘mmgen-xmrwallet
+--autosign dump’ from your wallet directory.  The dump files are formatted
+JSON and thus suitable for efficient incremental backup using git.
+
 
                               SECURITY WARNING
 
-To avoid exposing your private keys on a network-connected machine, you’re
-strongly advised to create all transactions offline using the --no-relay
-option.  For this, a monerod with a fully synced blockchain must be running
-on the offline machine.  The resulting transaction files are then sent using
-the 'relay' operation.
+If you have an existing MMGen Monero hot wallet setup, you’re strongly
+advised to migrate to offline autosigning to avoid further exposing your
+private keys on your network-connected machine.  See OFFLINE AUTOSIGNING
+and ‘Replacing Existing Hot Wallets with Watch-Only Wallets’ below.
 
 
                                   EXAMPLES
 
+Note that the transacting examples in this section apply for a hot wallet
+setup, which is now deprecated.  See OFFLINE AUTOSIGNING below.
+
 Generate an XMR key-address file with 5 addresses from your default wallet:
 $ mmgen-keygen --coin=xmr 1-5
 
@@ -158,4 +185,235 @@ $ mmgen-xmrwallet new *.akeys.mmenc 2:1,"from ABC exchange"
 View all the XMR transaction files in the current directory, sending output
 to pager:
 $ mmgen-xmrwallet --pager txview *XMR*.sigtx
+
+
+                             OFFLINE AUTOSIGNING
+
+                                  Tutorial
+
+Master the basic concepts of the MMGen wallet system and the processes of
+wallet creation, conversion and backup described in the Getting Started
+guide.  Optionally create a default MMGen wallet on your offline machine
+using ‘mmgen-walletgen’.  If you choose not to do this, you’ll be prompted
+for a seed phrase at the start of each signing session.
+
+Familiarize yourself with the autosigning setup process as described in
+‘mmgen-autosign --help’.  Prepare your removable device and set up the
+mountpoints on your offline and online machines according to the instructions
+therein.  Install ‘monero-wallet-rpc’ on your offline machine and the Monero
+CLI wallet and daemon binaries on your online machine.
+
+On the offline machine, insert the removable device and execute:
+
+$ mmgen-autosign --xmrwallets=1-2,7 setup
+
+This will create 3 Monero signing wallets with indexes 1, 2 and 7 and primary
+addresses matching your seed’s Monero addresses with the same indexes.  (Note
+that these particular indexes are arbitrary, for purposes of illustration
+only.  Feel free to choose your own list and/or range – or perhaps just the
+number ‘1’ if one wallet is all you require).
+
+These signing wallets are written to volatile memory and exist only for the
+duration of the signing session, just like the temporary MMGen signing wallet
+they’re generated from (see ‘mmgen-autosign --help’).
+
+A viewkey-address file for the 3 addresses will also be written to the
+removable device.  The data in this file will be used to create and access
+watch-only wallets on your online machine that match the signing wallets
+you’ve just created.
+
+When the setup operation completes, extract the removable device and restart
+the autosign script in wait mode:
+
+$ mmgen-autosign --coins=xmr --stealth-led wait
+
+Your only further physical interaction with the offline signing machine now
+(assuming everything goes as planned) will be inserting and extracting the
+removable device on it.  And this is the whole point of autosigning: to make
+cold signing as convenient as possible, almost like transacting with a hot
+wallet.
+
+If your signing machine is an SoC with MMGen LED support (see ‘mmgen-autosign
+--help’), a quickly flashing LED will indicate that signing is in progress, a
+slowly flashing LED an error condition, and no LED that the program is idle
+and waiting for device insertion.
+
+On your online machine, start monerod, wait until it’s fully synced with the
+network, insert the removable device and execute:
+
+$ mmgen-xmrwallet --autosign --restore-height=current create
+
+This will create 3 watch-only wallets matching your 3 offline signing wallets
+and write them to the current directory (an alternate wallet directory may be
+specified with the --wallet-dir option).
+
+Note that --restore-height=current is required to prevent a time-consuming
+full sync of the wallets from the Genesis block, a meaningless waste of time
+in this case since the wallets contain no funds.
+
+Also make note of the --autosign option, a requirement for ALL autosigning
+operations with ‘mmgen-xmrwallet’.
+
+Now list your newly created wallets:
+
+$ mmgen-xmrwallet --autosign list
+
+Note that you can also use the ‘sync’ operation here, which produces more
+abbreviated output than ‘list’.
+
+Send some XMR (preferably a tiny amount) to the primary address of wallet #7.
+Once the transaction has confirmed, invoke ‘sync’ or ‘list’ again to verify
+the funds have arrived.
+
+Since offline wallet #7 has no knowledge of the funds received by its online
+counterpart, we need to update its state.  Export the outputs of watch-only
+wallet #7 as follows:
+
+$ mmgen-xmrwallet --autosign export-outputs 7
+
+The outputs are now saved to the removable device and will be imported into
+offline wallet #7 when you sign your first transaction.
+
+Now you’re ready to begin transacting.  Let’s start by sweeping your funds in
+wallet #7’s primary address (account 0) to a new address in the same account:
+
+$ mmgen-xmrwallet --autosign sweep 7:0
+
+This operation creates an unsigned sweep transaction and saves it to the
+removable device.
+
+Now extract the removable device and insert it on the offline machine.  Wait
+for the quick LED flashing to stop (or the blue ‘safe to extract’ message, in
+the absence of LED support), signalling that signing is complete.
+
+Note that the offline wallet has performed two operations in one go here:
+an import of wallet outputs from the previous step and the signing of your
+just-created sweep transaction.
+
+Extract the removable device, insert it on your online machine and submit the
+signed sweep transaction to the watch-only wallet, which will broadcast it to
+the network:
+
+$ mmgen-xmrwallet --autosign submit
+
+Note that you may also relay the transaction to a remote daemon, optionally
+via a Tor proxy, using the --tx-relay-daemon option documented above.
+
+Once your transaction has confirmed, invoke ‘list’ or ‘sync’ to view your
+wallets’ balances.
+
+Congratulations, you’ve performed your first autosigned Monero transaction!
+
+For other examples, consult the EXAMPLES section above, noting the following
+differences that apply to autosigning:
+
+  1) The --autosign option must always be included.
+  2) The key-address file argument must always be omitted.
+  3) The ‘relay’ operation is replaced by ‘submit’, with TX filename omitted.
+  4) Always remember to sign your transactions after a ‘sweep’ or ‘transfer’
+     operation.
+  5) Always remember to export a wallet’s outputs when it has received funds
+     from an outside source.
+
+
+                              Exporting Outputs
+
+Exporting outputs from a wallet is generally required only in two cases:
+
+  a) after the wallet has received funds from an outside source or another
+     wallet; and
+  c) at the start of each signing session (after ‘mmgen-autosign setup’).
+
+However, sometimes things go wrong and an offline wallet may be unable to
+sign a transaction due to missing outputs.  In this case, you may export all
+outputs from its corresponding watch-only wallet with the following command:
+
+$ mmgen-xmrwallet --autosign --export-all export-outputs <wallet index>
+
+Then reinsert the removable device on the offline machine to redo the
+signing operation, which should now succeed.
+
+At the start of each new signing session, you must export ALL outputs from
+ALL wallets you intend to transact with:
+
+$ mmgen-xmrwallet --autosign --export-all export-outputs [wallet indexes]
+
+This is necessary because the signing wallets have just been created and
+therefore know nothing about the state of their watch-only counterparts.
+
+
+           Replacing Existing Hot Wallets with Watch-Only Wallets
+
+If you have an existing MMGen Monero hot wallet setup, you can migrate to
+offline transaction signing by ‘cloning’ your existing hot wallets as
+watch-only ones via the ‘dump’ and ‘restore’ operations described below.
+
+For additional security, it’s also wise to create new watch-only wallets that
+have never had keys exposed on an online machine and gradually transfer all
+funds from your ‘cloned’ wallets to them.  The creation of new wallets is
+explained in the Tutorial above.
+
+Start the cloning process by making dump files of your hot wallets’ metadata
+(accounts, subaddresses and labels).  ‘cd’ to the wallet directory (or use
+--wallet-dir) and execute:
+
+$ mmgen-xmrwallet dump /path/to/key-address-file.akeys{.mmenc}
+
+If you’ve been transacting with the wallets, you know where their key-address
+file is along with its encryption password, if any.  Supply an additional
+index range and/or list at the end of the command line if the key-address
+file contains more wallets than exist on disk or there are wallets you wish
+to ignore.
+
+Do a directory listing to verify that the dump files are present alongside
+their source wallet files ending with ‘MoneroWallet’.  Then execute:
+
+$ mmgen-xmrwallet --watch-only restore /path/to/key-address-file.akeys{.mmenc}
+
+This will create watch-only wallets that “mirror” the old hot wallets and
+populate them with the metadata saved in the dump files.
+
+Note that watch-only wallet filenames end with ‘MoneroWatchOnlyWallet’.  Your
+old hot wallets will be ignored from here on.  Eventually, you’ll want to
+destroy them.
+
+Your new wallets must now be synced with the blockchain.  Begin by starting
+monerod and synchronizing with the network.
+
+Mount ‘/mnt/mmgen_autosign’ and locate the file in the ‘xmr’ directory with
+the .vkeys extension, which contains the passwords you’ll need to log into
+the wallets.  This is a plain text file viewable with ‘cat’, ‘less’ or your
+favorite text editor.
+
+Then log into each watch-only wallet in turn as follows:
+
+$ monero-wallet-cli --wallet <wallet filename>
+
+Upon login, each wallet will begin syncing, a process which can take more
+than an hour depending on your hardware.  Note, however, that the process
+is interruptible: you may exit ‘monero-wallet-cli’ at any point, log back
+in again and resume where you left off.
+
+Once your watch-only wallets are synced, you need to export their outputs:
+
+$ mmgen-xmrwallet --autosign export-outputs
+
+Now insert the removable device on the offline machine and wait until the LED
+stops flashing (or ‘safe to extract’).  The wallet outputs are now imported
+into the signing wallets and corresponding signed key images have been
+written to the removable device.
+
+Insert the removable device on your online machine and import the key images
+into your watch-only wallets:
+
+$ mmgen-xmrwallet --autosign import-key-images
+
+Congratulations, your watch-only wallets are now complete and you may begin
+transacting!  First perform a ‘sync’ or ‘list’ to ensure that your balances
+are correct.  Then you might try sweeping some funds as described in the
+Tutorial above.
+
+Once you’ve gained proficiency with the autosigning process and feel ready
+to delete your old hot wallets, make sure to do so securely using ‘shred’,
+‘wipe’ or some other secure deletion utility.
 """.strip()

+ 11 - 5
mmgen/main_autosign.py

@@ -17,20 +17,20 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 """
-mmgen-autosign: Auto-sign MMGen transactions and message files
+autosign: Auto-sign MMGen transactions, message files and XMR wallet output files
 """
 
 import sys
 
 from .cfg import Config
-from .util import die,fmt_list,exit_if_mswin,async_run
+from .util import msg,die,fmt_list,exit_if_mswin,async_run
 
 exit_if_mswin('autosigning')
 
 opts_data = {
 	'sets': [('stealth_led', True, 'led', True)],
 	'text': {
-		'desc': 'Auto-sign MMGen transactions and message files',
+		'desc': 'Auto-sign MMGen transactions, message files and XMR wallet output files',
 		'usage':'[opts] [operation]',
 		'options': """
 -h, --help            Print this help message
@@ -52,6 +52,7 @@ opts_data = {
 -v, --verbose         Produce more verbose output
 -w, --wallet-dir=D    Specify an alternate wallet dir
                       (default: {asi.dfl_wallet_dir!r})
+-x, --xmrwallets=L    Range or list of wallets to be used for XMR autosigning
 """,
 	'notes': """
 
@@ -66,8 +67,9 @@ wait    - start in loop mode: wait-mount-sign-unmount-wait
                                USAGE NOTES
 
 If no operation is specified, this program mounts a removable device
-(typically a USB flash drive) containing unsigned MMGen transactions and/or
-message files, signs them, unmounts the removable device and exits.
+(typically a USB flash drive) containing unsigned MMGen transactions, message
+files, and/or XMR wallet output files, signs them, unmounts the removable
+device and exits.
 
 If invoked with ‘wait’, the program waits in a loop, mounting the removable
 device, performing signing operations and unmounting the device every time it
@@ -170,6 +172,10 @@ if len(cmd_args) == 1:
 		sys.exit(0)
 	elif cmd == 'setup':
 		asi.setup()
+		from .ui import keypress_confirm
+		if cfg.xmrwallets and keypress_confirm( cfg, '\nContinue with Monero setup?', default_yes=True ):
+			msg('')
+			asi.xmr_setup()
 		sys.exit(0)
 	elif cmd != 'wait':
 		die(1,f'{cmd!r}: unrecognized command')

+ 51 - 12
mmgen/main_xmrwallet.py

@@ -30,25 +30,43 @@ from .xmrwallet import (
 	MoneroWalletOps,
 	xmrwallet_uarg_info,
 	xmrwallet_uargs,
+	get_autosign_obj,
 )
 
 opts_data = {
+	'sets': [
+		('autosign',True,'watch_only',True),
+		('autosign_mountpoint',bool,'autosign',True),
+		('autosign_mountpoint',bool,'watch_only',True),
+	],
 	'text': {
 		'desc': """Perform various Monero wallet and transacting operations for
                    addresses in an MMGen XMR key-address file""",
 		'usage2': [
-			'[opts] create | sync | list <xmr_keyaddrfile> [wallets]',
-			'[opts] label    <xmr_keyaddrfile> LABEL_SPEC',
-			'[opts] new      <xmr_keyaddrfile> NEW_ADDRESS_SPEC',
-			'[opts] transfer <xmr_keyaddrfile> TRANSFER_SPEC',
-			'[opts] sweep    <xmr_keyaddrfile> SWEEP_SPEC',
+			'[opts] create | sync | list | dump | restore [xmr_keyaddrfile] [wallets]',
+			'[opts] label    [xmr_keyaddrfile] LABEL_SPEC',
+			'[opts] new      [xmr_keyaddrfile] NEW_ADDRESS_SPEC',
+			'[opts] transfer [xmr_keyaddrfile] TRANSFER_SPEC',
+			'[opts] sweep    [xmr_keyaddrfile] SWEEP_SPEC',
+			'[opts] submit   [TX_file]',
 			'[opts] relay    <TX_file>',
 			'[opts] txview   <TX_file> ...',
+			'[opts] export-outputs    [wallets]',
+			'[opts] import-key-images [wallets]',
 		],
 		'options': """
 -h, --help                       Print this help message
 --, --longhelp                   Print help message for long options (common
                                  options)
+-A, --export-all                 Export all outputs when performing the
+                                 ‘export-outputs’ operation
+-a, --autosign                   Use appropriate outdir and other params for
+                                 autosigning operations (implies --watch-only).
+                                 When this option is in effect, the viewkey-
+                                 address file is located automatically, so the
+                                 xmr_keyaddrfile argument must be omitted.
+-m, --autosign-mountpoint=P      Specify the autosign mountpoint (defaults to
+                                 ‘/mnt/mmgen_autosign’, implies --autosign)
 -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
@@ -64,6 +82,7 @@ opts_data = {
 -R, --no-relay                   Save transaction to file instead of relaying
 -s, --no-start-wallet-daemon     Don’t start the wallet daemon at startup
 -S, --no-stop-wallet-daemon      Don’t stop the wallet daemon at exit
+-W, --watch-only                 Create or operate on watch-only wallets
 -w, --wallet-dir=D               Output or operate on wallets in directory 'D'
                                  instead of the working directory
 -H, --wallet-rpc-host=host       Wallet RPC hostname (currently: {cfg.monero_wallet_rpc_host!r})
@@ -92,6 +111,12 @@ cfg = Config(opts_data=opts_data)
 
 cmd_args = cfg._args
 
+if cmd_args and cfg.autosign and (
+		cmd_args[0] in (MoneroWalletOps.kafile_arg_ops + ('export-outputs','import-key-images'))
+		or len(cmd_args) == 1 and cmd_args[0] == 'submit'
+	):
+	cmd_args.insert(1,None)
+
 if len(cmd_args) < 2:
 	cfg._opts.usage()
 
@@ -99,25 +124,36 @@ op     = cmd_args.pop(0)
 infile = cmd_args.pop(0)
 wallets = spec = None
 
-if op not in MoneroWalletOps.ops:
+if op.replace('-','_') not in MoneroWalletOps.ops:
 	die(1,f'{op!r}: unrecognized operation')
 
-if op == 'relay':
+if op in ('relay','submit'):
 	if len(cmd_args) != 0:
 		cfg._opts.usage()
 elif op == 'txview':
 	infile = [infile] + cmd_args
-elif op in ('create','sync','list'):
-	if len(cmd_args) not in (0,1):
+elif op in ('create','sync','list','dump','restore'): # kafile_arg_ops
+	if len(cmd_args) > 1:
 		cfg._opts.usage()
-	if cmd_args:
-		wallets = cmd_args[0]
+	wallets = cmd_args.pop(0) if cmd_args else None
 elif op in ('new','transfer','sweep','label'):
 	if len(cmd_args) != 1:
 		cfg._opts.usage()
 	spec = cmd_args[0]
+elif op in ('export-outputs','import-key-images'):
+	if not cfg.autosign: # --autosign only for now - TODO
+		die(f'--autosign must be used with command {op!r}')
+	if len(cmd_args) > 1:
+		cfg._opts.usage()
+	wallets = cmd_args.pop(0) if cmd_args else None
 
-m = getattr(MoneroWalletOps,op)(
+if cfg.autosign and not cfg.test_suite:
+	asi = get_autosign_obj(cfg)
+	if not asi.get_insert_status():
+		die(1,'Removable device not present!')
+	asi.do_mount()
+
+m = getattr(MoneroWalletOps,op.replace('-','_'))(
 	cfg,
 	xmrwallet_uargs(infile, wallets, spec))
 
@@ -131,3 +167,6 @@ try:
 	async_run(m.stop_wallet_daemon())
 except Exception as e:
 	ymsg(f'Unable to stop wallet daemon: {type(e).__name__}: {e}')
+
+if cfg.autosign and not cfg.test_suite:
+	asi.do_umount()

+ 4 - 2
mmgen/proto/xmr/daemon.py

@@ -101,7 +101,8 @@ class MoneroWalletDaemon(RPCDaemon):
 			daemon_addr = None,
 			proxy       = None,
 			port_shift  = None,
-			datadir     = None ):
+			datadir     = None,
+			trust_daemon = False ):
 
 		self.proto = proto
 		self.test_suite = test_suite
@@ -143,7 +144,8 @@ class MoneroWalletDaemon(RPCDaemon):
 				"the MMGen config file." )
 
 		self.daemon_args = list_gen(
-			['--untrusted-daemon'],
+			['--trusted-daemon', trust_daemon],
+			['--untrusted-daemon', not trust_daemon],
 			[f'--rpc-bind-port={self.rpc_port}'],
 			['--wallet-dir='+self.wallet_dir],
 			['--log-file='+self.logfile],

+ 1 - 0
mmgen/proto/xmr/params.py

@@ -39,6 +39,7 @@ class mainnet(CoinProtocol.DummyWIF,CoinProtocol.Base):
 	mmcaps         = ('key','addr')
 	ignore_daemon_version = False
 	coin_amt       = 'XMRAmt'
+	sign_mode      = 'standalone'
 
 	def get_addr_len(self,addr_fmt):
 		return (64,72)[addr_fmt == 'monero_integrated']

+ 647 - 42
mmgen/xmrwallet.py

@@ -23,13 +23,15 @@ xmrwallet.py - MoneroWalletOps class
 import os,re,time,json
 from collections import namedtuple
 from .objmethods import MMGenObject,Hilite,InitErrors
-from .obj import CoinTxID
+from .obj import CoinTxID,Int
 from .color import red,yellow,green,blue,cyan,pink,orange
 from .util import (
 	msg,
 	msg_r,
 	gmsg,
+	bmsg,
 	ymsg,
+	rmsg,
 	gmsg_r,
 	pp_msg,
 	die,
@@ -45,7 +47,7 @@ from .seed import SeedID
 from .protocol import init_proto
 from .proto.btc.common import b58a
 from .addr import CoinAddr,AddrIdx
-from .addrlist import KeyAddrList,AddrIdxList
+from .addrlist import KeyAddrList,ViewKeyAddrList,AddrIdxList
 from .rpc import json_encoder
 from .proto.xmr.rpc import MoneroRPCClient,MoneroWalletRPCClient
 from .proto.xmr.daemon import MoneroWalletDaemon
@@ -70,6 +72,16 @@ xmrwallet_uarg_info = (
 		r'(?:[^:]+):(?:\d+)'
 	)
 
+def get_autosign_obj(cfg):
+	from .autosign import Autosign,AutosignConfig
+	return Autosign(
+		AutosignConfig({
+			'mountpoint': cfg.autosign_mountpoint,
+			'test_suite': cfg.test_suite,
+			'coins': 'XMR',
+		})
+	)
+
 class XMRWalletAddrSpec(str,Hilite,InitErrors,MMGenObject):
 	color = 'cyan'
 	width = 0
@@ -160,6 +172,7 @@ class MoneroMMGenTX:
 
 		data_label = 'MoneroMMGenTX'
 		base_chksum_fields = ('op','create_time','network','seed_id','source','dest','amount')
+		full_chksum_fields = ('op','create_time','network','seed_id','source','dest','amount','fee','blob')
 		chksum_nchars = 6
 		xmrwallet_tx_data = namedtuple('xmrwallet_tx_data',[
 			'op',
@@ -175,12 +188,18 @@ class MoneroMMGenTX:
 			'fee',
 			'blob',
 			'metadata',
+			'unsigned_txset',
+			'signed_txset',
+			'complete',
 		])
-		full_chksum_fields = set(xmrwallet_tx_data._fields) - {'metadata'}
 
 		def __init__(self):
 			self.name = type(self).__name__
 
+		@property
+		def src_wallet_idx(self):
+			return int(self.data.source.split(':')[0])
+
 		def get_info(self,indent=''):
 			d = self.data
 			if d.dest:
@@ -208,6 +227,10 @@ class MoneroMMGenTX:
 			if pmid:
 				fs += '  Payment ID: {pmid}'
 
+			coldsign_status = (
+				pink(' [cold signed{}]'.format(', submitted' if d.complete else ''))
+				if d.signed_txset else '' )
+
 			from .util2 import format_elapsed_hr
 			return fmt(fs,strip_char='\t',indent=indent).format(
 					a = orange(self.base_chksum.upper()),
@@ -216,9 +239,9 @@ class MoneroMMGenTX:
 					d = d.txid.hl(),
 					e = make_timestr(d.create_time),
 					f = format_elapsed_hr(d.create_time),
-					g = make_timestr(d.sign_time),
-					h = format_elapsed_hr(d.sign_time),
-					i = blue(capfirst(d.op)),
+					g = make_timestr(d.sign_time) if d.sign_time else '-',
+					h = format_elapsed_hr(d.sign_time) if d.sign_time else '-',
+					i = blue(capfirst(d.op)) + coldsign_status,
 					j = d.source.wallet.hl(),
 					k = red(f'#{d.source.account}'),
 					l = to_entry if d.dest else '',
@@ -241,6 +264,9 @@ class MoneroMMGenTX:
 				e = self.ext
 			)
 
+			if self.cfg.autosign:
+				fn = os.path.join( get_autosign_obj(self.cfg).xmr_tx_dir, fn )
+
 			from .fileutil import write_data_to_file
 			write_data_to_file(
 				cfg                   = self.cfg,
@@ -249,7 +275,8 @@ class MoneroMMGenTX:
 				desc                  = self.desc,
 				ask_write             = ask_write,
 				ask_write_default_yes = not ask_write,
-				ask_overwrite         = ask_overwrite )
+				ask_overwrite         = ask_overwrite,
+				ignore_opt_outdir     = self.cfg.autosign )
 
 	class New(Base):
 
@@ -259,7 +286,13 @@ class MoneroMMGenTX:
 
 			assert not args, 'Non-keyword args not permitted'
 
-			d = namedtuple('kwargs_tuple',kwargs)(**kwargs)
+			if '_in_tx' in kwargs:
+				in_data = kwargs.pop('_in_tx').data._asdict()
+				in_data.update(kwargs)
+			else:
+				in_data = kwargs
+
+			d = namedtuple('monero_tx_in_data_tuple',in_data)(**in_data)
 			self.cfg = d.cfg
 
 			proto = init_proto( self.cfg, 'xmr', network=d.network, need_amt=True )
@@ -268,8 +301,8 @@ class MoneroMMGenTX:
 
 			self.data = self.xmrwallet_tx_data(
 				op             = d.op,
-				create_time    = now,
-				sign_time      = now,
+				create_time    = getattr(d,'create_time',now),
+				sign_time      = (getattr(d,'sign_time',None) or now) if self.signed else None,
 				network        = d.network,
 				seed_id        = SeedID(sid=d.seed_id),
 				source         = XMRWalletAddrSpec(d.source),
@@ -280,15 +313,31 @@ class MoneroMMGenTX:
 				fee            = proto.coin_amt(d.fee,from_unit='atomic'),
 				blob           = d.blob,
 				metadata       = d.metadata,
+				unsigned_txset = d.unsigned_txset,
+				signed_txset   = getattr(d,'signed_txset',None),
+				complete       = True if self.name == 'NewSigned' else getattr(d,'complete',False),
 			)
 
+	class NewUnsigned(New):
+		desc = 'unsigned transaction'
+		ext = 'rawtx'
+		signed = False
+
 	class NewSigned(New):
 		desc = 'signed transaction'
 		ext = 'sigtx'
 		signed = True
 
+	class NewColdSigned(NewSigned):
+		pass
+
+	class NewSubmitted(NewColdSigned):
+		desc = 'submitted transaction'
+		ext = 'subtx'
+
 	class Completed(Base):
 		desc = 'transaction'
+		forbidden_fields = ()
 
 		def __init__(self,cfg,fn):
 
@@ -302,11 +351,23 @@ class MoneroMMGenTX:
 			except Exception as e:
 				die( 'MoneroMMGenTXFileParseError', f'{type(e).__name__}: {e}\nCould not load transaction file' )
 
+			if not 'unsigned_txset' in d_wrap['data']: # backwards compat: use old checksum fields
+				self.full_chksum_fields = (
+					set(self.xmrwallet_tx_data._fields) -
+					{'metadata','unsigned_txset','signed_txset','complete'} )
+
+			for key in self.xmrwallet_tx_data._fields: # backwards compat: fill in missing fields
+				if not key in d_wrap['data']:
+					d_wrap['data'][key] = None
+
 			d = self.xmrwallet_tx_data(**d_wrap['data'])
 
 			if self.name != 'Completed':
 				assert fn.endswith('.'+self.ext), 'TX filename {fn!r} 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,'{} has {}sign time!'.format(self.desc,'no 'if self.signed else'')
+				for f in self.forbidden_fields:
+					assert not getattr(d,f), f'{self.name} TX mismatch: contains forbidden field {f!r}'
 
 			proto = init_proto( cfg, 'xmr', network=d.network, need_amt=True )
 
@@ -324,20 +385,182 @@ class MoneroMMGenTX:
 				fee            = proto.coin_amt(d.fee),
 				blob           = d.blob,
 				metadata       = d.metadata,
+				unsigned_txset = d.unsigned_txset,
+				signed_txset   = d.signed_txset,
+				complete       = d.complete,
 			)
 
 			self.check_checksums(d_wrap)
 
+	class Unsigned(Completed):
+		desc = 'unsigned transaction'
+		ext = 'rawtx'
+		signed = False
+		req_field = 'unsigned_txset'
+		forbidden_fields = ('signed_txset',)
+
 	class Signed(Completed):
 		desc = 'signed transaction'
 		ext = 'sigtx'
 		signed = True
 		req_field = 'blob'
+		forbidden_fields = ('signed_txset','unsigned_txset')
+
+	class ColdSigned(Signed):
+		req_field = 'signed_txset'
+		forbidden_fields = ()
+
+	class Submitted(ColdSigned):
+		desc = 'submitted transaction'
+		ext = 'subtx'
+
+class MoneroWalletOutputsFile:
+
+	class Base(MoneroMMGenFile):
+
+		desc = 'wallet outputs'
+		data_label = 'MoneroMMGenWalletOutputsFile'
+		base_chksum_fields = ('seed_id','wallet_index','outputs_data_hex',)
+		full_chksum_fields = ('seed_id','wallet_index','outputs_data_hex','signed_key_images')
+		fn_fs = '{a}-outputs-{b}.{c}'
+		ext_offset = 25 # len('-outputs-') + len(chksum) ({b})
+		chksum_nchars = 16
+		data_tuple = namedtuple('wallet_outputs_data',[
+			'seed_id',
+			'wallet_index',
+			'outputs_data_hex',
+			'signed_key_images',
+		])
+
+		def __init__(self,cfg):
+			self.name = type(self).__name__
+			self.cfg = cfg
+
+		def write(self,add_suf=''):
+			from .fileutil import write_data_to_file
+			write_data_to_file(
+				cfg               = self.cfg,
+				outfile           = self.get_outfile( self.cfg, self.wallet_fn ) + add_suf,
+				data              = self.make_wrapped_data(self.data._asdict()),
+				desc              = self.desc,
+				ask_overwrite     = False,
+				ignore_opt_outdir = True )
+
+		def get_outfile(self,cfg,wallet_fn):
+			fn = self.fn_fs.format(
+				a = wallet_fn,
+				b = self.base_chksum,
+				c = self.ext,
+			)
+			return os.path.join(
+				get_autosign_obj(cfg).xmr_outputs_dir,
+				os.path.basename(fn) ) if cfg.autosign else fn
+
+		def get_wallet_fn(self,fn):
+			assert fn.endswith(f'.{self.ext}'), (
+				f'{type(self).__name__}: filename does not end with {"."+self.ext!r}'
+			)
+			return fn[:-(len(self.ext)+self.ext_offset+1)]
+
+		def get_info(self,indent=''):
+			if self.data.signed_key_images is not None:
+				data = self.data.signed_key_images or []
+				return f'{self.wallet_fn}: {len(data)} signed key image{suf(data)}'
+			else:
+				return f'{self.wallet_fn}: no key images'
+
+	class New(Base):
+		ext = 'raw'
+
+		def __init__( self, parent, wallet_fn, data, wallet_idx=None ):
+			super().__init__(parent.cfg)
+			self.wallet_fn = wallet_fn
+			init_data = dict.fromkeys(self.data_tuple._fields)
+			init_data.update({
+				'seed_id':      parent.kal.al_id.sid,
+				'wallet_index': wallet_idx or parent.get_idx_from_fn(os.path.basename(wallet_fn)),
+			})
+			init_data.update({k:v for k,v in data.items() if k in init_data})
+			self.data = self.data_tuple(**init_data)
+
+	class Completed(New):
+
+		def __init__( self, parent, fn=None, wallet_fn=None ):
+			def check_equal(desc,a,b):
+				assert a == b, f'{desc} mismatch: {a} (from file) != {b} (from filename)'
+			fn = fn or self.get_outfile( parent.cfg, wallet_fn )
+			wallet_fn = wallet_fn or self.get_wallet_fn(fn)
+			d_wrap = self.extract_data_from_file( parent.cfg, fn )
+			data = d_wrap['data']
+			check_equal( 'Seed ID', data['seed_id'], parent.kal.al_id.sid )
+			wallet_idx = parent.get_idx_from_fn(os.path.basename(wallet_fn))
+			check_equal( 'Wallet index', data['wallet_index'], wallet_idx )
+			super().__init__(
+				parent     = parent,
+				wallet_fn  = wallet_fn,
+				data       = data,
+				wallet_idx = wallet_idx,
+			)
+			self.check_checksums(d_wrap)
+
+		@classmethod
+		def find_fn_from_wallet_fn(cls,cfg,wallet_fn,ret_on_no_match=False):
+			path = get_autosign_obj(cfg).xmr_outputs_dir or os.curdir
+			fn = os.path.basename(wallet_fn)
+			pat = cls.fn_fs.format(
+				a = fn,
+				b = f'[0-9a-f]{{{cls.chksum_nchars}}}\\',
+				c = cls.ext,
+			)
+			matches = [f for f in os.scandir(path) if re.match(pat,f.name)]
+			if not matches and ret_on_no_match:
+				return None
+			if not matches or len(matches) > 1:
+				die(2,'{a} matching pattern {b!r} found in {c}!'.format(
+					a = 'No files' if not matches else 'More than one file',
+					b = pat,
+					c = path
+				))
+			return matches[0].path
+
+	class Unsigned(Completed):
+		pass
+
+	class SignedNew(New):
+		desc = 'signed key images'
+		ext = 'sig'
+
+	class Signed(Completed,SignedNew):
+		pass
+
+class MoneroWalletDumpFile:
+
+	class Base:
+		desc = 'Monero wallet dump'
+		data_label = 'MoneroMMGenWalletDumpFile'
+		base_chksum_fields = ('seed_id','wallet_index','wallet_metadata')
+		full_chksum_fields = None
+		ext = 'dump'
+		ext_offset = 0
+		data_tuple = namedtuple('wallet_dump_data',[
+			'seed_id',
+			'wallet_index',
+			'wallet_metadata',
+		])
+		def get_outfile(self,cfg,wallet_fn):
+			return f'{wallet_fn}.{self.ext}'
+
+	class New(Base,MoneroWalletOutputsFile.New):
+		pass
+
+	class Completed(Base,MoneroWalletOutputsFile.Completed):
+		pass
 
 class MoneroWalletOps:
 
 	ops = (
 		'create',
+		'create_offline',
 		'sync',
 		'list',
 		'new',
@@ -345,7 +568,24 @@ class MoneroWalletOps:
 		'sweep',
 		'relay',
 		'txview',
-		'label' )
+		'label',
+		'sign',
+		'submit',
+		'dump',
+		'restore',
+		'export_outputs',
+		'import_key_images' )
+
+	kafile_arg_ops = (
+		'create',
+		'sync',
+		'list',
+		'label',
+		'new',
+		'transfer',
+		'sweep',
+		'dump',
+		'restore' )
 
 	opts = (
 		'wallet_dir',
@@ -356,13 +596,16 @@ class MoneroWalletOps:
 		'restore_height',
 		'no_start_wallet_daemon',
 		'no_stop_wallet_daemon',
-		'no_relay' )
+		'no_relay',
+		'watch_only',
+		'autosign' )
 
 	pat_opts = ('daemon','tx_relay_daemon')
 
 	class base(MMGenObject):
 
 		opts = ('wallet_dir',)
+		trust_daemon = False
 
 		def __init__(self,cfg,uarg_tuple):
 
@@ -448,8 +691,12 @@ class MoneroWalletOps:
 			'daemon',
 			'no_start_wallet_daemon',
 			'no_stop_wallet_daemon',
+			'autosign',
+			'watch_only',
 		)
 		wallet_exists = True
+		start_daemon = True
+		offline = False
 		skip_wallet_check = False # for debugging
 
 		def __init__(self,cfg,uarg_tuple):
@@ -470,11 +717,36 @@ class MoneroWalletOps:
 
 			super().__init__(cfg,uarg_tuple)
 
-			self.kal = KeyAddrList(
-				cfg      = cfg,
-				proto    = self.proto,
-				addrfile = uarg.infile,
-				key_address_validity_check = True )
+			if self.offline:
+				from .wallet import Wallet
+				self.seed_src = Wallet(
+					cfg           = cfg,
+					fn            = uarg.infile,
+					ignore_in_fmt = True )
+
+				gmsg('\nCreating ephemeral key-address list for offline wallets')
+				self.kal = KeyAddrList(
+					cfg       = cfg,
+					proto     = self.proto,
+					seed      = self.seed_src.seed,
+					addr_idxs = uarg.wallets,
+					skip_chksum_msg = True )
+			else:
+				# with watch_only, make a second attempt to open the file as KeyAddrList:
+				for first_try in (True,False):
+					try:
+						self.kal = (ViewKeyAddrList if (self.cfg.watch_only and first_try) else KeyAddrList)(
+							cfg      = cfg,
+							proto    = self.proto,
+							addrfile = self.autosign_viewkey_addr_file if self.cfg.autosign else uarg.infile,
+							key_address_validity_check = True,
+							skip_chksum_msg = True )
+						break
+					except:
+						if first_try:
+							msg(f'Attempting to open {uarg.infile} as key-address list')
+							continue
+						raise
 
 			msg('')
 
@@ -483,17 +755,25 @@ class MoneroWalletOps:
 			if not self.skip_wallet_check:
 				check_wallets()
 
+			relay_opt = self.parse_tx_relay_opt() if self.name == 'submit' and self.cfg.tx_relay_daemon else None
+
 			self.wd = MoneroWalletDaemon(
 				cfg         = self.cfg,
 				proto       = self.proto,
 				wallet_dir  = self.cfg.wallet_dir or '.',
 				test_suite  = self.cfg.test_suite,
-				daemon_addr = self.cfg.daemon or None,
+				daemon_addr = relay_opt[1] if relay_opt else (self.cfg.daemon or None),
+				trust_daemon = self.trust_daemon,
 			)
 
 			u = self.wd.usr_daemon_args = []
-			if self.name == 'create' and self.cfg.restore_height is None:
+			if self.offline or (self.name in ('create','restore') and self.cfg.restore_height is None):
 				u.append('--offline')
+			if relay_opt:
+				if self.cfg.test_suite:
+					u.append('--daemon-ssl-allow-any-cert')
+				if relay_opt[2]:
+					u.append(f'--proxy={relay_opt[2]}')
 
 			self.c = MoneroWalletRPCClient(
 				cfg             = self.cfg,
@@ -501,9 +781,13 @@ class MoneroWalletOps:
 				test_connection = False,
 			)
 
-			if not self.cfg.no_start_wallet_daemon:
+			if self.start_daemon and not self.cfg.no_start_wallet_daemon:
 				async_run(self.c.restart_daemon())
 
+		@classmethod
+		def get_idx_from_fn(cls,fn):
+			return int( re.match(r'[0-9a-fA-F]{8}-(\d+)-Monero(WatchOnly)?Wallet.*',fn)[1] )
+
 		def get_coin_daemon_rpc(self):
 
 			host,port = self.cfg.daemon.split(':') if self.cfg.daemon else ('localhost',self.wd.daemon_port)
@@ -518,6 +802,22 @@ class MoneroWalletOps:
 				user   = None,
 				passwd = None )
 
+		@property
+		def autosign_viewkey_addr_file(self):
+			from .addrfile import ViewKeyAddrFile
+			mpdir = get_autosign_obj(self.cfg).xmr_dir
+			fnlist = [f for f in os.listdir(mpdir) if f.endswith(ViewKeyAddrFile.ext)]
+			if len(fnlist) != 1:
+				die(2,
+					'{a} viewkey-address files found in autosign mountpoint directory {b!r}!\n'.format(
+						a = 'Multiple' if fnlist else 'No',
+						b = mpdir
+					)
+					+ 'Have you run ‘mmgen-autosign setup’ on your offline machine with the --xmrwallets option?'
+				)
+			else:
+				return os.path.join( mpdir, fnlist[0] )
+
 		def create_addr_data(self):
 			if uarg.wallets:
 				idxs = AddrIdxList(uarg.wallets)
@@ -531,18 +831,22 @@ class MoneroWalletOps:
 			if not self.cfg.no_stop_wallet_daemon:
 				await self.c.stop_daemon()
 
-		def get_wallet_fn(self,data):
+		def get_wallet_fn(self,data,watch_only=None):
+			if watch_only is None:
+				watch_only = self.cfg.watch_only
 			return os.path.join(
-				self.cfg.wallet_dir or '.','{a}-{b}-MoneroWallet{c}'.format(
+				self.cfg.wallet_dir or '.','{a}-{b}-Monero{c}Wallet{d}'.format(
 					a = self.kal.al_id.sid,
 					b = data.idx,
-					c = f'.{self.cfg.network}' if self.cfg.network != 'mainnet' else ''))
+					c = 'WatchOnly' if watch_only else '',
+					d = f'.{self.cfg.network}' if self.cfg.network != 'mainnet' else ''))
 
 		async def main(self):
-			gmsg('\n{a}ing {b} wallet{c}'.format(
+			gmsg('\n{a}ing {b} {c}wallet{d}'.format(
 				a = self.stem.capitalize(),
 				b = len(self.addr_data),
-				c = suf(self.addr_data) ))
+				c = 'watch-only ' if self.cfg.watch_only else '',
+				d = suf(self.addr_data) ))
 			processed = 0
 			for n,d in enumerate(self.addr_data): # [d.sec,d.addr,d.wallet_passwd,d.viewkey]
 				fn = self.get_wallet_fn(d)
@@ -559,6 +863,14 @@ class MoneroWalletOps:
 			gmsg(f'\n{processed} wallet{suf(processed)} {self.stem}ed')
 			return processed
 
+		def head_msg(self,wallet_idx,fn):
+			gmsg('\n{} {} wallet #{} ({})'.format(
+				self.action.capitalize(),
+				self.wallet_desc,
+				wallet_idx,
+				os.path.basename(fn)
+			))
+
 		class rpc:
 
 			def __init__(self,parent,d):
@@ -567,6 +879,9 @@ class MoneroWalletOps:
 				self.c = parent.c
 				self.d = d
 				self.fn = parent.get_wallet_fn(d)
+				self.new_tx_cls = (
+					MoneroMMGenTX.NewUnsigned if self.cfg.watch_only else
+					MoneroMMGenTX.NewSigned )
 
 			def open_wallet(self,desc,refresh=True):
 				gmsg_r(f'\n  Opening {desc} wallet...')
@@ -577,7 +892,8 @@ class MoneroWalletOps:
 				gmsg('done')
 
 				if refresh:
-					gmsg_r(f'  Refreshing {desc} wallet...')
+					m = ' and contacting relay' if self.parent.name == 'submit' and self.cfg.tx_relay_daemon else ''
+					gmsg_r(f'  Refreshing {desc} wallet{m}...')
 					ret = self.c.call('refresh')
 					gmsg('done')
 					if ret['received_money']:
@@ -695,7 +1011,7 @@ class MoneroWalletOps:
 					get_tx_hex = True,
 					get_tx_metadata = True
 				)
-				return MoneroMMGenTX.NewSigned(
+				return self.new_tx_cls(
 					cfg            = self.cfg,
 					op             = self.parent.name,
 					network        = self.parent.proto.network,
@@ -708,6 +1024,7 @@ class MoneroWalletOps:
 					fee            = res['fee'],
 					blob           = res['tx_blob'],
 					metadata       = res['tx_metadata'],
+					unsigned_txset = res['unsigned_txset'] if self.cfg.watch_only else None,
 				)
 
 			def make_sweep_tx(self,account,dest_acct,dest_addr_idx,addr):
@@ -723,7 +1040,7 @@ class MoneroWalletOps:
 				if len(res['tx_hash_list']) > 1:
 					die(3,'More than one TX required.  Cannot perform this sweep')
 
-				return MoneroMMGenTX.NewSigned(
+				return self.new_tx_cls(
 					cfg            = self.cfg,
 					op             = self.parent.name,
 					network        = self.parent.proto.network,
@@ -739,6 +1056,7 @@ class MoneroWalletOps:
 					fee            = res['fee_list'][0],
 					blob           = res['tx_blob_list'][0],
 					metadata       = res['tx_metadata_list'][0],
+					unsigned_txset = res['unsigned_txset'] if self.cfg.watch_only else None,
 				)
 
 			def relay_tx(self,tx_hex):
@@ -766,26 +1084,137 @@ class MoneroWalletOps:
 			else:
 				restore_height = self.cfg.restore_height
 
-			from .xmrseed import xmrseed
-			ret = self.c.call(
-				'restore_deterministic_wallet',
-				filename       = os.path.basename(fn),
-				password       = d.wallet_passwd,
-				seed           = xmrseed().fromhex(d.sec.wif,tostr=True),
-				restore_height = restore_height,
-				language       = 'English' )
+			if self.cfg.watch_only:
+				ret = self.c.call(
+					'generate_from_keys',
+					filename       = os.path.basename(fn),
+					password       = d.wallet_passwd,
+					address        = d.addr,
+					viewkey        = d.viewkey,
+					restore_height = restore_height )
+			else:
+				from .xmrseed import xmrseed
+				ret = self.c.call(
+					'restore_deterministic_wallet',
+					filename       = os.path.basename(fn),
+					password       = d.wallet_passwd,
+					seed           = xmrseed().fromhex(d.sec.wif,tostr=True),
+					restore_height = restore_height,
+					language       = 'English' )
 
 			pp_msg(ret) if self.cfg.debug else msg('  Address: {}'.format( ret['address'] ))
 			return True
 
-	class sync(wallet):
-		opts    = ('rescan_blockchain',)
+	class create_offline(create):
+		offline = True
 
 		def __init__(self,cfg,uarg_tuple):
 
 			super().__init__(cfg,uarg_tuple)
 
-			host,port = self.cfg.daemon.split(':') if self.cfg.daemon else ('localhost',self.wd.daemon_port)
+			gmsg('\nCreating viewkey-address file for watch-only wallets')
+			vkal = ViewKeyAddrList(
+				cfg       = self.cfg,
+				proto     = self.proto,
+				addrfile  = None,
+				addr_idxs = uarg.wallets,
+				seed      = self.seed_src.seed,
+				skip_chksum_msg = True )
+			vkf = vkal.file
+
+			# before writing viewkey-address file, delete any old ones in the directory:
+			for fn in os.listdir(self.cfg.outdir):
+				if fn.endswith(vkf.ext):
+					os.unlink(os.path.join(self.cfg.outdir,fn))
+
+			vkf.write() # write file to self.cfg.outdir
+
+	class restore(create):
+
+		def check_uopts(self):
+			if self.cfg.restore_height is not None:
+				die(1,f'--restore-height must be unset when running the ‘restore’ command')
+
+		async def process_wallet(self,d,fn,last):
+
+			def get_dump_data():
+				fns = [fn for fn in
+						[self.get_wallet_fn(d,watch_only=wo) + '.dump' for wo in (True,False)]
+							if os.path.exists(fn)]
+				if not fns:
+					die(1,f'No suitable dump file found for {fn!r}')
+				elif len(fns) > 1:
+					ymsg(f'Warning: more than one dump file found for {fn!r} - using the first!')
+				return MoneroWalletDumpFile.Completed(
+					parent = self,
+					fn     = fns[0] ).data._asdict()['wallet_metadata']
+
+			def restore_accounts():
+				bmsg('  Restoring accounts:')
+				for acct_idx,acct_data in enumerate(data[1:],1):
+					msg(fs.format(acct_idx, 0, acct_data['address']))
+					self.c.call('create_account')
+
+			def restore_subaddresses():
+				bmsg('  Restoring subaddresses:')
+				for acct_idx,acct_data in enumerate(data):
+					for addr_idx,addr_data in enumerate(acct_data['addresses'][1:],1):
+						msg(fs.format(acct_idx, addr_idx, addr_data['address']))
+						ret = self.c.call( 'create_address', account_index=acct_idx )
+
+			def restore_labels():
+				bmsg('  Restoring labels:')
+				for acct_idx,acct_data in enumerate(data):
+					for addr_idx,addr_data in enumerate(acct_data['addresses']):
+						addr_data['used'] = False # do this so that restored data matches
+						msg(fs.format(acct_idx, addr_idx, addr_data['label']))
+						self.c.call(
+							'label_address',
+							index = { 'major': acct_idx, 'minor': addr_idx },
+							label = addr_data['label'],
+						)
+
+			def make_format_str():
+				return '    acct {:O>%s}, addr {:O>%s} [{}]' % (
+					len(str( len(data) - 1 )),
+					len(str( max(len(acct_data['addresses']) for acct_data in data) - 1))
+				)
+
+			def check_restored_data():
+				restored_data = h.get_accts(print=False)[1]
+				if restored_data != data:
+					rmsg(f'Restored data does not match original dump!  Dumping bad data.')
+					MoneroWalletDumpFile.New(
+						parent    = self,
+						wallet_fn = fn,
+						data      = {'wallet_metadata': restored_data} ).write(add_suf='.bad')
+					die(3,'Fatal error')
+
+			res = await super().process_wallet(d,fn,last)
+
+			h = self.rpc(self,d)
+			h.open_wallet('newly created')
+
+			msg('')
+			data = get_dump_data()
+			fs = make_format_str()
+
+			gmsg('\nRestoring accounts, subaddresses and labels from dump file:\n')
+
+			restore_accounts()
+			restore_subaddresses()
+			restore_labels()
+
+			check_restored_data()
+
+			return True
+
+	class sync(wallet):
+		opts = ('rescan_blockchain',)
+
+		def __init__(self,cfg,uarg_tuple):
+
+			super().__init__(cfg,uarg_tuple)
 
 			self.dc = self.get_coin_daemon_rpc()
 
@@ -947,7 +1376,11 @@ class MoneroWalletOps:
 	class sweep(spec):
 		spec_id  = 'sweep_spec'
 		spec_key = ( (1,'source'), (3,'dest') )
-		opts     = ('no_relay','tx_relay_daemon')
+		opts     = ('no_relay','tx_relay_daemon','watch_only')
+
+		def check_uopts(self):
+			if self.cfg.tx_relay_daemon and (self.cfg.no_relay or self.cfg.autosign):
+				die(1,'--tx-relay-daemon makes no sense in this context!')
 
 		def init_tx_relay_daemon(self):
 
@@ -1037,7 +1470,7 @@ class MoneroWalletOps:
 			msg('Saving TX data to file')
 			new_tx.write(delete_metadata=True)
 
-			if self.cfg.no_relay:
+			if self.cfg.no_relay or self.cfg.autosign:
 				return True
 
 			if keypress_confirm( self.cfg, f'Relay {self.name} transaction?' ):
@@ -1142,11 +1575,183 @@ class MoneroWalletOps:
 			else:
 				ymsg('\nOperation cancelled by user request')
 
+	class sign(wallet):
+		wallet_desc = 'offline signing'
+		action = 'signing transaction with'
+		start_daemon = False
+		offline = True
+
+		async def main(self,fn):
+			await self.c.restart_daemon()
+			tx = MoneroMMGenTX.Unsigned( self.cfg, fn )
+			h = self.rpc(self,self.addr_data[0])
+			self.head_msg(tx.src_wallet_idx,h.fn)
+			h.open_wallet('offline signing')
+			res = self.c.call(
+				'sign_transfer',
+				unsigned_txset = tx.data.unsigned_txset,
+				export_raw = True,
+				get_tx_keys = True
+			)
+			new_tx = MoneroMMGenTX.NewColdSigned(
+				cfg            = self.cfg,
+				txid           = res['tx_hash_list'][0],
+				unsigned_txset = None,
+				signed_txset   = res['signed_txset'],
+				_in_tx         = tx,
+			)
+			await self.stop_wallet_daemon()
+			return new_tx
+
+	class submit(wallet):
+		wallet_desc = 'watch-only'
+		action = 'submitting transaction with'
+		opts = ('tx_relay_daemon',)
+
+		def check_uopts(self):
+			if self.cfg.daemon:
+				die(1,f'--daemon is not supported for the ‘{self.name}’ operation. Use --tx-relay-daemon instead')
+
+		def get_unsubmitted_tx_fn(self):
+			from .autosign import Signable
+			t = Signable.xmr_transaction( get_autosign_obj(self.cfg) )
+			if len(t.unsubmitted) != 1:
+				die('AutosignTXError', '{a} unsubmitted transaction{b} in {c!r}!'.format(
+					a = 'More than one' if t.unsubmitted else 'No',
+					b = suf(t.unsubmitted),
+					c = t.parent.xmr_tx_dir,
+				))
+			return t.unsubmitted[0].path
+
+		async def main(self):
+			tx = MoneroMMGenTX.ColdSigned(
+				cfg = self.cfg,
+				fn  = uarg.infile or self.get_unsubmitted_tx_fn() )
+			h = self.rpc( self, self.kal.entry(tx.src_wallet_idx) )
+			self.head_msg(tx.src_wallet_idx,h.fn)
+			h.open_wallet(self.wallet_desc)
+
+			msg('\n' + tx.get_info())
+
+			if self.cfg.tx_relay_daemon:
+				self.display_tx_relay_info()
+
+			if keypress_confirm( self.cfg, 'Submit transaction?' ):
+				res = self.c.call(
+					'submit_transfer',
+					tx_data_hex = tx.data.signed_txset )
+				assert res['tx_hash_list'][0] == tx.data.txid, 'TxID mismatch in ‘submit_transfer’ result!'
+			else:
+				die(1,'Exiting at user request')
+
+			new_tx = MoneroMMGenTX.NewSubmitted(
+				cfg          = self.cfg,
+				complete     = True,
+				_in_tx       = tx,
+			)
+			gmsg('\nOK')
+			new_tx.write(
+				ask_write     = not self.cfg.autosign,
+				ask_overwrite = not self.cfg.autosign )
+			return new_tx
+
+	class dump(wallet):
+		wallet_desc = 'source'
+
+		async def process_wallet(self,d,fn,last):
+			h = self.rpc(self,d)
+			h.open_wallet(self.wallet_desc)
+			acct_data,addr_data = h.get_accts(print=False)
+			msg('')
+			MoneroWalletDumpFile.New(
+				parent    = self,
+				wallet_fn = fn,
+				data      = {'wallet_metadata': addr_data} ).write()
+			return True
+
+	class export_outputs(wallet):
+		wallet_desc = 'watch-only'
+		action = 'exporting outputs from'
+		stem = 'process'
+		opts = ('export_all',)
+
+		async def process_wallet(self,d,fn,last):
+			h = self.rpc(self,d)
+			h.open_wallet('source')
+			self.head_msg(d.idx,h.fn)
+			for ftype in ('Unsigned','Signed'):
+				old_fn = getattr(MoneroWalletOutputsFile,ftype).find_fn_from_wallet_fn(
+					cfg             = self.cfg,
+					wallet_fn       = fn,
+					ret_on_no_match = True )
+				if old_fn:
+					os.unlink(old_fn)
+			m = MoneroWalletOutputsFile.New(
+				parent    = self,
+				wallet_fn = fn,
+				data      = self.c.call('export_outputs', all=self.cfg.export_all ),
+			)
+			m.write()
+			return True
+
+	class export_key_images(wallet):
+		wallet_desc = 'offline signing'
+		action = 'signing wallet outputs file with'
+		start_daemon = False
+		offline = True
+
+		async def main(self,f,wallet_idx):
+			await self.c.restart_daemon()
+			h = self.rpc(self,self.addr_data[0])
+			self.head_msg(wallet_idx,f.name)
+			h.open_wallet('offline signing')
+			m = MoneroWalletOutputsFile.Unsigned(
+				parent = self,
+				fn     = f.path )
+			res = self.c.call(
+				'import_outputs',
+				outputs_data_hex = m.data.outputs_data_hex )
+			idata = res['num_imported']
+			bmsg('\n  {} output{} imported'.format( idata, suf(idata) ))
+			data = m.data._asdict()
+			data.update(self.c.call('export_key_images')) # for testing: all = True
+			m = MoneroWalletOutputsFile.SignedNew(
+				parent    = self,
+				wallet_fn = m.get_wallet_fn(f.name),
+				data      = data )
+			idata = m.data.signed_key_images or []
+			bmsg('  {} key image{} signed'.format( len(idata), suf(idata) ))
+			await self.stop_wallet_daemon()
+			return m
+
+	class import_key_images(wallet):
+		wallet_desc = 'watch-only'
+		action = 'importing key images into'
+		stem = 'process'
+		trust_daemon = True
+
+		async def process_wallet(self,d,fn,last):
+			h = self.rpc(self,d)
+			h.open_wallet(self.wallet_desc)
+			self.head_msg(d.idx,h.fn)
+			m = MoneroWalletOutputsFile.Signed(
+				parent = self,
+				fn  = MoneroWalletOutputsFile.Signed.find_fn_from_wallet_fn( self.cfg, fn ),
+			)
+			data = m.data.signed_key_images or []
+			bmsg('\n  {} signed key image{} to import'.format( len(data), suf(data) ))
+			if data:
+				res = self.c.call( 'import_key_images', signed_key_images=data )
+				bmsg(f'  Success: {res}')
+			return True
+
 	class relay(base):
 		opts = ('tx_relay_daemon',)
 
 		def __init__(self,cfg,uarg_tuple):
 
+			check_uopts = MoneroWalletOps.submit.check_uopts
+
 			super().__init__(cfg,uarg_tuple)
 
 			self.tx = MoneroMMGenTX.Signed( self.cfg, uarg.infile )
@@ -1202,5 +1807,5 @@ class MoneroWalletOps:
 					tx.get_info() for tx in
 					sorted(
 						(MoneroMMGenTX.Completed( self.cfg, fn ) for fn in uarg.infile),
-						key = lambda x: x.data.sign_time )
+						key = lambda x: x.data.sign_time or x.data.create_time )
 			))

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

@@ -125,9 +125,10 @@ init_tests() {
 	[ \( "$ARM32" -o "$ARM64" \) -a "$DISTRO" != 'archarm' ] && t_altgen_skip+=' e'
 
 
-	d_xmr="Monero xmrwallet operations using stagenet"
+	d_xmr="Monero xmrwallet operations"
 	t_xmr="
-		- $test_py --coin=xmr
+		- $test_py --coin=xmr --exclude=xmr_autosign
+		- $test_py --coin=xmr xmr_autosign
 	"
 
 	d_eth="operations for Ethereum and Ethereum Classic using devnet"

+ 2 - 0
test/test_py_d/cfg.py

@@ -35,6 +35,7 @@ cmd_groups_dfl = {
 #	'chainsplit':       ('TestSuiteChainsplit',{}),
 	'ethdev':           ('TestSuiteEthdev',{}),
 	'xmrwallet':        ('TestSuiteXMRWallet',{}),
+	'xmr_autosign':     ('TestSuiteXMRAutosign',{}),
 }
 
 cmd_groups_extra = {
@@ -212,6 +213,7 @@ cfgs = { # addr_idx_lists (except 31,32,33,34) must contain exactly 8 addresses
 	'32': {},
 	'33': {},
 	'34': {},
+	'39': {},
 	'40': {},
 	'41': {},
 	'99': {}, # dummy

+ 2 - 0
test/test_py_d/ts_autosign.py

@@ -133,6 +133,8 @@ class TestSuiteAutosignBase(TestSuiteBase):
 		def gen_msg_fns():
 			fmap = dict(filedir_map)
 			for coin in self.coins:
+				if coin == 'xmr':
+					continue
 				sdir = os.path.join('test','ref',fmap[coin])
 				for fn in os.listdir(sdir):
 					if fn.endswith(f'[{coin.upper()}].rawmsg.json'):

+ 281 - 0
test/test_py_d/ts_xmr_autosign.py

@@ -0,0 +1,281 @@
+#!/usr/bin/env python3
+#
+# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
+# Copyright (C)2013-2023 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
+#   https://gitlab.com/mmgen/mmgen
+
+
+"""
+test.test_py_d.ts_xmr_autosign: xmr autosigning tests for the test.py test suite
+"""
+
+from .ts_xmrwallet import *
+from .ts_autosign import TestSuiteAutosignBase
+
+def make_burn_addr():
+	from mmgen.tool.coin import tool_cmd
+	return tool_cmd(
+		cfg     = cfg,
+		cmdname = 'privhex2addr',
+		proto   = cfg._proto,
+		mmtype  = 'monero' ).privhex2addr('beadcafe'*8)
+
+class TestSuiteXMRAutosign(TestSuiteXMRWallet,TestSuiteAutosignBase):
+	"""
+	Monero autosigning operations
+	"""
+
+	tmpdir_nums = [39]
+
+	# ts_xmrwallet attrs:
+	user_data = (
+		('miner', '98831F3A', False, 130, '1', []),
+		('alice', 'FE3C6545', True,  150, '1-2', []),
+	)
+
+	# ts_autosign attrs:
+	coins        = ['xmr']
+	daemon_coins = []
+	txfile_coins = []
+	live         = False
+	simulate     = False
+	bad_tx_count = 0
+	tx_relay_user = 'miner'
+
+	cmd_group = (
+		('daemon_version',           'checking daemon version'),
+		('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'),
+		('delete_tmp_wallets',       'deleting Alice’s tmp wallets'),
+		('autosign_setup',           'autosign setup with Alice’s seed'),
+		('create_watchonly_wallets', 'creating online (watch-only) wallets for Alice'),
+		('delete_tmp_dump_files',    'deleting Alice’s dump files'),
+		('gen_kafiles',              'generating key-address files for Miner'),
+		('create_wallets_miner',     'creating Monero wallets for Miner'),
+		('mine_initial_coins',       'mining initial coins'),
+		('fund_alice',               'sending funds to Alice'),
+		('create_transfer_tx1',      'creating a transfer TX'),
+		('sign_transfer_tx1',        'signing the transfer TX'),
+		('submit_transfer_tx1',      'submitting the transfer TX'),
+		('create_transfer_tx2',      'creating a transfer TX (for relaying via proxy)'),
+		('sign_transfer_tx2',        'signing the transfer TX (for relaying via proxy)'),
+		('submit_transfer_tx2',      'submitting the transfer TX (relaying via proxy)'),
+		('list_wallets',             'listing Alice’s wallets and checking balance'),
+		('dump_wallets',             'dumping Alice’s wallets'),
+		('delete_wallets',           'deleting Alice’s wallets'),
+		('restore_wallets',          'creating online (watch-only) wallets for Alice'),
+		('delete_dump_files',        'deleting Alice’s dump files'),
+		('export_outputs',           'exporting outputs from Alice’s watch-only wallets'),
+		('export_key_images',        'exporting signed key images from Alice’s offline wallets'),
+		('import_key_images',        'importing signed key images into Alice’s online wallets'),
+		('list_wallets',             'listing Alice’s wallets and checking balance'),
+	)
+
+	def __init__(self,trunner,cfgs,spawn):
+
+		TestSuiteXMRWallet.__init__(self,trunner,cfgs,spawn)
+		TestSuiteAutosignBase.__init__(self,trunner,cfgs,spawn)
+
+		if trunner == None:
+			return
+
+		from mmgen.cfg import Config
+		self.cfg = Config({
+			'coin': 'XMR',
+			'outdir': self.users['alice'].udir,
+			'wallet_dir': self.users['alice'].udir,
+			'wallet_rpc_password': 'passwOrd',
+		})
+
+		self.burn_addr = make_burn_addr()
+
+		self.opts.append('--xmrwallets={}'.format( self.users['alice'].kal_range )) # mmgen-autosign opts
+		self.autosign_opts = [f'--autosign-mountpoint={self.mountpoint}']           # mmgen-xmrwallet opts
+		self.tx_count = 1
+
+	def create_tmp_wallets(self):
+		self.spawn('',msg_only=True)
+		data = self.users['alice']
+		from mmgen.wallet import Wallet
+		from mmgen.xmrwallet import MoneroWalletOps,xmrwallet_uargs
+		silence()
+		kal = KeyAddrList(
+			cfg       = self.cfg,
+			proto     = self.proto,
+			addr_idxs = '1-2',
+			seed      = Wallet(cfg,data.mmwords).seed )
+		kal.file.write(ask_overwrite=False)
+		fn = get_file_with_ext(data.udir,'akeys')
+		m = MoneroWalletOps.create(
+			self.cfg,
+			xmrwallet_uargs(fn, '1-2', None))
+		async_run(m.main())
+		async_run(m.stop_wallet_daemon())
+		end_silence()
+		return 'ok'
+
+	def _new_addr_alice(self,*args):
+		data = self.users['alice']
+		return self.new_addr_alice(
+			*args,
+			kafile = get_file_with_ext(data.udir,'akeys') )
+
+	def new_account_alice(self):
+		return self._new_addr_alice(
+			'2',
+			'start',
+			fr'Creating new account.*Index:\s+{self.na_idx}\s')
+
+	def new_address_alice(self):
+		return self._new_addr_alice(
+			'2:1',
+			'continue',
+			fr'Account index:\s+1\s+Creating new address' )
+
+	def new_address_alice_label(self):
+		return self._new_addr_alice(
+			'2:1,Alice’s new address',
+			'stop',
+			fr'Account index:\s+1\s+Creating new address.*Alice’s new address' )
+
+	def dump_tmp_wallets(self):
+		return self._dump_wallets(autosign=False)
+
+	def dump_wallets(self):
+		return self._dump_wallets(autosign=True)
+
+	def _dump_wallets(self,autosign):
+		data = self.users['alice']
+		t = self.spawn(
+			'mmgen-xmrwallet',
+			self.extra_opts
+			+ [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')]) )
+		t.expect('2 wallets dumped')
+		return t
+
+	def _delete_files(self,*ext_list):
+		data = self.users['alice']
+		self.spawn('',msg_only=True)
+		for ext in ext_list:
+			get_file_with_ext(data.udir,ext,no_dot=True,delete_all=True)
+		return 'ok'
+
+	def delete_tmp_wallets(self):
+		return self._delete_files( 'MoneroWallet', 'MoneroWallet.keys', '.akeys' )
+
+	def delete_wallets(self):
+		return self._delete_files( 'MoneroWatchOnlyWallet', '.keys', '.address.txt' )
+
+	def delete_tmp_dump_files(self):
+		return self._delete_files( '.dump' )
+
+	def delete_dump_files(self):
+		return self._delete_files( '.dump' )
+
+	def autosign_setup(self):
+		from pathlib import Path
+		Path(self.autosign_xmr_dir).mkdir(parents=True,exist_ok=True)
+		Path(self.autosign_xmr_dir,'old.vkeys').touch()
+		t = self.make_wallet(
+			mn_type        = 'mmgen',
+			mn_file        = self.users['alice'].mmwords,
+			use_dfl_wallet = None )
+		t.expect('Continue with Monero setup? (Y/n): ','y')
+		t.written_to_file('View keys')
+		return t
+
+	def create_watchonly_wallets(self):
+		return self.create_wallets( 'alice', op='restore' )
+
+	def restore_wallets(self):
+		return self.create_wallets( 'alice', op='restore' )
+
+	def list_wallets(self):
+		return self.sync_wallets(
+			'alice',
+			op           = 'list',
+			bal_chk_func = lambda n,bal: (0.83 < bal < 0.8536) if n == 0 else True )
+			# 1.234567891234 - 0.124 - 0.257 = 0.853567891234 (minus fees)
+
+	def _create_transfer_tx(self,amt):
+		return self.do_op('transfer','alice',f'1:0:{self.burn_addr},{amt}',no_relay=True,do_ret=True)
+
+	def create_transfer_tx1(self):
+		return self._create_transfer_tx('0.124')
+
+	def create_transfer_tx2(self):
+		get_file_with_ext(self.asi.xmr_tx_dir,'rawtx',delete_all=True)
+		get_file_with_ext(self.asi.xmr_tx_dir,'sigtx',delete_all=True)
+		return self._create_transfer_tx('0.257')
+
+	def _sign_transfer_tx(self):
+		return self.do_sign(['--full-summary'],tx_name='Monero transaction')
+
+	def sign_transfer_tx1(self):
+		return self._sign_transfer_tx()
+
+	def sign_transfer_tx2(self):
+		return self._sign_transfer_tx()
+
+	def _xmr_autosign_op(self,op,desc,dtype=None,ext=None,wallet_arg=None,add_opts=[]):
+		data = self.users['alice']
+		args = (
+			self.extra_opts
+			+ self.autosign_opts
+			+ [f'--wallet-dir={data.udir}']
+			+ ([f'--daemon=localhost:{data.md.rpc_port}'] if not op == 'submit' else [])
+			+ add_opts
+			+ [ op ]
+			+ ([get_file_with_ext(self.asi.xmr_tx_dir,ext)] if ext else [])
+			+ ([wallet_arg] if wallet_arg else [])
+		)
+		t = self.spawn( 'mmgen-xmrwallet', args, extra_desc=f'({desc}, Alice)' )
+		if dtype:
+			t.written_to_file(dtype.capitalize())
+		return t
+
+	def submit_transfer_tx1(self):
+		return self._submit_transfer_tx( self.tx_relay_daemon_parm, ext='sigtx' )
+
+	def submit_transfer_tx2(self):
+		return self._submit_transfer_tx( self.tx_relay_daemon_proxy_parm, ext=None )
+
+	def _submit_transfer_tx(self,relay_parm,ext):
+		t = self._xmr_autosign_op(
+			op       = 'submit',
+			desc     = 'submitting TX',
+			add_opts = [f'--tx-relay-daemon={relay_parm}'],
+			ext      = ext )
+		t.expect( 'Submit transaction? (y/N): ', 'y' )
+		t.written_to_file('Submitted transaction')
+		t.ok()
+		return self.mine_chk(
+			'alice', 1, 0,
+			lambda x: 0 < x < 1.234567891234,
+			'unlocked balance 0 < 1.234567891234' )
+
+	def export_outputs(self):
+		return self._xmr_autosign_op(
+			op    = 'export-outputs',
+			desc  = 'exporting outputs',
+			dtype = 'wallet outputs',
+			wallet_arg = '1-2' )
+
+	def export_key_images(self):
+		self.tx_count = 2
+		return self.do_sign(['--full-summary'],tx_name='Monero wallet outputs file')
+
+	def import_key_images(self):
+		return self._xmr_autosign_op(
+			op    = 'import-key-images',
+			desc  = 'importing key images' )

+ 47 - 21
test/test_py_d/ts_xmrwallet.py

@@ -26,7 +26,7 @@ from subprocess import run,PIPE
 from mmgen.cfg import gc
 from mmgen.obj import MMGenRange
 from mmgen.amt import XMRAmt
-from mmgen.addrlist import KeyAddrList,AddrIdxList
+from mmgen.addrlist import ViewKeyAddrList,KeyAddrList,AddrIdxList
 from ..include.common import *
 from .common import *
 
@@ -44,10 +44,11 @@ class TestSuiteXMRWallet(TestSuiteBase):
 	color = True
 	socks_port = 49237
 	user_data = (
-		('miner', '98831F3A', 130, '1-2', []),
-		('bob',   '1378FC64', 140, None,  ['--restricted-rpc']),
-		('alice', 'FE3C6545', 150, '1-4', []),
+		('miner', '98831F3A', False, 130, '1-2', []),
+		('bob',   '1378FC64', False, 140, None,  ['--restricted-rpc']),
+		('alice', 'FE3C6545', False, 150, '1-4', []),
 	)
+	tx_relay_user = 'bob'
 
 	cmd_group = (
 		('daemon_version',            'checking daemon version'),
@@ -91,15 +92,18 @@ class TestSuiteXMRWallet(TestSuiteBase):
 		self.proto = init_proto( cfg, 'XMR', network='mainnet' )
 		self.datadir_base  = os.path.join('test','daemons','xmrtest')
 		self.extra_opts = ['--wallet-rpc-password=passw0rd']
+		self.autosign_mountpoint = os.path.join(self.tmpdir,'mmgen_autosign')
+		self.autosign_xmr_dir = os.path.join(self.tmpdir,'mmgen_autosign','xmr')
 		self.init_users()
 		self.init_daemon_args()
+		self.autosign_opts = []
 
 		for v in self.users.values():
 			run(['mkdir','-p',v.udir])
 
 		self.init_proxy()
 
-		self.tx_relay_daemon_parm = 'localhost:{}'.format( self.users['bob'].md.rpc_port )
+		self.tx_relay_daemon_parm = 'localhost:{}'.format( self.users[self.tx_relay_user].md.rpc_port )
 		self.tx_relay_daemon_proxy_parm = (
 			self.tx_relay_daemon_parm + f':127.0.0.1:{self.socks_port}' ) # must be IP, not 'localhost'
 
@@ -205,6 +209,7 @@ class TestSuiteXMRWallet(TestSuiteBase):
 		ud = namedtuple('user_data',[
 			'sid',
 			'mmwords',
+			'autosign',
 			'udir',
 			'datadir',
 			'kal_range',
@@ -220,6 +225,7 @@ class TestSuiteXMRWallet(TestSuiteBase):
 		# kal_range must be None, a single digit, or a single hyphenated range
 		for (   user,
 				sid,
+				autosign,
 				shift,
 				kal_range,
 				add_coind_args ) in self.user_data:
@@ -260,15 +266,24 @@ class TestSuiteXMRWallet(TestSuiteBase):
 				daemon          = wd,
 				test_connection = False,
 			)
+			if autosign:
+				kafile_suf = 'vkeys'
+				fn_stem    = 'MoneroWatchOnlyWallet'
+				kafile_dir = self.autosign_xmr_dir
+			else:
+				kafile_suf = 'akeys'
+				fn_stem    = 'MoneroWallet'
+				kafile_dir = udir
 			self.users[user] = ud(
 				sid           = sid,
 				mmwords       = f'test/ref/{sid}.mmwords',
+				autosign      = autosign,
 				udir          = udir,
 				datadir       = datadir,
 				kal_range     = kal_range,
-				kafile        = f'{udir}/{sid}-XMR-M[{kal_range}].akeys',
-				walletfile_fs = f'{udir}/{sid}-{{}}-MoneroWallet',
-				addrfile_fs   = f'{udir}/{sid}-{{}}-MoneroWallet.address.txt',
+				kafile        = f'{kafile_dir}/{sid}-XMR-M[{kal_range}].{kafile_suf}',
+				walletfile_fs = f'{udir}/{sid}-{{}}-{fn_stem}',
+				addrfile_fs   = f'{udir}/{sid}-{{}}-{fn_stem}.address.txt',
 				md            = md,
 				md_rpc        = md_rpc,
 				wd            = wd,
@@ -296,6 +311,8 @@ class TestSuiteXMRWallet(TestSuiteBase):
 		for user,data in self.users.items():
 			if not data.kal_range:
 				continue
+			if data.autosign:
+				continue
 			run(['mkdir','-p',data.udir])
 			run(f'rm -f {data.kafile}',shell=True)
 			t = self.spawn(
@@ -312,7 +329,7 @@ class TestSuiteXMRWallet(TestSuiteBase):
 	def create_wallets_miner(self): return self.create_wallets('miner')
 	def create_wallets_alice(self): return self.create_wallets('alice')
 
-	def create_wallets(self,user,wallet=None,add_opts=[]):
+	def create_wallets(self,user,wallet=None,add_opts=[],op='create'):
 		assert wallet is None or is_int(wallet), 'wallet arg'
 		data = self.users[user]
 		stem_glob = data.walletfile_fs.format(wallet or '*')
@@ -325,9 +342,10 @@ class TestSuiteXMRWallet(TestSuiteBase):
 			'mmgen-xmrwallet',
 			[f'--wallet-dir={data.udir}']
 			+ self.extra_opts
+			+ (self.autosign_opts if data.autosign else [])
 			+ add_opts
-			+ ['create']
-			+ [data.kafile]
+			+ [op]
+			+ ([] if data.autosign else [data.kafile])
 			+ [wallet or data.kal_range]
 		)
 		for i in MMGenRange(wallet or data.kal_range).items:
@@ -339,7 +357,7 @@ class TestSuiteXMRWallet(TestSuiteBase):
 			)
 		return t
 
-	def new_addr_alice(self,spec,cfg,expect):
+	def new_addr_alice(self,spec,cfg,expect,kafile=None):
 		data = self.users['alice']
 		t = self.spawn(
 			'mmgen-xmrwallet',
@@ -348,7 +366,7 @@ class TestSuiteXMRWallet(TestSuiteBase):
 			[f'--daemon=localhost:{data.md.rpc_port}'] +
 			(['--no-start-wallet-daemon'] if cfg in ('continue','stop') else []) +
 			(['--no-stop-wallet-daemon'] if cfg in ('start','continue') else []) +
-			[ 'new', data.kafile, spec ] )
+			['new', (kafile or data.kafile), spec] )
 		res = strip_ansi_escapes(t.read()).replace('\r','')
 		m = re.search(expect,res,re.DOTALL)
 		assert m, f'no match found for {expect!r}'
@@ -426,7 +444,7 @@ class TestSuiteXMRWallet(TestSuiteBase):
 	def list_wallets_all(self):
 		return self.sync_wallets('alice',op='list')
 
-	def sync_wallets(self,user,op='sync',wallets=None,add_opts=[]):
+	def sync_wallets(self,user,op='sync',wallets=None,add_opts=[],bal_chk_func=None):
 		data = self.users[user]
 		cmd_opts = list_gen(
 			[f'--wallet-dir={data.udir}'],
@@ -436,9 +454,10 @@ class TestSuiteXMRWallet(TestSuiteBase):
 			'mmgen-xmrwallet',
 			self.extra_opts
 			+ cmd_opts
+			+ self.autosign_opts
 			+ add_opts
 			+ [op]
-			+ [data.kafile]
+			+ ([] if data.autosign else [data.kafile])
 			+ ([wallets] if wallets else [])
 		)
 		wlist = AddrIdxList(wallets) if wallets else MMGenRange(data.kal_range).items
@@ -450,7 +469,10 @@ class TestSuiteXMRWallet(TestSuiteBase):
 			))
 			t.expect('Chain height: ')
 			t.expect('Wallet height: ')
-			t.expect('Balance: ')
+			res = t.expect_getend('Unlocked balance: ')
+			if bal_chk_func:
+				bal = XMRAmt(strip_ansi_escapes(res))
+				assert bal_chk_func(n,bal), f'balance check for wallet {n} failed!'
 		return t
 
 	def do_op(self, op, user, arg2,
@@ -464,10 +486,11 @@ class TestSuiteXMRWallet(TestSuiteBase):
 		data = self.users[user]
 		cmd_opts = list_gen(
 			[f'--wallet-dir={data.udir}'],
-			[f'--outdir={data.udir}'],
+			[f'--outdir={data.udir}', not data.autosign],
 			[f'--daemon=localhost:{data.md.rpc_port}'],
 			[f'--tx-relay-daemon={tx_relay_parm}', tx_relay_parm],
-			['--no-relay', no_relay]
+			['--no-relay', no_relay and not data.autosign],
+			[f'--autosign-mountpoint={self.autosign_mountpoint}', data.autosign],
 		)
 		add_desc = (', ' + add_desc) if add_desc else ''
 
@@ -476,10 +499,13 @@ class TestSuiteXMRWallet(TestSuiteBase):
 			self.extra_opts
 			+ cmd_opts
 			+ [op]
-			+ [data.kafile]
+			+ ([] if data.autosign else [data.kafile])
 			+ [arg2],
 			extra_desc = f'({capfirst(user)}{add_desc})' )
 
+		if op == 'sign':
+			return t
+
 		if op == 'sweep':
 			t.expect(
 				r'Create new {} .* \(y/N\): '.format(('address','account')[',' in arg2]),
@@ -491,7 +517,7 @@ class TestSuiteXMRWallet(TestSuiteBase):
 		if return_amt:
 			amt = XMRAmt(strip_ansi_escapes(t.expect_getend('Amount: ')).replace('XMR','').strip())
 
-		dtype = 'signed'
+		dtype = 'unsigned' if data.autosign else 'signed'
 		t.expect(f'Save {dtype} transaction? (y/N): ','y')
 		t.written_to_file(f'{dtype.capitalize()} transaction')
 
@@ -597,7 +623,7 @@ class TestSuiteXMRWallet(TestSuiteBase):
 	async def open_wallet_user(self,user,wnum):
 		data = self.users[user]
 		silence()
-		kal = KeyAddrList(
+		kal = (ViewKeyAddrList if data.autosign else KeyAddrList)(
 			cfg      = cfg,
 			proto    = self.proto,
 			addrfile = data.kafile,