5 Commits de1a71a5f1 ... cb99e13cd5

Author SHA1 Message Date
  The MMGen Project cb99e13cd5 XMR compat: basic transaction support 3 days ago
  The MMGen Project 6ef5f6c797 mmgen-txcreate: help screen cleanups 3 days ago
  The MMGen Project f6c09b83da cmdtest.py autosign: `wait_loop_start()`: add `opts` param 3 days ago
  The MMGen Project 78bd55b3bb CmdTestBase: new `extra_daemons` attribute 3 days ago
  The MMGen Project 24a22e63e1 whitespace, minor cleanups 3 days ago

+ 31 - 16
mmgen/autosign.py

@@ -178,17 +178,22 @@ class Signable:
 
 	class transaction(base):
 		desc = 'non-automount transaction'
+		dir_name = 'tx_dir'
 		rawext = 'rawtx'
 		sigext = 'sigtx'
-		dir_name = 'tx_dir'
 		automount = False
 
 		async def sign(self, f):
 			from .tx import UnsignedTX
 			tx1 = UnsignedTX(
-					cfg       = self.cfg,
-					filename  = f,
-					automount = self.automount)
+				cfg       = self.cfg,
+				filename  = f,
+				automount = self.automount)
+			if tx1.proto.coin == 'XMR':
+				ctx = Signable.xmr_compat_transaction(self.parent)
+				for k in ('desc', 'print_summary', 'print_bad_list'):
+					setattr(self, k, getattr(ctx, k))
+				return await ctx.sign(f, compat_call=True)
 			if tx1.proto.sign_mode == 'daemon':
 				from .rpc import rpc_init
 				tx1.rpc = await rpc_init(self.cfg, tx1.proto, ignore_wallet=True)
@@ -350,7 +355,14 @@ class Signable:
 			bmsg('\nAutosign summary:')
 			msg('\n'.join(s.get_info(indent='  ') for s in signables) + self.summary_footer)
 
-		async def sign(self, f):
+	class xmr_transaction(xmr_signable, automount_transaction):
+		desc = 'Monero non-compat transaction'
+		dir_name = 'xmr_tx_dir'
+		rawext = 'rawtx'
+		sigext = 'sigtx'
+		subext = 'subtx'
+
+		async def sign(self, f, compat_call=False):
 			from . import xmrwallet
 			from .xmrwallet.file.tx import MoneroMMGenTX
 			tx1 = MoneroMMGenTX.Completed(self.parent.xmrwallet_cfg, f)
@@ -358,23 +370,24 @@ class Signable:
 				'sign',
 				self.parent.xmrwallet_cfg,
 				infile  = str(self.parent.wallet_files[0]), # MMGen wallet file
-				wallets = str(tx1.src_wallet_idx))
+				wallets = str(tx1.src_wallet_idx),
+				compat_call = compat_call)
 			tx2 = await m.main(f, restart_daemon=self.need_daemon_restart(m, tx1.src_wallet_idx))
 			tx2.write(ask_write=False)
 			return tx2
 
-	class xmr_transaction(xmr_signable, automount_transaction):
-		dir_name = 'xmr_tx_dir'
-		desc = 'Monero non-compat transaction'
-		rawext = 'rawtx'
-		sigext = 'sigtx'
-		subext = 'subtx'
+	class xmr_compat_transaction(xmr_transaction):
+		desc = 'Monero compat transaction'
+		dir_name = 'txauto_dir'
+		rawext = 'arawtx'
+		sigext = 'asigtx'
+		subext = 'asubtx'
 
 	class xmr_wallet_outputs_file(xmr_signable, base):
 		desc = 'Monero wallet outputs file'
+		dir_name = 'xmr_outputs_dir'
 		rawext = 'raw'
 		sigext = 'sig'
-		dir_name = 'xmr_outputs_dir'
 		clean_all = True
 		summary_footer = '\n'
 
@@ -400,9 +413,9 @@ class Signable:
 
 	class message(base):
 		desc = 'message file'
+		dir_name = 'msg_dir'
 		rawext = 'rawmsg.json'
 		sigext = 'sigmsg.json'
-		dir_name = 'msg_dir'
 		fail_msg = 'failed to sign or signed incompletely'
 
 		async def sign(self, f):
@@ -553,8 +566,10 @@ class Autosign:
 			self.signables += Signable.non_xmr_signables
 
 		if self.have_xmr:
-			self.dirs |= self.xmr_dirs
-			self.signables += Signable.xmr_signables
+			self.dirs |= self.xmr_dirs | (
+				{'txauto_dir': 'txauto'} if cfg.xmrwallet_compat and self.xmr_only else {})
+			self.signables += Signable.xmr_signables + (
+				('automount_transaction',) if cfg.xmrwallet_compat and self.xmr_only else ())
 
 		for name, path in self.dirs.items():
 			setattr(self, name, self.mountpoint / path)

+ 2 - 1
mmgen/data/mmgen.cfg

@@ -153,7 +153,8 @@
 # monero_wallet_rpc_password passw0rd
 
 # Configure mmgen-xmrwallet for compatibility with mmgen-tx{create,sign,send}
-# family of commands (equivalent to mmgen-xmrwallet --compat option)
+# family of commands (equivalent to mmgen-xmrwallet --compat option).  This
+# option also enables signing of XMR compat transactions by `mmgen-autosign`.
 # xmrwallet_compat true
 
 #######################################################################

+ 1 - 1
mmgen/data/release_date

@@ -1 +1 @@
-November 2025
+December 2025

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-16.1.dev19
+16.1.dev20

+ 7 - 4
mmgen/help/help_notes.py

@@ -19,10 +19,13 @@ class help_notes:
 		self.cfg = cfg
 
 	def txcreate_args(self):
-		return (
-			'[ADDR,AMT ... | DATA_SPEC] ADDR'
-				if self.proto.base_proto == 'Bitcoin' else
-			'ADDR,AMT')
+		match self.proto.base_proto:
+			case 'Bitcoin':
+				return '[ADDR,AMT ... | DATA_SPEC] ADDR [addr file ...]'
+			case 'Monero':
+				return 'ADDR,AMT'
+			case _:
+				return 'ADDR,AMT [addr file ...]'
 
 	def swaptxcreate_args(self):
 		return 'COIN1 [AMT CHG_ADDR] COIN2 [ADDR]'

+ 16 - 2
mmgen/help/txcreate_examples.py

@@ -22,7 +22,9 @@ def help(proto, cfg):
 	addr = t.privhex2addr('bead' * 16)
 	sample_addr = addr.views[addr.view_pref]
 
-	return f"""
+	match proto.base_proto:
+		case 'Bitcoin':
+			return f"""
 EXAMPLES:
 
   Send 0.123 {proto.coin} to an external {proto.name} address, returning the change to a
@@ -52,7 +54,19 @@ EXAMPLES:
   address of specified type:
 
     $ {gc.prog_name} {mmtype}
-""" if proto.base_proto == 'Bitcoin' else f"""
+"""
+
+		case 'Monero':
+			return f"""
+EXAMPLES:
+
+  Send 0.123 {proto.coin} to an external {proto.name} address:
+
+    $ {gc.prog_name} {sample_addr},0.123
+"""
+
+		case _:
+			return f"""
 EXAMPLES:
 
   Send 0.123 {proto.coin} to an external {proto.name} address:

+ 18 - 11
mmgen/main_txcreate.py

@@ -22,7 +22,8 @@ mmgen-txcreate: Create a cryptocoin transaction with MMGen- and/or non-MMGen
 """
 
 from .cfg import gc, Config
-from .util import Msg, fmt_list, async_run
+from .util import Msg, fmt_list, fmt_dict, async_run
+from .xmrwallet import tx_priorities
 
 target = gc.prog_name.split('-')[1].removesuffix('create')
 
@@ -37,14 +38,14 @@ opts_data = {
 			'tx':     f'Create a transaction with outputs to specified coin or {gc.proj_name} addresses',
 			'swaptx': f'Create a DEX swap transaction from one {gc.proj_name} tracking wallet to another',
 		}[target],
-		'usage':   '[opts] {u_args} [addr file ...]',
+		'usage':   '[opts] {u_args}',
 		'options': """
 			-- -h, --help            Print this help message
 			-- --, --longhelp        Print help message for long (global) options
 			-- -a, --autosign        Create a transaction for offline autosigning (see
 			+                        ‘mmgen-autosign’). The removable device is mounted and
 			+                        unmounted automatically
-			r- -A, --fee-adjust=  f  Adjust transaction fee by factor ‘f’ (see below)
+			R- -A, --fee-adjust=  f  Adjust transaction fee by factor ‘f’ (see below)
 			-- -B, --no-blank        Don't blank screen before displaying {a_info}
 			-- -c, --comment-file=f  Source the transaction's comment from file 'f'
 			b- -C, --fee-estimate-confs=c Desired number of confirmations for fee estimation
@@ -53,7 +54,7 @@ opts_data = {
 			e- -D, --contract-data=D Path to file containing hex-encoded contract data
 			b- -E, --fee-estimate-mode=M Specify the network fee estimate mode.  Choices:
 			+                        {fe_all}.  Default: {fe_dfl!r}
-			r- -f, --fee=         f  Transaction fee, as a decimal {cu} amount or as
+			R- -f, --fee=         f  Transaction fee, as a decimal {cu} amount or as
 			+                        {fu} (an integer followed by {fl}).
 			+                        See FEE SPECIFICATION below.  If omitted, fee will be
 			+                        calculated using network fee estimation.
@@ -63,7 +64,7 @@ opts_data = {
 			+                        (integer).  When unset, a hardcoded default will be
 			+                        used.  Applicable only for swaps from token assets.
 			-- -i, --info            Display {a_info} and exit
-			-- -I, --inputs=      i  Specify transaction inputs (comma-separated list of
+			R- -I, --inputs=      i  Specify transaction inputs (comma-separated list of
 			+                        MMGen IDs or coin addresses).  Note that ALL unspent
 			+                        outputs associated with each address will be included.
 			bt -l, --locktime=    t  Lock time (block height or unix seconds) (default: 0)
@@ -72,6 +73,10 @@ opts_data = {
 			b- -L, --autochg-ignore-labels Ignore labels when autoselecting change addresses
 			-- -m, --minconf=     n  Minimum number of confirmations required to spend
 			+                        outputs (default: 1)
+			m- -p, --priority=N      Specify an integer priority ‘N’ for inclusion of trans-
+			+                        action in blockchain (higher number means higher fee).
+			+                        Valid parameters: {tp}.
+			+                        If option is omitted, the default priority will be used
 			-- -q, --quiet           Suppress warnings; overwrite files without prompting
 			-s -r, --stream-interval=N Set block interval for streaming swap (default: {si})
 			bt -R, --no-rbf          Make transaction non-replaceable (non-replace-by-fee
@@ -101,6 +106,7 @@ opts_data = {
 			a_info = help_notes('account_info_desc'),
 			fu     = help_notes('rel_fee_desc'),
 			fl     = help_notes('fee_spec_letters', use_quotes=True),
+			tp     = fmt_dict(tx_priorities, fmt='equal_compact'),
 			si     = help_notes('stream_interval'),
 			fe_all = fmt_list(cfg._autoset_opts['fee_estimate_mode'].choices, fmt='no_spc'),
 			fe_dfl = cfg._autoset_opts['fee_estimate_mode'].choices[0],
@@ -132,7 +138,7 @@ async def main():
 	if cfg.autosign:
 		from .tx.util import mount_removable_device
 		from .autosign import Signable
-		asi = mount_removable_device(cfg)
+		asi = mount_removable_device(cfg, add_cfg={'xmrwallet_compat': True})
 		Signable.automount_transaction(asi).check_create_ok()
 
 	if target == 'swaptx':
@@ -149,10 +155,11 @@ async def main():
 		locktime = int(cfg.locktime or 0),
 		do_info  = cfg.info)
 
-	tx2.file.write(
-		outdir                = asi.txauto_dir if cfg.autosign else None,
-		ask_write             = not cfg.yes,
-		ask_overwrite         = not cfg.yes,
-		ask_write_default_yes = False)
+	if not tx1.is_compat:
+		tx2.file.write(
+			outdir                = asi.txauto_dir if cfg.autosign else None,
+			ask_write             = not cfg.yes,
+			ask_overwrite         = not cfg.yes,
+			ask_write_default_yes = False)
 
 async_run(cfg, main)

+ 3 - 0
mmgen/main_txsend.py

@@ -131,6 +131,9 @@ async def main():
 			automount  = cfg.autosign,
 			quiet_open = True)
 
+	if tx.is_compat:
+		return await tx.compat_send()
+
 	cfg = Config({'_clone': cfg, 'proto': tx.proto, 'coin': tx.proto.coin})
 
 	if cfg.tx_proxy:

+ 4 - 3
mmgen/opts.py

@@ -308,9 +308,10 @@ 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
+			-r --monero-wallet-rpc-user=USER Monero wallet RPC username
+			-r --monero-wallet-rpc-password=USER Monero wallet RPC password
+			-r --monero-daemon=HOST:PORT Connect to the monerod at HOST:PORT
+			-r --xmrwallet-compat     Enable XMR compatibility mode
 			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

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

@@ -37,9 +37,10 @@ class mainnet(CoinProtocol.RPC, CoinProtocol.DummyWIF, CoinProtocol.Base):
 	pubkey_type    = 'monero' # required by DummyWIF
 	avg_bdi        = 120
 	privkey_len    = 32
-	mmcaps         = ('rpc',)
+	mmcaps         = ('rpc', 'tw')
 	coin_amt       = 'XMRAmt'
 	sign_mode      = 'standalone'
+	has_usr_fee    = False
 
 	coin_cfg_opts = (
 		'ignore_daemon_version',

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

@@ -25,6 +25,7 @@ from ....tw.unspent import TwUnspentOutputs
 
 class MoneroTwView:
 
+	is_account_based = True
 	item_desc = 'account'
 	nice_addr_w = {'addr': 20}
 	total = None

+ 17 - 0
mmgen/proto/xmr/tx/base.py

@@ -0,0 +1,17 @@
+#!/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.tx.base: Monero base transaction class
+"""
+
+class Base:
+	is_compat = True
+	has_comment = False

+ 27 - 0
mmgen/proto/xmr/tx/completed.py

@@ -0,0 +1,27 @@
+#!/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.tx.completed: Monero completed transaction class
+"""
+
+from ....cfg import Config
+
+from .base import Base
+
+class Completed(Base):
+
+	def __init__(self, cfg, *args, proto, filename, **kwargs):
+		self.cfg = Config({
+			'_clone':  cfg,
+			'coin':    'XMR',
+			'network': proto.network})
+		self.proto = proto
+		self.filename = filename

+ 55 - 0
mmgen/proto/xmr/tx/new.py

@@ -0,0 +1,55 @@
+#!/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.tx.new: Monero new transaction class
+"""
+
+from ....tx.new import New as TxNew
+
+from .base import Base
+
+class New(Base, TxNew):
+
+	async def get_input_addrs_from_inputs_opt(self):
+		return [] # TODO
+
+	async def set_gas(self):
+		pass
+
+	async def get_fee(self, fee, outputs_sum, start_fee_desc):
+		return True
+
+	def copy_inputs_from_tw(self, tw_unspent_data):
+		self.inputs = tw_unspent_data
+
+	def get_unspent_nums_from_user(self, accts_data):
+		from ....util import msg, is_int
+		from ....ui import line_input
+		prompt = 'Enter an account number to spend from: '
+		while True:
+			if reply := line_input(self.cfg, prompt).strip():
+				if is_int(reply) and 1 <= int(reply) <= len(accts_data):
+					return [int(reply)]
+				msg(f'Account number must be an integer between 1 and {len(accts_data)} inclusive')
+
+	async def compat_create(self):
+		from ....xmrwallet import op as xmrwallet_op
+		i = self.inputs[0]
+		o = self.outputs[0]
+		op = xmrwallet_op(
+			'transfer',
+			self.cfg,
+			None,
+			None,
+			spec = f'{i.idx}:{i.acct_idx}:{o.addr},{o.amt}',
+			compat_call = True)
+		await op.restart_wallet_daemon()
+		return await op.main()

+ 32 - 0
mmgen/proto/xmr/tx/online.py

@@ -0,0 +1,32 @@
+#!/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.tx.online: Monero online signed transaction class
+"""
+
+from .completed import Completed
+
+class OnlineSigned(Completed):
+
+	async def compat_send(self):
+		from ....xmrwallet import op as xmrwallet_op
+		op = xmrwallet_op('submit', self.cfg, self.filename, None, compat_call=True)
+		await op.restart_wallet_daemon()
+		return await op.main()
+
+class Sent(OnlineSigned):
+	pass
+
+class AutomountOnlineSigned(OnlineSigned):
+	pass
+
+class AutomountSent(AutomountOnlineSigned):
+	pass

+ 21 - 0
mmgen/proto/xmr/tx/unsigned.py

@@ -0,0 +1,21 @@
+#!/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.tx.unsigned: Monero unsigned transaction class
+"""
+
+from .completed import Completed
+
+class Unsigned(Completed):
+	pass
+
+class AutomountUnsigned(Unsigned):
+	pass

+ 1 - 0
mmgen/tw/view.py

@@ -80,6 +80,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 			def do(method, data, cw, fs, color, fmt_method):
 				return [l.rstrip() for l in method(data, cw, fs, color, fmt_method)]
 
+	is_account_based = False
 	has_age     = False
 	has_used    = False
 	has_wallet  = True

+ 2 - 0
mmgen/tx/base.py

@@ -81,6 +81,8 @@ class Base(MMGenObject):
 	signed       = False
 	is_bump      = False
 	is_swap      = False
+	is_compat    = False
+	has_comment  = True
 	swap_attrs = {
 		'swap_proto': None,
 		'swap_quote_expiry': None,

+ 7 - 0
mmgen/tx/file.py

@@ -25,6 +25,10 @@ import os, json
 from ..util import ymsg, make_chksum_6, die
 from ..obj import MMGenObject, HexStr, MMGenTxID, CoinTxID, MMGenTxComment
 
+def get_monero_proto(tx, data):
+	from ..protocol import init_proto
+	return init_proto(tx.cfg, 'XMR', network=data['MoneroMMGenTX']['data']['network'])
+
 class txdata_json_encoder(json.JSONEncoder):
 	def default(self, o):
 		if type(o).__name__.endswith('Amt'):
@@ -90,6 +94,9 @@ class MMGenTxFile(MMGenObject):
 		tx = self.tx
 		tx.file_format = 'json'
 		outer_data = json.loads(data)
+		if 'MoneroMMGenTX' in outer_data:
+			tx.proto = get_monero_proto(tx, outer_data)
+			return None
 		data = outer_data[self.data_label]
 		if outer_data['chksum'] != make_chksum_6(json_dumps(data)):
 			chk = make_chksum_6(json_dumps(data))

+ 28 - 17
mmgen/tx/new.py

@@ -245,6 +245,9 @@ class New(Base):
 					is_chg = not a.amt,
 					is_vault = a.is_vault)
 
+		if self.is_compat:
+			return
+
 		if self.chg_idx is None:
 			die(2,
 				fmt(self.msg_no_change_output.format(self.dcoin)).strip()
@@ -385,21 +388,24 @@ class New(Base):
 			in_sum - out_sum if in_sum >= out_sum else out_sum - in_sum)
 
 	async def get_inputs(self, outputs_sum):
+
+		data = self.twuo.accts_data if self.twuo.is_account_based else self.twuo.data
+
 		sel_nums = (
 			self.get_unspent_nums_from_inputs_opt if self.cfg.inputs else
 			self.get_unspent_nums_from_user
-		)(self.twuo.data)
+		)(data)
 
 		msg('Selected {}{}: {}'.format(
 			self.twuo.item_desc,
 			suf(sel_nums),
 			' '.join(str(n) for n in sel_nums)))
-		sel_unspent = MMGenList(self.twuo.data[i-1] for i in sel_nums)
+		sel_unspent = MMGenList(data[i-1] for i in sel_nums)
 
-		if not await self.precheck_sufficient_funds(
+		if not (self.is_compat or await self.precheck_sufficient_funds(
 				sum(s.amt for s in sel_unspent),
 				sel_unspent,
-				outputs_sum):
+				outputs_sum)):
 			return False
 
 		self.copy_inputs_from_tw(sel_unspent)  # makes self.inputs
@@ -453,13 +459,16 @@ class New(Base):
 			cmd_args, addrfile_args = self.get_addrfiles_from_cmdline(cmd_args)
 			if self.is_swap:
 				cmd_args = await self.process_swap_cmdline_args(cmd_args, addrfile_args)
-			from ..rpc import rpc_init
-			self.rpc = await rpc_init(self.cfg, self.proto)
-			from ..addrdata import TwAddrData
-			await self.process_cmdline_args(
-				cmd_args,
-				self.get_addrdata_from_files(self.proto, addrfile_args),
-				await TwAddrData(self.cfg, self.proto, twctl=self.twctl))
+			if self.is_compat:
+				await self.process_cmdline_args(cmd_args, None, None)
+			else:
+				from ..rpc import rpc_init
+				self.rpc = await rpc_init(self.cfg, self.proto)
+				from ..addrdata import TwAddrData
+				await self.process_cmdline_args(
+					cmd_args,
+					self.get_addrdata_from_files(self.proto, addrfile_args),
+					await TwAddrData(self.cfg, self.proto, twctl=self.twctl))
 
 		if not self.is_bump:
 			self.twuo = await TwUnspentOutputs(
@@ -507,13 +516,12 @@ class New(Base):
 					desc)) is not None:
 				break
 
-		self.check_non_mmgen_inputs(caller=caller)
+		if not self.is_compat:
+			self.check_non_mmgen_inputs(caller=caller)
+			self.update_change_output(funds_left)
+			self.check_chg_addr_is_wallet_addr()
 
-		self.update_change_output(funds_left)
-
-		self.check_chg_addr_is_wallet_addr()
-
-		if not self.cfg.yes:
+		if self.has_comment and not self.cfg.yes:
 			self.add_comment()  # edits an existing comment
 
 		if self.is_swap:
@@ -521,6 +529,9 @@ class New(Base):
 			if time.time() > self.swap_quote_refresh_time + self.swap_quote_refresh_timeout:
 				await self.update_vault_output(self.vault_output.amt)
 
+		if self.is_compat:
+			return await self.compat_create()
+
 		await self.create_serialized(locktime=locktime) # creates self.txid too
 
 		self.add_timestamp()

+ 4 - 4
mmgen/tx/util.py

@@ -12,7 +12,7 @@
 tx.util: transaction utilities
 """
 
-def get_autosign_obj(cfg):
+def get_autosign_obj(cfg, add_cfg={}):
 	from ..cfg import Config
 	from ..autosign import Autosign
 	return Autosign(
@@ -21,10 +21,10 @@ def get_autosign_obj(cfg):
 			'mountpoint': cfg.autosign_mountpoint,
 			'coins': cfg.coin,
 			# used only in online environment (xmrwallet, txcreate, txsend, txbump):
-			'online': not cfg.offline}))
+			'online': not cfg.offline} | add_cfg))
 
-def mount_removable_device(cfg):
-	asi = get_autosign_obj(cfg)
+def mount_removable_device(cfg, add_cfg={}):
+	asi = get_autosign_obj(cfg, add_cfg=add_cfg)
 	if not asi.device_inserted:
 		from ..util import die
 		die(1, 'Removable device not present!')

+ 7 - 6
mmgen/xmrwallet/__init__.py

@@ -116,16 +116,17 @@ def op_cls(op_name):
 
 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')
+		if cfg.wallet_dir and not cfg.offline:
+			die(1, '--wallet-dir cannot be specified in xmrwallet compatibility mode')
 		from ..tw.ctl import TwCtl
 		from ..cfg import Config
 		twctl_cls = cfg._proto.base_proto_subclass(TwCtl, 'tw.ctl')
 		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)})
+			'xmrwallet_compat': True} | ({} if cfg.offline else {
+				'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, compat_call))

+ 23 - 1
mmgen/xmrwallet/file/tx.py

@@ -164,7 +164,7 @@ class MoneroMMGenTX:
 				d = self.ext)
 
 			if self.cfg.autosign:
-				fn = get_autosign_obj(self.cfg).xmr_tx_dir / fn
+				fn = getattr(get_autosign_obj(self.cfg), self.tx_dir) / fn
 
 			from ...fileutil import write_data_to_file
 			write_data_to_file(
@@ -183,6 +183,7 @@ class MoneroMMGenTX:
 		is_submitting = False
 		is_complete = False
 		signed = False
+		tx_dir = 'xmr_tx_dir'
 
 		def __init__(self, *args, **kwargs):
 
@@ -245,6 +246,18 @@ class MoneroMMGenTX:
 		is_submitting = True
 		is_complete = True
 
+	class NewUnsignedCompat(NewUnsigned):
+		tx_dir = 'txauto_dir'
+		ext = 'arawtx'
+
+	class NewColdSignedCompat(NewColdSigned):
+		tx_dir = 'txauto_dir'
+		ext = 'asigtx'
+
+	class NewSubmittedCompat(NewSubmitted):
+		tx_dir = 'txauto_dir'
+		ext = 'asubtx'
+
 	class Completed(Base):
 		desc = 'transaction'
 		forbidden_fields = ()
@@ -333,3 +346,12 @@ class MoneroMMGenTX:
 
 	class View(Completed):
 		silent_load = True
+
+	class UnsignedCompat(Unsigned):
+		ext = 'arawtx'
+
+	class ColdSignedCompat(ColdSigned):
+		ext = 'asigtx'
+
+	class SubmittedCompat(Submitted):
+		ext = 'asubtx'

+ 5 - 0
mmgen/xmrwallet/ops/__init__.py

@@ -43,6 +43,7 @@ class OpBase:
 		self.cfg = cfg
 		self.uargs = uarg_tuple
 		self.compat_call = self.uargs.compat_call
+		self.tx_dir = 'txauto_dir' if self.compat_call else 'xmr_tx_dir'
 
 		classes = tuple(gen_classes())
 		self.opts = tuple(set(opt for cls in classes for opt in xmrwallet.opts))
@@ -102,6 +103,10 @@ class OpBase:
 			self.cfg.tx_relay_daemon,
 			re.ASCII)
 
+	def get_tx_cls(self, clsname):
+		from ..file.tx import MoneroMMGenTX
+		return getattr(MoneroMMGenTX, clsname + ('Compat' if self.compat_call else ''))
+
 	def display_tx_relay_info(self, *, indent=''):
 		m = self.parse_tx_relay_opt()
 		msg(fmt(f"""

+ 2 - 3
mmgen/xmrwallet/ops/sign.py

@@ -12,7 +12,6 @@
 xmrwallet.ops.sign: Monero wallet ops for the MMGen Suite
 """
 
-from ..file.tx import MoneroMMGenTX
 from ..rpc import MoneroWalletRPC
 
 from .wallet import OpWallet
@@ -24,7 +23,7 @@ class OpSign(OpWallet):
 	async def main(self, fn, *, restart_daemon=True):
 		if restart_daemon:
 			await self.restart_wallet_daemon()
-		tx = MoneroMMGenTX.Unsigned(self.cfg, fn)
+		tx = self.get_tx_cls('Unsigned')(self.cfg, fn)
 		h = MoneroWalletRPC(self, self.addr_data[0])
 		self.head_msg(tx.src_wallet_idx, h.fn)
 		if restart_daemon:
@@ -34,7 +33,7 @@ class OpSign(OpWallet):
 			unsigned_txset = tx.data.unsigned_txset,
 			export_raw = True,
 			get_tx_keys = True)
-		new_tx = MoneroMMGenTX.NewColdSigned(
+		new_tx = self.get_tx_cls('NewColdSigned')(
 			cfg            = self.cfg,
 			txid           = res['tx_hash_list'][0],
 			unsigned_txset = None,

+ 4 - 6
mmgen/xmrwallet/ops/submit.py

@@ -20,7 +20,6 @@ from ...ui import keypress_confirm
 from ...proto.xmr.daemon import MoneroWalletDaemon
 from ...proto.xmr.rpc import MoneroWalletRPCClient
 
-from ..file.tx import MoneroMMGenTX
 from ..rpc import MoneroWalletRPC
 
 from . import OpBase
@@ -45,7 +44,7 @@ class OpSubmit(OpWallet):
 		else:
 			from ...autosign import Signable
 			fn = Signable.xmr_transaction(self.asi).get_unsubmitted()
-		return MoneroMMGenTX.ColdSigned(cfg=self.cfg, fn=fn)
+		return self.get_tx_cls('ColdSigned')(cfg=self.cfg, fn=fn)
 
 	def get_relay_rpc(self):
 
@@ -100,9 +99,7 @@ class OpSubmit(OpWallet):
 			from ...util2 import format_elapsed_hr
 			msg(f'success\nRelay time: {format_elapsed_hr(t_start, rel_now=False, show_secs=True)}')
 
-		new_tx = MoneroMMGenTX.NewSubmitted(
-			cfg          = self.cfg,
-			_in_tx       = tx)
+		new_tx = self.get_tx_cls('NewSubmitted')(cfg=self.cfg, _in_tx=tx)
 
 		gmsg('\nOK')
 		new_tx.write(
@@ -120,7 +117,8 @@ class OpResubmit(OpSubmit):
 	def get_tx(self):
 		from ...autosign import Signable
 		fns = Signable.xmr_transaction(self.asi).get_submitted()
-		return sorted((MoneroMMGenTX.Submitted(self.cfg, Path(fn)) for fn in fns),
+		cls = self.get_tx_cls('Submitted')
+		return sorted((cls(self.cfg, Path(fn)) for fn in fns),
 			key = lambda x: getattr(x.data, 'submit_time', None) or x.data.create_time)[-1]
 
 class OpAbort(OpBase):

+ 6 - 6
mmgen/xmrwallet/ops/txview.py

@@ -16,7 +16,7 @@ from pathlib import Path
 
 from ...util import die
 
-from ..file.tx import MoneroMMGenTX
+from ..file.tx import MoneroMMGenTX as mtx
 
 from . import OpBase
 
@@ -33,13 +33,13 @@ class OpTxview(OpBase):
 		self.mount_removable_device()
 
 		if self.cfg.autosign:
-			files = [f for f in self.asi.xmr_tx_dir.iterdir()
-						if f.name.endswith('.'+MoneroMMGenTX.Submitted.ext)]
+			files = [f for f in getattr(self.asi, self.tx_dir).iterdir()
+						if f.name.endswith('.' + mtx.Submitted.ext)]
 		else:
 			files = self.uargs.infile
 
 		txs = sorted(
-			(MoneroMMGenTX.View(self.cfg, Path(fn)) for fn in files),
+			(mtx.View(self.cfg, Path(fn)) for fn in files),
 				# old TX files have no ‘submit_time’ field:
 				key = lambda x: getattr(x.data, 'submit_time', None) or x.data.create_time)
 
@@ -58,7 +58,7 @@ class OpTxlist(OpTxview):
 	view_method = 'get_info_oneline'
 	add_nl = True
 	footer = '\n'
-	fixed_cols_w = MoneroMMGenTX.Base.oneline_fixed_cols_w
+	fixed_cols_w = mtx.Base.oneline_fixed_cols_w
 	min_addr_w = 10
 
 	@property
@@ -67,7 +67,7 @@ class OpTxlist(OpTxview):
 
 	@property
 	def col_hdr(self):
-		return MoneroMMGenTX.View.oneline_fs.format(
+		return mtx.View.oneline_fs.format(
 			a = 'Network',
 			b = 'Seed ID',
 			c = 'Submitted' if self.cfg.autosign else 'Date',

+ 4 - 3
mmgen/xmrwallet/rpc.py

@@ -20,7 +20,7 @@ from ..util import msg, msg_r, gmsg, gmsg_r, die
 from ..addr import CoinAddr
 
 from .include import gen_acct_addr_info, XMRWalletAddrSpec
-from .file.tx import MoneroMMGenTX
+from .file.tx import MoneroMMGenTX as mtx
 
 class MoneroWalletRPC:
 
@@ -32,8 +32,9 @@ class MoneroWalletRPC:
 		self.d = d
 		self.fn = parent.get_wallet_fn(d)
 		self.new_tx_cls = (
-			MoneroMMGenTX.NewUnsigned if self.cfg.watch_only else
-			MoneroMMGenTX.NewSigned)
+			mtx.NewUnsignedCompat if self.parent.compat_call else
+			mtx.NewUnsigned if self.cfg.watch_only else
+			mtx.NewSigned)
 
 	def open_wallet(self, desc=None, *, refresh=True):
 		add_desc = desc + ' ' if desc else self.parent.add_wallet_desc

+ 1 - 0
setup.cfg

@@ -105,6 +105,7 @@ packages =
 	mmgen.proto.xchain
 	mmgen.proto.xmr
 	mmgen.proto.xmr.tw
+	mmgen.proto.xmr.tx
 	mmgen.proto.zec
 	mmgen.rpc
 	mmgen.rpc.backends

+ 7 - 8
test/cmdtest_d/autosign.py

@@ -116,8 +116,7 @@ class CmdTestAutosignBase(CmdTestBase):
 					'test_suite_xmr_autosign': self.name == 'CmdTestXMRAutosign',
 					'test_suite_autosign_threaded': self.threaded,
 					'test_suite_root_pfx': None if self.live else self.tmpdir,
-					'online': subdir == 'online',
-				}))
+					'online': subdir == 'online'}))
 
 			if create_dirs and not self.live:
 				for k in ('mountpoint', 'shm_dir', 'wallet_dir'):
@@ -182,12 +181,12 @@ class CmdTestAutosignBase(CmdTestBase):
 
 	def start_daemons(self):
 		self.spawn(msg_only=True)
-		start_test_daemons(*self.network_ids)
+		start_test_daemons(*(self.network_ids + self.extra_daemons))
 		return 'ok'
 
 	def stop_daemons(self):
 		self.spawn(msg_only=True)
-		stop_test_daemons(*self.network_ids, remove_datadir=True)
+		stop_test_daemons(*(self.network_ids + self.extra_daemons), remove_datadir=True)
 		return 'ok'
 
 	def run_setup(
@@ -547,21 +546,21 @@ class CmdTestAutosignThreaded(CmdTestAutosignBase):
 		self.remove_device_online()
 		return t
 
-	def _wait_loop_start(self, add_opts=[]):
+	def _wait_loop_start(self, opts=[], add_opts=[]):
 		t = self.spawn(
 			'mmgen-autosign',
-			self.opts + add_opts + ['--full-summary', 'wait'],
+			(opts or self.opts) + add_opts + ['--full-summary', 'wait'],
 			direct_exec      = True,
 			no_passthru_opts = True,
 			spawn_env_override = self.spawn_env | {'EXEC_WRAPPER_DO_RUNTIME_MSG': ''})
 		self.write_to_tmpfile('autosign_thread_pid', str(t.ep.pid))
 		return t
 
-	def wait_loop_start(self, add_opts=[]):
+	def wait_loop_start(self, opts=[], add_opts=[]):
 		import threading
 		threading.Thread(
 			target = self._wait_loop_start,
-			kwargs = {'add_opts': add_opts},
+			kwargs = {'opts': opts, 'add_opts': add_opts},
 			name   = 'Autosign wait loop').start()
 		time.sleep(0.1) # try to ensure test output is displayed before next test starts
 		return 'silent'

+ 1 - 0
test/cmdtest_d/base.py

@@ -41,6 +41,7 @@ class CmdTestBase:
 	skip_cmds = ()
 	test_name = None
 	is_helper = False
+	extra_daemons = []
 
 	def __init__(self, cfg, trunner, cfgs, spawn):
 		if hasattr(self, 'name'): # init may called multiple times

+ 81 - 6
test/cmdtest_d/xmr_autosign.py

@@ -17,7 +17,13 @@ import os, re, asyncio, json
 
 from mmgen.color import blue, cyan, brown
 
-from ..include.common import imsg, silence, end_silence, strip_ansi_escapes, read_from_file
+from ..include.common import (
+	imsg,
+	oqmsg,
+	silence,
+	end_silence,
+	strip_ansi_escapes,
+	read_from_file)
 from .include.common import get_file_with_ext, cleanup_env
 
 from .xmrwallet import CmdTestXMRWallet
@@ -502,6 +508,7 @@ class CmdTestXMRCompat(CmdTestXMRAutosign):
 	Monero autosigning operations (compat mode)
 	"""
 	menu_prompt = 'efresh balances:\b'
+	extra_daemons = ['ltc']
 
 	cmd_group = (
 		('autosign_setup',           'autosign setup with Alice’s seed'),
@@ -530,6 +537,17 @@ class CmdTestXMRCompat(CmdTestXMRAutosign):
 		('alice_twview2',            'viewing Alice’s tracking wallets (reload, sort options)'),
 		('alice_twview3',            'viewing Alice’s tracking wallets (check balances)'),
 		('alice_listaddresses2',     'listing Alice’s addresses (sort options)'),
+		('wait_loop_start_compat',   'starting autosign wait loop in XMR compat mode [--coins=xmr]'),
+		('alice_txcreate1',          'creating a transaction'),
+		('alice_txabort1',           'aborting the transaction'),
+		('alice_txcreate2',          'recreating the transaction'),
+		('wait_signed1',             'autosigning the transaction'),
+		('wait_loop_kill',           'stopping autosign wait loop'),
+		('alice_txabort2',           'aborting the raw and signed transactions'),
+		('alice_txcreate3',          'recreating the transaction'),
+		('wait_loop_start_ltc',      'starting autosign wait loop in XMR compat mode [--coins=ltc,xmr]'),
+		('alice_txsend1',            'sending the transaction'),
+		('wait_loop_kill',           'stopping autosign wait loop'),
 		('stop_daemons',             'stopping all wallet and coin daemons'),
 	)
 
@@ -541,11 +559,10 @@ class CmdTestXMRCompat(CmdTestXMRAutosign):
 		self.alice_dump_file = os.path.join(
 			self.alice_tw_dir,
 			'{}-2-MoneroWatchOnlyWallet.dump'.format(self.users['alice'].sid))
-		self.alice_opts = [
-			'--alice',
-			'--coin=xmr',
-			'--monero-wallet-rpc-password=passwOrd',
-			f'--monero-daemon=localhost:{self.users["alice"].md.rpc_port}']
+		self.alice_daemon_opts = [
+			f'--monero-daemon=localhost:{self.users["alice"].md.rpc_port}',
+			'--monero-wallet-rpc-password=passwOrd']
+		self.alice_opts = ['--alice', '--coin=xmr'] + self.alice_daemon_opts
 
 	def create_watchonly_wallets(self):
 		return self._create_wallets()
@@ -655,3 +672,61 @@ class CmdTestXMRCompat(CmdTestXMRAutosign):
 				assert s in text
 		self.remove_device_online()
 		return t
+
+	def wait_loop_start_compat(self):
+		return self.wait_loop_start(opts=['--xmrwallet-compat', '--coins=xmr'])
+
+	def wait_loop_start_ltc(self):
+		return self.wait_loop_start(opts=['--xmrwallet-compat', '--coins=ltc,xmr'])
+
+	def alice_txcreate1(self):
+		return self._alice_txops('txcreate', [f'{self.burn_addr},0.012345'], acct_num=1)
+
+	alice_txcreate3 = alice_txcreate2 = alice_txcreate1
+
+	def alice_txabort1(self):
+		return self._alice_txops('txsend', opts=['--alice', '--abort'])
+
+	alice_txabort2 = alice_txabort1
+
+	def alice_txsend1(self):
+		return self._alice_txops(
+			'txsend',
+			opts        = ['--alice', '--quiet'],
+			add_opts    = self.alice_daemon_opts,
+			acct_num    = 1,
+			wait_signed = True)
+
+	def wait_signed1(self):
+		self.spawn(msg_only=True)
+		oqmsg('')
+		self._wait_signed('transaction')
+		return 'silent'
+
+	def _alice_txops(
+			self,
+			op,
+			args = [],
+			*,
+			opts = [],
+			add_opts = [],
+			menu = '',
+			acct_num = None,
+			wait_signed = False,
+			signable_desc = 'transaction'):
+		if wait_signed:
+			self._wait_signed(signable_desc)
+		self.insert_device_online()
+		t = self.spawn(f'mmgen-{op}', (opts or self.alice_opts) + self.autosign_opts + add_opts + args)
+		if '--abort' in opts:
+			t.expect('(y/N): ', 'y')
+		elif op == 'txcreate':
+			for ch in menu + 'q':
+				t.expect(self.menu_prompt, ch)
+			t.expect('to spend from: ', f'{acct_num}\n')
+			t.expect('(y/N): ', 'y') # save?
+		elif op == 'txsend':
+			t.expect('(y/N): ', 'y') # view?
+		t.read() # required!
+		self.remove_device_online()
+		return t

+ 8 - 2
test/cmdtest_d/xmrwallet.py

@@ -24,7 +24,7 @@ import sys, os, time, re, atexit, asyncio, shutil
 from subprocess import run, PIPE
 from collections import namedtuple
 
-from mmgen.util import capfirst, is_int, die, list_gen
+from mmgen.util import capfirst, is_int, die, suf, list_gen
 from mmgen.obj import MMGenRange
 from mmgen.amt import XMRAmt
 from mmgen.addrlist import ViewKeyAddrList, KeyAddrList, AddrIdxList
@@ -39,6 +39,8 @@ from ..include.common import (
 	read_from_file,
 	silence,
 	end_silence,
+	start_test_daemons,
+	stop_test_daemons,
 	strip_ansi_escapes
 )
 from .include.common import get_file_with_ext
@@ -49,6 +51,8 @@ from .base import CmdTestBase
 def stop_daemons(self):
 	for v in self.users.values():
 		v.md.stop()
+	if self.extra_daemons:
+		stop_test_daemons(*self.extra_daemons, remove_datadir=True, verbose=True)
 
 def stop_miner_wallet_daemon(self):
 	asyncio.run(self.users['miner'].wd_rpc.stop_daemon())
@@ -672,7 +676,7 @@ class CmdTestXMRWallet(CmdTestBase):
 	async def mine(self, nblks):
 		start_height = height = await self._get_height()
 		imsg(f'Height: {height}')
-		imsg_r(f'Mining {nblks} blocks...')
+		imsg_r(f'Mining {nblks} block{suf(nblks)}...')
 		await self.start_mining()
 		while height < start_height + nblks:
 			await asyncio.sleep(2)
@@ -843,6 +847,8 @@ class CmdTestXMRWallet(CmdTestBase):
 		for v in self.users.values():
 			run(['mkdir', '-p', v.daemon_datadir])
 			v.md.start()
+		if self.extra_daemons:
+			start_test_daemons(*self.extra_daemons, verbose=True)
 
 	def stop_daemons(self):
 		self.spawn(msg_only=True)

+ 9 - 9
test/include/common.py

@@ -288,23 +288,23 @@ def end_msg(t):
 		('' if cfg.test_suite_deterministic else f', elapsed time: {t//60:02d}:{t%60:02d}')
 	))
 
-def start_test_daemons(*network_ids, remove_datadir=False):
+def start_test_daemons(*network_ids, remove_datadir=False, verbose=False):
 	if not cfg.no_daemon_autostart:
-		return test_daemons_ops(*network_ids, op='start', remove_datadir=remove_datadir)
+		return test_daemons_ops(*network_ids, op='start', remove_datadir=remove_datadir, verbose=verbose)
 
-def stop_test_daemons(*network_ids, force=False, remove_datadir=False):
+def stop_test_daemons(*network_ids, force=False, remove_datadir=False, verbose=False):
 	if force or not cfg.no_daemon_stop:
-		return test_daemons_ops(*network_ids, op='stop', remove_datadir=remove_datadir)
+		return test_daemons_ops(*network_ids, op='stop', remove_datadir=remove_datadir, verbose=verbose)
 
-def restart_test_daemons(*network_ids, remove_datadir=False):
-	if not stop_test_daemons(*network_ids, remove_datadir=remove_datadir):
+def restart_test_daemons(*network_ids, remove_datadir=False, verbose=False):
+	if not stop_test_daemons(*network_ids, remove_datadir=remove_datadir, verbose=verbose):
 		return False
-	return start_test_daemons(*network_ids, remove_datadir=remove_datadir)
+	return start_test_daemons(*network_ids, remove_datadir=remove_datadir, verbose=verbose)
 
-def test_daemons_ops(*network_ids, op, remove_datadir=False):
+def test_daemons_ops(*network_ids, op, remove_datadir=False, verbose=False):
 	if not cfg.no_daemon_autostart:
 		from mmgen.daemon import CoinDaemon
-		silent = not (cfg.verbose or cfg.exact_output)
+		silent = not (verbose or cfg.verbose or cfg.exact_output)
 		ret = False
 		for network_id in network_ids:
 			d = CoinDaemon(cfg, network_id=network_id, test_suite=True)