20 Commits 8fd463ecfe ... be025dc817

Author SHA1 Message Date
  The MMGen Project be025dc817 tx.base: add `nondata_outputs` property 10 months ago
  The MMGen Project 92fc9fd462 fixes and cleanups throughout 10 months ago
  The MMGen Project 8311763e94 tx.sign: cleanups 10 months ago
  The MMGen Project 67e1688aa1 cfg: support `usage()` on bad invocation for txcreate 10 months ago
  The MMGen Project 0f7e51e499 tx.new: add `is_swap` attribute 10 months ago
  The MMGen Project 9a963e1488 txcreate, txdo: delay RPC initialization 10 months ago
  The MMGen Project 0b833dadea minor fixes and cleanups 10 months ago
  The MMGen Project 6df695024e tx.new: prompt user if change address is not wallet address 10 months ago
  The MMGen Project e4181e0fb0 tx.new: load twuo after parsing outputs 10 months ago
  The MMGen Project 9631433072 tx.new: get_autochg_addr(): add `desc` param 10 months ago
  The MMGen Project fc5ec2bc88 tx.new: make get_autochg_addr() a method 10 months ago
  The MMGen Project 9223b0e97b tx.new: new method: get_addrfiles_from_cmdline() 10 months ago
  The MMGen Project 40de553ea5 tx.new: add hooks for multi-proto support 10 months ago
  The MMGen Project 9742f5f194 cmdtest.py regtest, swap: add hooks for multi-proto support 10 months ago
  The MMGen Project 487cbfcc0d minor cleanups, variable & method renames 10 months ago
  The MMGen Project e4b6d0536c swaptxcreate, swaptxdo: create entry points, NewSwap tx class 10 months ago
  The MMGen Project dc028988cb cfg, opts: improve contextual options handling 10 months ago
  The MMGen Project 8b6c24cc07 tx.new + subclasses: method & import renames, refactor, cleanups 10 months ago
  The MMGen Project dc30edba48 minor fixes and cleanups 10 months ago
  The MMGen Project 8adbda8f08 update Github workflows for Python 3.13 10 months ago
50 changed files with 637 additions and 367 deletions
  1. 1 1
      .github/workflows/build.yaml
  2. 1 1
      .github/workflows/ruff.yaml
  3. 16 0
      cmds/mmgen-swaptxcreate
  4. 16 0
      cmds/mmgen-swaptxdo
  5. 2 2
      mmgen/addr.py
  6. 0 3
      mmgen/autosign.py
  7. 27 23
      mmgen/cfg.py
  8. 1 1
      mmgen/data/version
  9. 8 8
      mmgen/help/__init__.py
  10. 3 1
      mmgen/help/help_notes.py
  11. 1 1
      mmgen/main.py
  12. 23 11
      mmgen/main_txcreate.py
  13. 22 13
      mmgen/main_txdo.py
  14. 1 1
      mmgen/main_txsend.py
  15. 26 16
      mmgen/opts.py
  16. 8 7
      mmgen/proto/btc/tw/addresses.py
  17. 10 6
      mmgen/proto/btc/tx/base.py
  18. 1 1
      mmgen/proto/btc/tx/completed.py
  19. 10 4
      mmgen/proto/btc/tx/info.py
  20. 17 6
      mmgen/proto/btc/tx/new.py
  21. 23 0
      mmgen/proto/btc/tx/new_swap.py
  22. 1 1
      mmgen/proto/btc/tx/online.py
  23. 4 0
      mmgen/proto/eth/tx/base.py
  24. 1 1
      mmgen/proto/eth/tx/info.py
  25. 8 5
      mmgen/proto/eth/tx/new.py
  26. 1 1
      mmgen/proto/eth/tx/online.py
  27. 6 6
      mmgen/tw/addresses.py
  28. 4 0
      mmgen/tx/__init__.py
  29. 9 10
      mmgen/tx/base.py
  30. 2 2
      mmgen/tx/bump.py
  31. 2 3
      mmgen/tx/file.py
  32. 10 5
      mmgen/tx/info.py
  33. 102 79
      mmgen/tx/new.py
  34. 22 0
      mmgen/tx/new_swap.py
  35. 40 30
      mmgen/tx/sign.py
  36. 1 1
      mmgen/util.py
  37. 2 2
      mmgen/util2.py
  38. 2 0
      setup.cfg
  39. 1 1
      test/cmdtest.py
  40. 1 10
      test/cmdtest_d/ct_autosign.py
  41. 19 2
      test/cmdtest_d/ct_base.py
  42. 1 1
      test/cmdtest_d/ct_ethdev.py
  43. 40 31
      test/cmdtest_d/ct_help.py
  44. 69 54
      test/cmdtest_d/ct_regtest.py
  45. 3 0
      test/cmdtest_d/ct_shared.py
  46. 57 12
      test/cmdtest_d/ct_swap.py
  47. 1 1
      test/daemontest_d/ut_tx.py
  48. 8 0
      test/include/common.py
  49. 2 2
      test/overlay/fakemods/mmgen/tx/new.py
  50. 1 1
      test/test-release.sh

+ 1 - 1
.github/workflows/build.yaml

@@ -18,7 +18,7 @@ jobs:
 
     strategy:
       matrix:
-        python-version: ["3.9","3.10","3.11","3.12"]
+        python-version: ["3.9", "3.11", "3.12", "3.13"]
 
     steps:
     - uses: actions/checkout@v4

+ 1 - 1
.github/workflows/ruff.yaml

@@ -18,7 +18,7 @@ jobs:
 
     strategy:
       matrix:
-        python-version: ["3.9", "3.11", "3.12"]
+        python-version: ["3.9", "3.11", "3.12", "3.13"]
 
     steps:
     - uses: actions/checkout@v4

+ 16 - 0
cmds/mmgen-swaptxcreate

@@ -0,0 +1,16 @@
+#!/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
+
+"""
+mmgen-swaptxcreate: Create an unsigned DEX swap transaction with MMGen or non-MMGen inputs
+"""
+
+from mmgen.main import launch
+launch(mod='txcreate')

+ 16 - 0
cmds/mmgen-swaptxdo

@@ -0,0 +1,16 @@
+#!/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
+
+"""
+mmgen-swaptxdo: Create, sign and broadcast a DEX swap transaction
+"""
+
+from mmgen.main import launch
+launch(mod='txdo')

+ 2 - 2
mmgen/addr.py

@@ -117,10 +117,10 @@ class AddrListID(HiliteStr, InitErrors, MMGenObject):
 			me.mmtype = mmtype
 			return me
 		except Exception as e:
-			return cls.init_fail(e, f'sid={sid}, mmtype={mmtype}')
+			return cls.init_fail(e, f'sid={sid}, mmtype={mmtype}, id_str={id_str}')
 
 def is_addrlist_id(proto, s):
-	return get_obj(AddrListID, proto=proto, id_str=s, silent=False, return_bool=True)
+	return get_obj(AddrListID, proto=proto, id_str=s, silent=True, return_bool=True)
 
 class MMGenID(HiliteStr, InitErrors, MMGenObject):
 	color = 'orange'

+ 0 - 3
mmgen/autosign.py

@@ -516,9 +516,6 @@ class Autosign:
 		if any(k in cfg._uopts for k in ('help', 'longhelp')):
 			return
 
-		if 'coin' in cfg._uopts:
-			die(1, '--coin option not supported with this command.  Use --coins instead')
-
 		self.coins = cfg.coins.upper().split(',') if cfg.coins else []
 
 		if cfg.xmrwallets and not 'XMR' in self.coins:

+ 27 - 23
mmgen/cfg.py

@@ -56,29 +56,32 @@ class GlobalConstants(Lockable):
 	btc_fork_rpc_coins = ('btc', 'bch', 'ltc')
 	eth_fork_coins = ('eth', 'etc')
 
-	_cc = namedtuple('cmd_cap', ['proto', 'rpc', 'coin', 'caps', 'platforms'])
+	# ‘use_coin_opt’ must be False if ‘coin_codes’ is set
+	_cc = namedtuple('cmd_cap', ['proto', 'rpc', 'use_coin_opt', 'coin_codes', 'caps', 'platforms'])
 	cmd_caps_data = {
-		'addrgen':      _cc(True,  False, None,  [],      'lmw'),
-		'addrimport':   _cc(True,  True,  'R',   ['tw'],  'lmw'),
-		'autosign':     _cc(True,  True,  'r',   ['rpc'], 'lm'),
-		'keygen':       _cc(True,  False, None,  [],      'lmw'),
-		'msg':          _cc(True,  True,  'R',   ['msg'], 'lmw'),
-		'passchg':      _cc(False, False, None,  [],      'lmw'),
-		'passgen':      _cc(False, False, None,  [],      'lmw'),
-		'regtest':      _cc(True,  True,  'b',   ['tw'],  'lmw'),
-		'seedjoin':     _cc(False, False, None,  [],      'lmw'),
-		'seedsplit':    _cc(False, False, None,  [],      'lmw'),
-		'subwalletgen': _cc(False, False, None,  [],      'lmw'),
-		'tool':         _cc(True,  True,  None,  [],      'lmw'),
-		'txbump':       _cc(True,  True,  'R',   ['tw'],  'lmw'),
-		'txcreate':     _cc(True,  True,  'R',   ['tw'],  'lmw'),
-		'txdo':         _cc(True,  True,  'R',   ['tw'],  'lmw'),
-		'txsend':       _cc(True,  True,  'R',   ['tw'],  'lmw'),
-		'txsign':       _cc(True,  True,  'R',   ['tw'],  'lmw'),
-		'walletchk':    _cc(False, False, None,  [],      'lmw'),
-		'walletconv':   _cc(False, False, None,  [],      'lmw'),
-		'walletgen':    _cc(False, False, None,  [],      'lmw'),
-		'xmrwallet':    _cc(True,  True,  'xmr', ['rpc'], 'lmw'),
+		'addrgen':      _cc(True,  False, True,  None,    [],      'lmw'),
+		'addrimport':   _cc(True,  True,  True,  None,    ['tw'],  'lmw'),
+		'autosign':     _cc(True,  True,  False, '-rRb',  ['rpc'], 'lm'),
+		'keygen':       _cc(True,  False, True,  None,    [],      'lmw'),
+		'msg':          _cc(True,  True,  True,  None,    ['msg'], 'lmw'),
+		'passchg':      _cc(False, False, False, None,    [],      'lmw'),
+		'passgen':      _cc(False, False, False, None,    [],      'lmw'),
+		'regtest':      _cc(True,  True,  True,  None,    ['tw'],  'lmw'),
+		'seedjoin':     _cc(False, False, False, None,    [],      'lmw'),
+		'seedsplit':    _cc(False, False, False, None,    [],      'lmw'),
+		'subwalletgen': _cc(False, False, False, None,    [],      'lmw'),
+		'swaptxcreate': _cc(True,  True,  False, '-rRb',  ['tw'],  'lmw'),
+		'swaptxdo':     _cc(True,  True,  False, '-rRb',  ['tw'],  'lmw'),
+		'tool':         _cc(True,  True,  True,  None,    [],      'lmw'),
+		'txbump':       _cc(True,  True,  True,  None,    ['tw'],  'lmw'),
+		'txcreate':     _cc(True,  True,  True,  None,    ['tw'],  'lmw'),
+		'txdo':         _cc(True,  True,  True,  None,    ['tw'],  'lmw'),
+		'txsend':       _cc(True,  True,  True,  None,    ['tw'],  'lmw'),
+		'txsign':       _cc(True,  True,  True,  None,    ['tw'],  'lmw'),
+		'walletchk':    _cc(False, False, False, None,    [],      'lmw'),
+		'walletconv':   _cc(False, False, False, None,    [],      'lmw'),
+		'walletgen':    _cc(False, False, False, None,    [],      'lmw'),
+		'xmrwallet':    _cc(True,  True,  False, '-r',    ['rpc'], 'lmw'),
 	}
 
 	prog_name = os.path.basename(sys.argv[0])
@@ -373,6 +376,7 @@ class Config(Lockable):
 	_autoset_opts = {
 		'fee_estimate_mode': _ov('nocase_pfx', ['conservative', 'economical']),
 		'rpc_backend':       _ov('nocase_pfx', ['auto', 'httplib', 'curl', 'aiohttp', 'requests']),
+		'swap_proto':        _ov('nocase_pfx', ['thorchain']),
 	}
 
 	_auto_typeset_opts = {
@@ -475,7 +479,7 @@ class Config(Lockable):
 			'_data_dir_root_override',
 			self._uopts.pop('data_dir', None))
 
-		if parse_only and not any(k in self._uopts for k in ['help', 'longhelp']):
+		if parse_only and not any(k in self._uopts for k in ['help', 'longhelp', 'usage']):
 			return
 
 		# Step 2: set cfg from user-supplied data, skipping auto opts; set type from corresponding

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-15.1.dev14
+15.1.dev15

+ 8 - 8
mmgen/help/__init__.py

@@ -46,10 +46,10 @@ def show_hash_presets(cfg):
 
 def gen_arg_tuple(cfg, func, text):
 
-	def help_notes(k):
+	def help_notes(k, *args, **kwargs):
 		import importlib
 		return getattr(importlib.import_module(
-			f'{cfg._opts.help_pkg}.help_notes').help_notes(proto, cfg), k)()
+			f'{cfg._help_pkg}.help_notes').help_notes(proto, cfg), k)(*args, **kwargs)
 
 	def help_mod(modname):
 		import importlib
@@ -140,14 +140,14 @@ class CmdHelp_v2(CmdHelp_v1):
 	def gen_text(self, opts):
 		from ..opts import cmd_opts_v2_help_pat
 		skipping = False
-		coin_filter_codes = opts.global_filter_codes.coin
-		cmd_filter_codes = opts.opts_data['filter_codes']
+		coin_codes = opts.global_filter_codes.coin
+		cmd_codes = opts.opts_data['filter_codes']
 		for line in opts.opts_data['text']['options'][1:].rstrip().splitlines():
 			m = cmd_opts_v2_help_pat.match(line)
 			if m[1] == '+':
 				if not skipping:
 					yield line[6:]
-			elif m[1] in coin_filter_codes and m[2] in cmd_filter_codes:
+			elif (coin_codes is None or m[1] in coin_codes) and m[2] in cmd_codes:
 				yield '{} --{} {}'.format(
 					(f'-{m[3]},', '   ')[m[3] == '-'],
 					m[4],
@@ -165,14 +165,14 @@ class GlobalHelp(Help):
 	def gen_text(self, opts):
 		from ..opts import global_opts_help_pat
 		skipping = False
-		coin_filter_codes = opts.global_filter_codes.coin
-		cmd_filter_codes = opts.global_filter_codes.cmd
+		coin_codes = opts.global_filter_codes.coin
+		cmd_codes = opts.global_filter_codes.cmd
 		for line in opts.global_opts_data['text']['options'][1:].rstrip().splitlines():
 			m = global_opts_help_pat.match(line)
 			if m[1] == '+':
 				if not skipping:
 					yield line[4:]
-			elif m[1] in coin_filter_codes and m[2] in cmd_filter_codes:
+			elif (coin_codes is None or m[1] in coin_codes) and (cmd_codes is None or m[2] in cmd_codes):
 				yield '  --{} {}'.format(m[3], m[5]) if m[3] else m[5]
 				skipping = False
 			else:

+ 3 - 1
mmgen/help/help_notes.py

@@ -20,8 +20,10 @@ class help_notes:
 		self.proto = proto
 		self.cfg = cfg
 
-	def txcreate_args(self):
+	def txcreate_args(self, target):
 		return (
+			'COIN1 [AMT CHG_ADDR] COIN2 [ADDR]'
+				if target == 'swaptx' else
 			'[ADDR,AMT ... | DATA_SPEC] ADDR <change addr, addrlist ID or addr type>'
 				if self.proto.base_proto == 'Bitcoin' else
 			'ADDR,AMT')

+ 1 - 1
mmgen/main.py

@@ -56,7 +56,7 @@ def launch(*, mod=None, func=None, fqmod=None, package='mmgen'):
 			2:   _o(yellow,  2, '{message}'),
 			3:   _o(yellow,  3, '\nMMGen Error ({name}):\n{message}'),
 			4:   _o(red,     4, '\nMMGen Fatal Error ({name}):\n{message}'),
-			'x': _o(yellow,  5, '\nMMGen Unhandled Exception ({name}): {e}'),
+			'x': _o(yellow,  5, '\nMMGen Python Exception ({name}): {e}'),
 		}[getattr(e, 'mmcode', 'x')]
 
 		(sys.stdout if getattr(e, 'stdout', None) else sys.stderr).write(

+ 23 - 11
mmgen/main_txcreate.py

@@ -24,11 +24,19 @@ mmgen-txcreate: Create a cryptocoin transaction with MMGen- and/or non-MMGen
 from .cfg import gc, Config
 from .util import fmt_list, async_run
 
+target = gc.prog_name.split('-')[1].removesuffix('create')
+
 opts_data = {
-	'filter_codes': ['-'],
+	'filter_codes': {
+		'tx':     ['-', 't'],
+		'swaptx': ['-', 's'],
+	}[target],
 	'sets': [('yes', True, 'quiet', True)],
 	'text': {
-		'desc': f'Create a transaction with outputs to specified coin or {gc.proj_name} addresses',
+		'desc': {
+			'tx':     f'Create a transaction with outputs to specified coin or {gc.proj_name} addresses',
+			'swaptx': f'Create a DEX swap transaction with {gc.proj_name} inputs and outputs',
+		}[target],
 		'usage':   '[opts] {u_args} [addr file ...]',
 		'options': """
 			-- -h, --help            Print this help message
@@ -54,13 +62,15 @@ opts_data = {
 			-- -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.
-			b- -l, --locktime=    t  Lock time (block height or unix seconds) (default: 0)
+			bt -l, --locktime=    t  Lock time (block height or unix seconds) (default: 0)
 			b- -L, --autochg-ignore-labels Ignore labels when autoselecting change addresses
 			-- -m, --minconf=     n  Minimum number of confirmations required to spend
 			+                        outputs (default: 1)
 			-- -q, --quiet           Suppress warnings; overwrite files without prompting
-			b- -R, --no-rbf          Make transaction non-replaceable (non-replace-by-fee
+			bt -R, --no-rbf          Make transaction non-replaceable (non-replace-by-fee
 			+                        according to BIP 125)
+			-s -s, --swap-proto      Swap protocol to use (Default: {x_dfl},
+			+                        Choices: {x_all})
 			-- -v, --verbose         Produce more verbose output
 			b- -V, --vsize-adj=   f  Adjust transaction's estimated vsize by factor 'f'
 			-- -y, --yes             Answer 'yes' to prompts, suppress non-essential output
@@ -70,15 +80,17 @@ opts_data = {
 	},
 	'code': {
 		'usage': lambda cfg, proto, help_notes, s: s.format(
-			u_args = help_notes('txcreate_args')),
+			u_args = help_notes('txcreate_args', target)),
 		'options': lambda cfg, proto, help_notes, s: s.format(
+			cfg    = cfg,
+			cu     = proto.coin,
 			a_info = help_notes('account_info_desc'),
 			fu     = help_notes('rel_fee_desc'),
 			fl     = help_notes('fee_spec_letters'),
 			fe_all = fmt_list(cfg._autoset_opts['fee_estimate_mode'].choices, fmt='no_spc'),
 			fe_dfl = cfg._autoset_opts['fee_estimate_mode'].choices[0],
-			cu     = proto.coin,
-			cfg    = cfg),
+			x_all = fmt_list(cfg._autoset_opts['swap_proto'].choices, fmt='no_spc'),
+			x_dfl = cfg._autoset_opts['swap_proto'].choices[0]),
 		'notes': lambda cfg, help_notes, s: s.format(
 			c      = help_notes('txcreate'),
 			F      = help_notes('fee'),
@@ -89,6 +101,9 @@ opts_data = {
 
 cfg = Config(opts_data=opts_data)
 
+if not (cfg.info or cfg.contract_data) and len(cfg._args) < {'tx': 1, 'swaptx': 2}[target]:
+	cfg._usage()
+
 async def main():
 
 	if cfg.autosign:
@@ -98,10 +113,7 @@ async def main():
 		Signable.automount_transaction(asi).check_create_ok()
 
 	from .tx import NewTX
-	tx1 = await NewTX(cfg=cfg, proto=cfg._proto)
-
-	from .rpc import rpc_init
-	tx1.rpc = await rpc_init(cfg)
+	tx1 = await NewTX(cfg=cfg, proto=cfg._proto, target=target)
 
 	tx2 = await tx1.create(
 		cmd_args = cfg._args,

+ 22 - 13
mmgen/main_txdo.py

@@ -24,11 +24,19 @@ from .cfg import gc, Config
 from .util import die, fmt_list, async_run
 from .subseed import SubSeedIdxRange
 
+target = gc.prog_name.split('-')[1].removesuffix('do')
+
 opts_data = {
-	'filter_codes': ['-'],
+	'filter_codes': {
+		'tx':     ['-', 't'],
+		'swaptx': ['-', 's'],
+	}[target],
 	'sets': [('yes', True, 'quiet', True)],
 	'text': {
-		'desc': f'Create, sign and send an {gc.proj_name} transaction',
+		'desc': {
+			'tx':     f'Create, sign and send an {gc.proj_name} transaction',
+			'swaptx': f'Create, sign and send a DEX swap transaction with {gc.proj_name} inputs and outputs',
+		}[target],
 		'usage':   '[opts] {u_args} [addr file ...] [seed source ...]',
 		'options': """
 			-- -h, --help             Print this help message
@@ -62,7 +70,7 @@ opts_data = {
 			-- -k, --keys-from-file=f Provide additional keys for non-{pnm} addresses
 			-- -K, --keygen-backend=n Use backend 'n' for public key generation.  Options
 			+                         for {coin_id}: {kgs}
-			b- -l, --locktime=      t Lock time (block height or unix seconds) (default: 0)
+			bt -l, --locktime=      t Lock time (block height or unix seconds) (default: 0)
 			b- -L, --autochg-ignore-labels Ignore labels when autoselecting change addresses
 			-- -m, --minconf=n        Minimum number of confirmations required to spend
 			+                         outputs (default: 1)
@@ -75,9 +83,11 @@ opts_data = {
 			-- -p, --hash-preset=   p Use the scrypt hash parameters defined by preset 'p'
 			+                         for password hashing (default: '{gc.dfl_hash_preset}')
 			-- -P, --passwd-file=   f Get {pnm} wallet passphrase from file 'f'
-			b- -R, --no-rbf           Make transaction non-replaceable (non-replace-by-fee
-			+                         according to BIP 125)
 			-- -q, --quiet            Suppress warnings; overwrite files without prompting
+			bt -R, --no-rbf           Make transaction non-replaceable (non-replace-by-fee
+			+                         according to BIP 125)
+			-s -s, --swap-proto       Swap protocol to use (Default: {x_dfl},
+			+                         Choices: {x_all})
 			-- -u, --subseeds=      n The number of subseed pairs to scan for (default: {ss},
 			+                         maximum: {ss_max}). Only the default or first supplied
 			+                         wallet is scanned for subseeds.
@@ -103,10 +113,11 @@ column below:
 	},
 	'code': {
 		'usage': lambda cfg, proto, help_notes, s: s.format(
-			u_args  = help_notes('txcreate_args')),
+			u_args  = help_notes('txcreate_args', target)),
 		'options': lambda cfg, proto, help_notes, s: s.format(
 			gc      = gc,
 			cfg     = cfg,
+			cu      = proto.coin,
 			pnm     = gc.proj_name,
 			pnl     = gc.proj_name.lower(),
 			a_info  = help_notes('account_info_desc'),
@@ -114,12 +125,13 @@ column below:
 			coin_id = help_notes('coin_id'),
 			fu      = help_notes('rel_fee_desc'),
 			fl      = help_notes('fee_spec_letters'),
+			dsl     = help_notes('dfl_seed_len'),
 			ss      = help_notes('dfl_subseeds'),
 			ss_max  = SubSeedIdxRange.max_idx,
 			fe_all  = fmt_list(cfg._autoset_opts['fee_estimate_mode'].choices, fmt='no_spc'),
 			fe_dfl  = cfg._autoset_opts['fee_estimate_mode'].choices[0],
-			dsl     = help_notes('dfl_seed_len'),
-			cu      = proto.coin),
+			x_all   = fmt_list(cfg._autoset_opts['swap_proto'].choices, fmt='no_spc'),
+			x_dfl   = cfg._autoset_opts['swap_proto'].choices[0]),
 		'notes': lambda cfg, help_notes, s: s.format(
 			c       = help_notes('txcreate'),
 			F       = help_notes('fee'),
@@ -139,10 +151,7 @@ seed_files = get_seed_files(cfg, cfg._args)
 
 async def main():
 
-	tx1 = await NewTX(cfg=cfg, proto=cfg._proto)
-
-	from .rpc import rpc_init
-	tx1.rpc = await rpc_init(cfg)
+	tx1 = await NewTX(cfg=cfg, proto=cfg._proto, target=target)
 
 	tx2 = await tx1.create(
 		cmd_args = cfg._args,
@@ -159,7 +168,7 @@ async def main():
 		tx4 = await SentTX(cfg=cfg, data=tx3.__dict__)
 		if await tx4.send():
 			tx4.file.write(ask_overwrite=False, ask_write=False)
-			tx4.print_contract_addr()
+			tx4.post_write()
 	else:
 		die(2, 'Transaction could not be signed')
 

+ 1 - 1
mmgen/main_txsend.py

@@ -120,6 +120,6 @@ async def main():
 			outdir        = asi.txauto_dir if cfg.autosign else None,
 			ask_overwrite = False,
 			ask_write     = False)
-		tx2.print_contract_addr()
+		tx2.post_write()
 
 async_run(main())

+ 26 - 16
mmgen/opts.py

@@ -171,17 +171,22 @@ def parse_opts(cfg, opts_data, global_opts_data, global_filter_codes, need_proto
 
 	def parse_v2():
 		cmd_filter_codes = opts_data['filter_codes']
+		coin_codes = global_filter_codes.coin
 		for line in opts_data['text']['options'].splitlines():
 			m = cmd_opts_v2_pat.match(line)
-			if m and m[1] in global_filter_codes.coin and m[2] in cmd_filter_codes:
+			if m and (coin_codes is None or m[1] in coin_codes) and m[2] in cmd_filter_codes:
 				ret = opt_tuple(m[4].replace('-', '_'), m[5] == '=')
 				yield (m[3], ret)
 				yield (m[4], ret)
 
 	def parse_global():
+		coin_codes = global_filter_codes.coin
+		cmd_codes = global_filter_codes.cmd
 		for line in global_opts_data['text']['options'].splitlines():
 			m = global_opts_pat.match(line)
-			if m and m[1] in global_filter_codes.coin and m[2] in global_filter_codes.cmd:
+			if m and (
+					(coin_codes is None or m[1] in coin_codes) and
+					(cmd_codes is None or m[2] in cmd_codes)):
 				yield (m[3], opt_tuple(m[3].replace('-', '_'), m[4] == '='))
 
 	opts = tuple((parse_v2 if 'filter_codes' in opts_data else parse_v1)()) + tuple(parse_global())
@@ -274,6 +279,7 @@ class Opts:
 		# Make these available to usage():
 		cfg._usage_data = opts_data['text'].get('usage2') or opts_data['text']['usage']
 		cfg._usage_code = opts_data.get('code', {}).get('usage')
+		cfg._help_pkg = self.help_pkg
 
 		if os.getenv('MMGEN_DEBUG_OPTS'):
 			opt_preproc_debug(po)
@@ -294,7 +300,7 @@ class UserOpts(Opts):
 			'options': """
 			-- --accept-defaults      Accept defaults at all prompts
 			hp --cashaddr=0|1         Display addresses in cashaddr format (default: 1)
-			-p --coin=c               Choose coin unit. Default: BTC. Current choice: {cu_dfl}
+			-c --coin=c               Choose coin unit. Default: BTC. Current choice: {cu_dfl}
 			er --token=t              Specify an ERC20 token by address or symbol
 			-- --color=0|1            Disable or enable color output (default: 1)
 			-- --columns=N            Force N columns of output with certain commands
@@ -351,6 +357,10 @@ class UserOpts(Opts):
 	@staticmethod
 	def get_global_filter_codes(need_proto):
 		"""
+		Enable options based on the value of --coin and name of executable
+
+		Both must produce a matching code list, or None, for the option to be enabled
+
 		Coin codes:
 		  'b' - Bitcoin or Bitcoin code fork supporting RPC
 		  'R' - Bitcoin or Ethereum code fork supporting RPC
@@ -360,26 +370,26 @@ class UserOpts(Opts):
 		  '-' - other coin
 		Cmd codes:
 		  'p' - proto required
+		  'c' - proto required, --coin recognized
 		  'r' - RPC required
 		  '-' - no capabilities required
 		"""
 		ret = namedtuple('global_filter_codes', ['coin', 'cmd'])
 		if caps := gc.cmd_caps:
-			coin = caps.coin if caps.coin and len(caps.coin) > 1 else get_coin()
+			coin = get_coin() if caps.use_coin_opt else None
+			# a return value of None removes the filter, enabling all options for the given criterion
 			return ret(
-				coin = (
-					('-', 'r', 'R', 'b', 'h') if coin == 'bch' else
-					('-', 'r', 'R', 'b') if coin in gc.btc_fork_rpc_coins else
-					('-', 'r', 'R', 'e') if coin in gc.eth_fork_coins else
-					('-', 'r') if coin in gc.rpc_coins else
-					('-')),
+				coin = caps.coin_codes or (
+					None if coin is None else
+					['-', 'r', 'R', 'b', 'h'] if coin == 'bch' else
+					['-', 'r', 'R', 'b'] if coin in gc.btc_fork_rpc_coins else
+					['-', 'r', 'R', 'e'] if coin in gc.eth_fork_coins else
+					['-', 'r'] if coin in gc.rpc_coins else
+					['-']),
 				cmd = (
 					['-']
 					+ (['r'] if caps.rpc else [])
-					+ (['p'] if caps.proto else [])
+					+ (['p', 'c'] if caps.proto and caps.use_coin_opt else ['p'] if caps.proto else [])
 				))
-		else:
-			return ret(
-				coin = ('-', 'r', 'R', 'b', 'h', 'e'),
-				cmd = ('-', 'r', 'p')
-			)
+		else: # unmanaged command: enable everything
+			return ret(None, None)

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

@@ -14,7 +14,6 @@ proto.btc.tw.addresses: Bitcoin base protocol tracking wallet address list class
 
 from ....tw.addresses import TwAddresses
 from ....tw.shared import TwLabel
-from ....util import msg, msg_r
 from ....obj import get_obj
 from .rpc import BitcoinTwRPC
 
@@ -47,15 +46,17 @@ class BitcoinTwAddresses(TwAddresses, BitcoinTwRPC):
 
 	async def get_rpc_data(self):
 
-		msg_r('Getting unspent outputs...')
+		qmsg = self.cfg._util.qmsg
+		qmsg_r = self.cfg._util.qmsg_r
+		qmsg_r('Getting unspent outputs...')
 		addrs = await self.get_unspent_by_mmid(self.minconf)
-		msg('done')
+		qmsg('done')
 
 		coin_amt = self.proto.coin_amt
 		amt0 = coin_amt('0')
 		self.total = sum((v['amt'] for v in addrs.values()), start=amt0)
 
-		msg_r('Getting labels and associated addresses...')
+		qmsg_r('Getting labels and associated addresses...')
 		for e in await self.get_label_addr_pairs():
 			if e.label and e.label.mmid not in addrs:
 				addrs[e.label.mmid] = {
@@ -64,9 +65,9 @@ class BitcoinTwAddresses(TwAddresses, BitcoinTwRPC):
 					'recvd':  amt0,
 					'confs':  0,
 					'lbl':    e.label}
-		msg('done')
+		qmsg('done')
 
-		msg_r('Getting received funds data...')
+		qmsg_r('Getting received funds data...')
 		# args: 1:minconf, 2:include_empty, 3:include_watchonly, 4:include_immature_coinbase (>=v23.0.0)
 		for d in await self.rpc.call('listreceivedbylabel', 1, True, True):
 			label = get_obj(TwLabel, proto=self.proto, text=d['label'])
@@ -74,6 +75,6 @@ class BitcoinTwAddresses(TwAddresses, BitcoinTwRPC):
 				assert label.mmid in addrs, f'{label.mmid!r} not found in addrlist!'
 				addrs[label.mmid]['recvd'] = coin_amt(d['amount'])
 				addrs[label.mmid]['confs'] = d['confirmations']
-		msg('done')
+		qmsg('done')
 
 		return addrs

+ 10 - 6
mmgen/proto/btc/tx/base.py

@@ -15,7 +15,7 @@ proto.btc.tx.base: Bitcoin base transaction class
 from collections import namedtuple
 
 from ....addr import CoinAddr
-from ....tx import base as TxBase
+from ....tx.base import Base as TxBase
 from ....obj import MMGenList, HexStr, ListItemAttr
 from ....util import msg, make_chksum_6, die, pp_fmt
 
@@ -169,16 +169,16 @@ def DeserializeTX(proto, txhex):
 
 	return namedtuple('deserialized_tx', list(d.keys()))(**d)
 
-class Base(TxBase.Base):
+class Base(TxBase):
 	rel_fee_desc = 'satoshis per byte'
 	rel_fee_disp = 'sat/byte'
 	_deserialized = None
 
-	class Output(TxBase.Base.Output): # output contains either addr or data, but not both
+	class Output(TxBase.Output): # output contains either addr or data, but not both
 		addr = ListItemAttr(CoinAddr, include_proto=True) # ImmutableAttr in parent cls
-		data = ListItemAttr(OpReturnData, include_proto=True, typeconv=True) # type None in parent cls
+		data = ListItemAttr(OpReturnData, include_proto=True) # type None in parent cls
 
-	class InputList(TxBase.Base.InputList):
+	class InputList(TxBase.InputList):
 
 		# Lexicographical Indexing of Transaction Inputs and Outputs
 		# https://github.com/bitcoin/bips/blob/master/bip-0069.mediawiki
@@ -189,7 +189,7 @@ class Base(TxBase.Base):
 					+ int.to_bytes(a.vout, 4, 'big'))
 			self.sort(key=sort_func)
 
-	class OutputList(TxBase.Base.OutputList):
+	class OutputList(TxBase.OutputList):
 
 		def sort_bip69(self):
 			def sort_func(a):
@@ -295,6 +295,10 @@ class Base(TxBase.Base):
 			getattr(self.proto.coin_amt, to_unit) /
 			self.estimate_size()))
 
+	@property
+	def nondata_outputs(self):
+		return [o for o in self.outputs if not o.data]
+
 	@property
 	def deserialized(self):
 		if not self._deserialized:

+ 1 - 1
mmgen/proto/btc/tx/completed.py

@@ -67,7 +67,7 @@ class Completed(Base, TxBase.Completed):
 	@property
 	def send_amt(self):
 		return self.sum_outputs(
-			exclude = None if len(self.outputs) == 1 else self.chg_idx
+			exclude = None if len(self.nondata_outputs) == 1 else self.chg_idx
 		)
 
 	def check_txfile_hex_data(self):

+ 10 - 4
mmgen/proto/btc/tx/info.py

@@ -14,12 +14,12 @@ proto.btc.tx.info: Bitcoin transaction info class
 
 from ....tx.info import TxInfo
 from ....util import fmt, die
-from ....color import red, green, pink, blue
+from ....color import red, green, blue, pink
 from ....addr import MMGenID
 
 class TxInfo(TxInfo):
 	sort_orders = ('addr', 'raw')
-	txinfo_hdr_fs = 'TRANSACTION DATA\n\nID={i} ({a} {c}) RBF={r} Sig={s} Locktime={l}\n'
+	txinfo_hdr_fs = '{hdr}\n  ID={i} ({a} {c}) RBF={r} Sig={s} Locktime={l}\n'
 	txinfo_hdr_fs_short = 'TX {i} ({a} {c}) RBF={r} Sig={s} Locktime={l}\n'
 	txinfo_ftr_fs = fmt("""
 		Input amount: {i} {d}
@@ -64,7 +64,10 @@ class TxInfo(TxInfo):
 					append_chars=('', ' (chg)')[bool(not is_input and e.is_chg and terse)],
 					append_color='green')
 			else:
-				return MMGenID.fmtc(nonmm_str, width=max_mmwid, color=True)
+				return MMGenID.fmtc(
+					nonmm_str,
+					width = max_mmwid,
+					color = True)
 
 		def format_io(desc):
 			io = getattr(tx, desc)
@@ -134,7 +137,10 @@ class TxInfo(TxInfo):
 			vp1 = 0
 
 		return (
-			'Displaying inputs and outputs in {} sort order'.format({'raw':'raw', 'addr':'address'}[sort])
+			'Inputs/Outputs sort order: {}'.format({
+				'raw':  pink('UNSORTED'),
+				'addr': pink('ADDRESS')
+			}[sort])
 			+ ('\n\n', '\n')[terse]
 			+ ''.join(format_io('inputs'))
 			+ ''.join(format_io('outputs')))

+ 17 - 6
mmgen/proto/btc/tx/new.py

@@ -12,13 +12,13 @@
 proto.btc.tx.new: Bitcoin new transaction class
 """
 
-from ....tx import new as TxBase
+from ....tx.new import New as TxNew
 from ....obj import MMGenTxID
 from ....util import msg, fmt, make_chksum_6, die, suf
-from ....color import pink
+from ....color import pink, yellow
 from .base import Base
 
-class New(Base, TxBase.New):
+class New(Base, TxNew):
 	usr_fee_prompt = 'Enter transaction fee: '
 	fee_fail_fs = 'Network fee estimation for {c} confirmations failed ({t})'
 	no_chg_msg = 'Warning: Change address will be deleted as transaction produces no change'
@@ -105,7 +105,7 @@ class New(Base, TxBase.New):
 		msg(err)
 		return False
 
-	async def get_input_addrs_from_cmdline(self):
+	async def get_input_addrs_from_inputs_opt(self):
 		# Bitcoin full node, call doesn't go to the network, so just call listunspent with addrs=[]
 		return []
 
@@ -125,17 +125,28 @@ class New(Base, TxBase.New):
 	def final_inputs_ok_msg(self, funds_left):
 		return 'Transaction produces {} {} in change'.format(funds_left.hl(), self.coin)
 
+	def check_chg_addr_is_wallet_addr(self, message='Change address is not an MMGen wallet address!'):
+		def do_err():
+			from ....ui import confirm_or_raise
+			confirm_or_raise(
+				cfg = self.cfg,
+				message = yellow(message),
+				action = 'Are you sure this is what you want?')
+		if len(self.nondata_outputs) > 1 and not self.chg_output.mmid:
+			do_err()
+
 	async def create_serialized(self, locktime=None, bump=None):
 
 		if not bump:
-			self.inputs.sort_bip69()
 			# Set all sequence numbers to the same value, in conformity with the behavior of most modern wallets:
 			do_rbf = self.proto.cap('rbf') and not self.cfg.no_rbf
 			seqnum_val = self.proto.max_int - (2 if do_rbf else 1 if locktime else 0)
 			for i in self.inputs:
 				i.sequence = seqnum_val
 
-		self.outputs.sort_bip69()
+		if not self.is_swap:
+			self.inputs.sort_bip69()
+			self.outputs.sort_bip69()
 
 		inputs_list = [{
 				'txid':     e.txid,

+ 23 - 0
mmgen/proto/btc/tx/new_swap.py

@@ -0,0 +1,23 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2024 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+proto.btc.tx.new_swap: Bitcoin new swap transaction class
+"""
+
+from ....tx.new_swap import NewSwap as TxNewSwap
+from .new import New
+
+class NewSwap(New, TxNewSwap):
+	desc = 'Bitcoin swap transaction'
+
+	async def process_swap_cmdline_args(self, cmd_args, addrfile_args):
+		import sys
+		sys.exit(0)

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

@@ -72,7 +72,7 @@ class OnlineSigned(Signed, TxBase.OnlineSigned):
 		self.add_blockcount()
 		return True
 
-	def print_contract_addr(self):
+	def post_write(self):
 		pass
 
 class Sent(TxBase.Sent, OnlineSigned):

+ 4 - 0
mmgen/proto/eth/tx/base.py

@@ -29,6 +29,10 @@ class Base(TxBase.Base):
 	usr_contract_data = HexStr('')
 	disable_fee_check = False
 
+	@property
+	def nondata_outputs(self):
+		return self.outputs
+
 	def pretty_fmt_fee(self, fee):
 		if fee < 1:
 			ret = f'{fee:.8f}'.rstrip('0')

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

@@ -18,7 +18,7 @@ from ....color import pink, yellow, blue
 from ....addr import MMGenID
 
 class TxInfo(TxInfo):
-	txinfo_hdr_fs = 'TRANSACTION DATA\n\nID={i} ({a} {c}) Sig={s} Locktime={l}\n'
+	txinfo_hdr_fs = '{hdr}\n  ID={i} ({a} {c}) Sig={s} Locktime={l}\n'
 	txinfo_hdr_fs_short = 'TX {i} ({a} {c}) Sig={s} Locktime={l}\n'
 	txinfo_ftr_fs = fmt("""
 		Total in account:  {i} {d}

+ 8 - 5
mmgen/proto/eth/tx/new.py

@@ -80,7 +80,7 @@ class New(Base, TxBase.New):
 			'update_txid() must be called only when self.serialized is not hex data')
 		self.txid = MMGenTxID(make_chksum_6(self.serialized).upper())
 
-	async def process_cmd_args(self, cmd_args, ad_f, ad_w):
+	async def process_cmdline_args(self, cmd_args, ad_f, ad_w):
 
 		lc = len(cmd_args)
 
@@ -90,14 +90,14 @@ class New(Base, TxBase.New):
 		if lc != 1:
 			die(1, f'{lc} output{suf(lc)} specified, but Ethereum transactions must have exactly one')
 
-		arg = self.parse_cmd_arg(cmd_args[0], ad_f, ad_w)
+		arg = self.parse_cmdline_arg(self.proto, cmd_args[0], ad_f, ad_w)
 
 		self.add_output(
-			coinaddr = arg.coin_addr,
+			coinaddr = arg.addr,
 			amt      = self.proto.coin_amt(arg.amt or '0'),
 			is_chg   = not arg.amt)
 
-	def select_unspent(self, unspent):
+	def get_unspent_nums_from_user(self, unspent):
 		from ....ui import line_input
 		while True:
 			reply = line_input(self.cfg, 'Enter an account to spend from: ').strip()
@@ -119,6 +119,9 @@ class New(Base, TxBase.New):
 	async def get_rel_fee_from_network(self):
 		return Int(await self.rpc.call('eth_gasPrice'), 16), 'eth_gasPrice'
 
+	def check_chg_addr_is_wallet_addr(self):
+		pass
+
 	def check_fee(self):
 		if not self.disable_fee_check:
 			assert self.usr_fee <= self.proto.max_tx_fee
@@ -152,7 +155,7 @@ class New(Base, TxBase.New):
 		if self.outputs and self.outputs[0].is_chg:
 			self.update_output_amt(0, funds_left)
 
-	async def get_input_addrs_from_cmdline(self):
+	async def get_input_addrs_from_inputs_opt(self):
 		ret = []
 		if self.cfg.inputs:
 			data_root = (await TwCtl(self.cfg, self.proto)).data_root # must create new instance here

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

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

+ 6 - 6
mmgen/tw/addresses.py

@@ -274,7 +274,7 @@ class TwAddresses(TwView):
 				return bool(e.recvd)
 		return None # addr not in tracking wallet
 
-	def get_change_address(self, al_id, bot=None, top=None, exclude=None):
+	def get_change_address(self, al_id, bot=None, top=None, exclude=None, desc=None):
 		"""
 		Get lowest-indexed unused address in tracking wallet for requested AddrListID.
 		Return values on failure:
@@ -326,14 +326,14 @@ class TwAddresses(TwView):
 								d.twmmid.hl(),
 								yellow('has a label,'),
 								d.comment.hl2(encl='‘’'),
-								yellow(',\n  but allowing it for change anyway by user request')
+								yellow(f',\n  but allowing it for {desc} anyway by user request')
 							))
 						return d
 				else:
 					break
 			return False
 
-	def get_change_address_by_addrtype(self, mmtype, exclude=None):
+	def get_change_address_by_addrtype(self, mmtype, exclude, desc):
 		"""
 		Find the lowest-indexed change addresses in tracking wallet of given address type,
 		present them in a menu and return a single change address chosen by the user.
@@ -352,9 +352,9 @@ class TwAddresses(TwView):
 					c = yellow(' <== has a label!') if d.comment else ''
 				)
 
-			prompt = '\nChoose a change address:\n\n{}\n\nEnter a number> '.format(
-				'\n'.join(format_line(n, d) for n, d in enumerate(addrs, 1))
-			)
+			prompt = '\nChoose a {desc}:\n\n{items}\n\nEnter a number> '.format(
+				desc = desc,
+				items = '\n'.join(format_line(n, d) for n, d in enumerate(addrs, 1)))
 
 			from ..ui import line_input
 			while True:

+ 4 - 0
mmgen/tx/__init__.py

@@ -45,6 +45,9 @@ def _get_cls_info(clsname, modname, kwargs):
 			die(1, f'{ext!r}: unrecognized file extension for CompletedTX')
 		clsname = cls.__name__
 		modname = cls.__module__.rsplit('.', maxsplit=1)[-1]
+	elif clsname == 'New' and kwargs['target'] == 'swaptx':
+		clsname = 'NewSwap'
+		modname = 'new_swap'
 
 	kwargs['proto'] = proto
 
@@ -94,6 +97,7 @@ BaseTX         = _get('Base',     'base')
 UnsignedTX     = _get('Unsigned', 'unsigned')
 
 NewTX          = _get_async('New',          'new')
+NewSwapTX      = _get_async('NewSwap',      'new_swap')
 CompletedTX    = _get_async('Completed',    'completed')
 SignedTX       = _get_async('Signed',       'signed')
 OnlineSignedTX = _get_async('OnlineSigned', 'online')

+ 9 - 10
mmgen/tx/base.py

@@ -79,6 +79,8 @@ class Base(MMGenObject):
 	locktime     = None
 	chain        = None
 	signed       = False
+	is_swap      = False
+	file_format  = 'json'
 	non_mmgen_inputs_msg = f"""
 		This transaction includes inputs with non-{gc.proj_name} addresses.  When
 		signing the transaction, private keys for the addresses listed below must
@@ -89,7 +91,6 @@ class Base(MMGenObject):
 		Non-{gc.proj_name} addresses found in inputs:
 		    {{}}
 	"""
-	file_format = 'json'
 
 	class Input(MMGenTxIO):
 		scriptPubKey  = ListItemAttr(HexStr)
@@ -97,8 +98,8 @@ class Base(MMGenObject):
 		tw_copy_attrs = {'scriptPubKey', 'vout', 'amt', 'comment', 'mmid', 'addr', 'confs', 'txid'}
 
 	class Output(MMGenTxIO):
-		is_chg = ListItemAttr(bool, typeconv=False)
-		data   = ListItemAttr(None, typeconv=False) # placeholder
+		is_chg   = ListItemAttr(bool, typeconv=False)
+		data     = ListItemAttr(None, typeconv=False) # placeholder
 
 	class InputList(MMGenTxIOList):
 		desc = 'transaction inputs'
@@ -145,12 +146,10 @@ class Base(MMGenObject):
 			return self.proto.coin_amt('0')
 		return sum(e.amt for e in olist)
 
-	def _chg_output_ops(self, op):
-		is_chgs = [x.is_chg for x in self.outputs]
+	def _chg_output_ops(self, op, attr):
+		is_chgs = [getattr(x, attr) for x in self.outputs]
 		if is_chgs.count(True) == 1:
-			return (
-				is_chgs.index(True) if op == 'idx' else
-				self.outputs[is_chgs.index(True)])
+			return is_chgs.index(True) if op == 'idx' else self.outputs[is_chgs.index(True)]
 		elif is_chgs.count(True) == 0:
 			return None
 		else:
@@ -158,11 +157,11 @@ class Base(MMGenObject):
 
 	@property
 	def chg_idx(self):
-		return self._chg_output_ops('idx')
+		return self._chg_output_ops('idx', 'is_chg')
 
 	@property
 	def chg_output(self):
-		return self._chg_output_ops('output')
+		return self._chg_output_ops('output', 'is_chg')
 
 	def add_timestamp(self):
 		self.timestamp = make_timestamp()

+ 2 - 2
mmgen/tx/bump.py

@@ -49,8 +49,8 @@ class Bump(Completed, New):
 				return False
 			return True
 
-		if len(self.outputs) == 1:
-			if check_sufficient_funds(self.outputs[0].amt):
+		if len(self.nondata_outputs) == 1:
+			if check_sufficient_funds(self.nondata_outputs[0].amt):
 				self.bump_output_idx = 0
 				return 0
 			else:

+ 2 - 3
mmgen/tx/file.py

@@ -64,14 +64,13 @@ class MMGenTxFile(MMGenObject):
 		'send_amt': 'skip',
 		'timestamp': None,
 		'blockcount': None,
-		'serialized': None,
-	}
+		'serialized': None}
 	extra_attrs = {
 		'locktime': None,
 		'comment': MMGenTxComment,
 		'coin_txid': CoinTxID,
 		'sent_timestamp': None,
-	}
+		'is_swap': None}
 
 	def __init__(self, tx):
 		self.tx       = tx

+ 10 - 5
mmgen/tx/info.py

@@ -15,7 +15,7 @@ tx.info: transaction info class
 import importlib
 
 from ..cfg import gc
-from ..color import red, green, orange
+from ..color import red, green, cyan, orange
 from ..util import msg, msg_r, decode_timestamp, make_timestr
 from ..util2 import format_elapsed_hr
 
@@ -29,6 +29,9 @@ class TxInfo:
 
 		tx = self.tx
 
+		if tx.is_swap:
+			sort = 'raw'
+
 		if tx.proto.base_proto == 'Ethereum':
 			blockcount = None
 		else:
@@ -48,6 +51,7 @@ class TxInfo:
 
 		def gen_view():
 			yield (self.txinfo_hdr_fs_short if terse else self.txinfo_hdr_fs).format(
+				hdr = cyan('TRANSACTION DATA'),
 				i = tx.txid.hl(),
 				a = tx.send_amt.hl(),
 				c = tx.dcoin,
@@ -60,19 +64,19 @@ class TxInfo:
 			for attr, label in [('timestamp', 'Created:'), ('sent_timestamp', 'Sent:')]:
 				if (val := getattr(tx, attr)) is not None:
 					_ = decode_timestamp(val)
-					yield f'{label:8} {make_timestr(_)} ({format_elapsed_hr(_)})\n'
+					yield f'  {label:8} {make_timestr(_)} ({format_elapsed_hr(_)})\n'
 
 			if tx.chain != 'mainnet': # if mainnet has a coin-specific name, display it
-				yield green(f'Chain: {tx.chain.upper()}') + '\n'
+				yield green(f'  Chain: {tx.chain.upper()}') + '\n'
 
 			if tx.coin_txid:
-				yield f'{tx.coin} TxID: {tx.coin_txid.hl()}\n'
+				yield f'  {tx.coin} TxID: {tx.coin_txid.hl()}\n'
 
 			enl = ('\n', '')[bool(terse)]
 			yield enl
 
 			if tx.comment:
-				yield f'Comment: {tx.comment.hl()}\n{enl}'
+				yield f'  Comment: {tx.comment.hl()}\n{enl}'
 
 			yield self.format_body(
 				blockcount,
@@ -121,6 +125,7 @@ class TxInfo:
 			from ..ui import do_pager
 			do_pager(o)
 		else:
+			msg('')
 			msg_r(o)
 			from ..term import get_char
 			if pause:

+ 102 - 79
mmgen/tx/new.py

@@ -82,6 +82,10 @@ class New(Base):
 	chg_autoselected = False
 	_funds_available = namedtuple('funds_available', ['is_positive', 'amt'])
 
+	def __init__(self, *args, target=None, **kwargs):
+		self.is_swap = target == 'swaptx'
+		super().__init__(*args, **kwargs)
+
 	def warn_insufficient_funds(self, amt, coin):
 		msg(self.msg_insufficient_funds.format(amt.hl(), coin))
 
@@ -90,7 +94,7 @@ class New(Base):
 		o['amt'] = amt
 		self.outputs[idx] = self.Output(self.proto, **o)
 
-	def add_mmaddrs_to_outputs(self, ad_w, ad_f):
+	def add_mmaddrs_to_outputs(self, ad_f, ad_w):
 		a = [e.addr for e in self.outputs]
 		d = ad_w.make_reverse_dict(a)
 		if ad_f:
@@ -167,22 +171,22 @@ class New(Base):
 	def process_data_output_arg(self, arg):
 		return None
 
-	def parse_cmd_arg(self, arg_in, ad_f, ad_w):
+	def parse_cmdline_arg(self, proto, arg_in, ad_f, ad_w):
 
-		_pa = namedtuple('parsed_txcreate_cmdline_arg', ['arg', 'mmid', 'coin_addr', 'amt', 'data'])
+		_pa = namedtuple('txcreate_cmdline_output', ['arg', 'mmid', 'addr', 'amt', 'data'])
 
 		if data := self.process_data_output_arg(arg_in):
 			return _pa(arg_in, None, None, None, data)
 
 		arg, amt = arg_in.split(',', 1) if ',' in arg_in else (arg_in, None)
 
-		if mmid := get_obj(MMGenID, proto=self.proto, id_str=arg, silent=True):
-			coin_addr = mmaddr2coinaddr(self.cfg, arg, ad_w, ad_f, self.proto)
-		elif is_coin_addr(self.proto, arg):
-			coin_addr = CoinAddr(self.proto, arg)
-		elif is_mmgen_addrtype(self.proto, arg) or is_addrlist_id(self.proto, arg):
-			if self.proto.base_proto_coin != 'BTC':
-				die(2, f'Change addresses not supported for {self.proto.name} protocol')
+		if mmid := get_obj(MMGenID, proto=proto, id_str=arg, silent=True):
+			coin_addr = mmaddr2coinaddr(self.cfg, arg, ad_w, ad_f, proto)
+		elif is_coin_addr(proto, arg):
+			coin_addr = CoinAddr(proto, arg)
+		elif is_mmgen_addrtype(proto, arg) or is_addrlist_id(proto, arg):
+			if proto.base_proto_coin != 'BTC':
+				die(2, f'Change addresses not supported for {proto.name} protocol')
 			self.chg_autoselected = True
 			coin_addr = None
 		else:
@@ -190,31 +194,29 @@ class New(Base):
 
 		return _pa(arg, mmid, coin_addr, amt, None)
 
-	async def process_cmd_args(self, cmd_args, ad_f, ad_w):
+	async def get_autochg_addr(self, proto, arg, exclude, desc):
+		from ..tw.addresses import TwAddresses
+		al = await TwAddresses(self.cfg, proto, get_data=True)
 
-		async def get_autochg_addr(arg, parsed_args):
-			from ..tw.addresses import TwAddresses
-			al = await TwAddresses(self.cfg, self.proto, get_data=True)
-			exclude = [a.mmid for a in parsed_args if a.mmid]
+		if obj := get_obj(MMGenAddrType, proto=proto, id_str=arg, silent=True):
+			res = al.get_change_address_by_addrtype(obj, exclude=exclude, desc=desc)
+			req_desc = f'of address type {arg!r}'
+		else:
+			res = al.get_change_address(arg, exclude=exclude, desc=desc)
+			req_desc = f'from address list {arg!r}'
 
-			if is_mmgen_addrtype(self.proto, arg):
-				res = al.get_change_address_by_addrtype(MMGenAddrType(self.proto, arg), exclude=exclude)
-				desc = 'of address type'
-			else:
-				res = al.get_change_address(arg, exclude=exclude)
-				desc = 'from address list'
+		if res:
+			return res
 
-			if res:
-				return res
+		die(2, 'Tracking wallet contains no {t}addresses {d}'.format(
+			t = '' if res is None else 'unused ',
+			d = req_desc))
 
-			die(2, 'Tracking wallet contains no {t}addresses {d} {a!r}'.format(
-				t = '' if res is None else 'unused ',
-				d = desc,
-				a = arg))
+	async def process_cmdline_args(self, cmd_args, ad_f, ad_w):
 
-		parsed_args = [self.parse_cmd_arg(a, ad_f, ad_w) for a in cmd_args]
+		parsed_args = [self.parse_cmdline_arg(self.proto, arg, ad_f, ad_w) for arg in cmd_args]
 
-		chg_args = [a for a in parsed_args if not ((a.amt and a.coin_addr) or a.data)]
+		chg_args = [a for a in parsed_args if not ((a.amt and a.addr) or a.data)]
 
 		if len(chg_args) > 1:
 			desc = 'requested' if self.chg_autoselected else 'listed'
@@ -224,8 +226,11 @@ class New(Base):
 			if a.data:
 				self.add_output(None, self.proto.coin_amt('0'), data=a.data)
 			else:
+				exclude = [a.mmid for a in parsed_args if a.mmid]
 				self.add_output(
-					coinaddr = a.coin_addr or (await get_autochg_addr(a.arg, parsed_args)).addr,
+					coinaddr = a.addr or (
+						await self.get_autochg_addr(self.proto, a.arg, exclude=exclude, desc='change address')
+					).addr,
 					amt      = self.proto.coin_amt(a.amt or '0'),
 					is_chg   = not a.amt)
 
@@ -240,61 +245,62 @@ class New(Base):
 				f'{gc.proj_name} Segwit address requested on the command line, '
 				'but Segwit is not active on this chain')
 
-		if not self.outputs:
-			die(2, 'At least one output must be specified on the command line')
+		if not self.nondata_outputs:
+			die(2, 'At least one spending output must be specified on the command line')
 
-	async def get_outputs_from_cmdline(self, cmd_args):
-		from ..addrdata import AddrData, TwAddrData
-		from ..addrlist import AddrList
+		self.add_mmaddrs_to_outputs(ad_f, ad_w)
+
+		self.check_dup_addrs('outputs')
+
+		if self.chg_output is not None:
+			if self.chg_autoselected:
+				self.confirm_autoselected_addr(self.chg_output.mmid, 'change address')
+			elif len(self.nondata_outputs) > 1:
+				await self.warn_addr_used(self.proto, self.chg_output, 'change address')
+
+	def get_addrfiles_from_cmdline(self, cmd_args):
 		from ..addrfile import AddrFile
-		addrfiles = remove_dups(
+		addrfile_args = remove_dups(
 			tuple(a for a in cmd_args if get_extension(a) == AddrFile.ext),
 			desc = 'command line',
 			edesc = 'argument',
 		)
-		cmd_args  = remove_dups(
-			tuple(a for a in cmd_args if a not in addrfiles),
-			desc = 'command line',
-			edesc = 'argument',
-		)
+		cmd_args = tuple(a for a in cmd_args if a not in addrfile_args)
+		if not self.is_swap:
+			cmd_args = remove_dups(cmd_args, desc='command line', edesc='argument')
+		return cmd_args, addrfile_args
 
-		ad_f = AddrData(self.proto)
+	def get_addrdata_from_files(self, proto, addrfiles):
+		from ..addrdata import AddrData
+		from ..addrlist import AddrList
 		from ..fileutil import check_infile
+		ad_f = AddrData(proto)
 		for addrfile in addrfiles:
 			check_infile(addrfile)
-			ad_f.add(AddrList(self.cfg, self.proto, addrfile))
-
-		ad_w = await TwAddrData(self.cfg, self.proto, twctl=self.twctl)
-
-		await self.process_cmd_args(cmd_args, ad_f, ad_w)
+			try:
+				ad_f.add(AddrList(self.cfg, proto, addrfile))
+			except Exception as e:
+				msg(f'{type(e).__name__}: {e}')
+		return ad_f
 
-		self.add_mmaddrs_to_outputs(ad_w, ad_f)
-		self.check_dup_addrs('outputs')
-
-		if self.chg_output is not None:
-			if self.chg_autoselected:
-				self.confirm_autoselected_addr(self.chg_output)
-			elif len(self.outputs) > 1:
-				await self.warn_chg_addr_used(self.chg_output)
-
-	def confirm_autoselected_addr(self, chg):
+	def confirm_autoselected_addr(self, mmid, desc):
 		from ..ui import keypress_confirm
 		if not keypress_confirm(
 				self.cfg,
-				'Using {a} as {b} address. OK?'.format(
-					a = chg.mmid.hl(),
-					b = 'single output' if len(self.outputs) == 1 else 'change'),
+				'Using {a} as {b}. OK?'.format(
+					a = mmid.hl(),
+					b = 'single output address' if len(self.nondata_outputs) == 1 else desc),
 				default_yes = True):
 			die(1, 'Exiting at user request')
 
-	async def warn_chg_addr_used(self, chg):
+	async def warn_addr_used(self, proto, chg, desc):
 		from ..tw.addresses import TwAddresses
-		if (await TwAddresses(self.cfg, self.proto, get_data=True)).is_used(chg.addr):
+		if (await TwAddresses(self.cfg, proto, get_data=True)).is_used(chg.addr):
 			from ..ui import keypress_confirm
 			if not keypress_confirm(
 					self.cfg,
 					'{a} {b} {c}\n{d}'.format(
-						a = yellow('Requested change address'),
+						a = yellow(f'Requested {desc}'),
 						b = chg.mmid.hl() if chg.mmid else chg.addr.hl(chg.addr.view_pref),
 						c = yellow('is already used!'),
 						d = yellow('Address reuse harms your privacy and security. Continue anyway? (y/N): ')
@@ -304,7 +310,7 @@ class New(Base):
 				die(1, 'Exiting at user request')
 
 	# inputs methods
-	def select_unspent(self, unspent):
+	def get_unspent_nums_from_user(self, unspent):
 		prompt = 'Enter a range or space-separated list of outputs to spend: '
 		from ..ui import line_input
 		while True:
@@ -317,13 +323,12 @@ class New(Base):
 						return selected
 					msg(f'Unspent output number must be <= {len(unspent)}')
 
-	def select_unspent_cmdline(self, unspent):
+	def get_unspent_nums_from_inputs_opt(self, unspent):
 
-		def idx2num(idx):
+		def do_add_msg(idx):
 			uo = unspent[idx]
-			mmid_disp = f' ({uo.twmmid})' if uo.twmmid.type == 'mmgen' else ''
-			msg(f'Adding input: {idx + 1} {uo.addr}{mmid_disp}')
-			return idx + 1
+			mm_disp = f' ({uo.twmmid})' if uo.twmmid.type == 'mmgen' else ''
+			msg('Adding input: {} {}{}'.format(idx + 1, uo.addr, mm_disp))
 
 		def get_uo_nums():
 			for addr in self.cfg.inputs.split(','):
@@ -335,9 +340,10 @@ class New(Base):
 					die(1, f'{addr!r}: not an MMGen ID or {self.coin} address')
 
 				found = False
-				for idx, us in enumerate(unspent):
-					if getattr(us, attr) == addr:
-						yield idx2num(idx)
+				for idx, e in enumerate(unspent):
+					if getattr(e, attr) == addr:
+						do_add_msg(idx)
+						yield idx + 1
 						found = True
 
 				if not found:
@@ -364,8 +370,10 @@ class New(Base):
 	async def get_inputs_from_user(self, outputs_sum):
 
 		while True:
-			us_f = self.select_unspent_cmdline if self.cfg.inputs else self.select_unspent
-			sel_nums = us_f(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)
 
 			msg(f'Selected output{suf(sel_nums)}: {{}}'.format(' '.join(str(n) for n in sel_nums)))
 			sel_unspent = MMGenList(self.twuo.data[i-1] for i in sel_nums)
@@ -405,14 +413,27 @@ class New(Base):
 		if self.cfg.comment_file:
 			self.add_comment(self.cfg.comment_file)
 
-		twuo_addrs = await self.get_input_addrs_from_cmdline()
+		if not do_info:
+			cmd_args, addrfile_args = self.get_addrfiles_from_cmdline(cmd_args)
+			if self.is_swap:
+				# updates self.proto!
+				self.proto, 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))
+
+		self.twuo = await TwUnspentOutputs(
+			self.cfg,
+			self.proto,
+			minconf = self.cfg.minconf,
+			addrs = await self.get_input_addrs_from_inputs_opt())
 
-		self.twuo = await TwUnspentOutputs(self.cfg, self.proto, minconf=self.cfg.minconf, addrs=twuo_addrs)
 		await self.twuo.get_data()
 
-		if not do_info:
-			await self.get_outputs_from_cmdline(cmd_args)
-
 		from ..ui import do_license_msg
 		do_license_msg(self.cfg)
 
@@ -438,6 +459,8 @@ class New(Base):
 
 		self.update_change_output(funds_left)
 
+		self.check_chg_addr_is_wallet_addr()
+
 		if not self.cfg.yes:
 			self.add_comment()  # edits an existing comment
 

+ 22 - 0
mmgen/tx/new_swap.py

@@ -0,0 +1,22 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2024 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+tx.new_swap: new swap transaction class
+"""
+
+from .new import New
+
+class NewSwap(New):
+	desc = 'swap transaction'
+	is_swap = True
+
+	async def process_swap_cmdline_args(self, cmd_args, addrfile_args):
+		raise NotImplementedError(f'Swap not implemented for protocol {self.proto.__name__}')

+ 40 - 30
mmgen/tx/sign.py

@@ -77,35 +77,45 @@ def generate_kals_for_mmgen_addrs(need_keys, infiles, saved_seeds, proto):
 						skip_chksum = True)
 	return MMGenList(gen_kals())
 
-def add_keys(tx, src, infiles=None, saved_seeds=None, keyaddr_list=None):
-	need_keys = [e for e in getattr(tx, src) if e.mmid and not e.have_wif]
+def add_keys(src, io_list, infiles=None, saved_seeds=None, keyaddr_list=None):
+
+	need_keys = [e for e in io_list if e.mmid and not e.have_wif]
+
 	if not need_keys:
 		return []
-	desc, src_desc = (
-		('key-address file', 'From key-address file:') if keyaddr_list else
-		('seed(s)', 'Generated from seed:'))
-	cfg._util.qmsg(f'Checking {gc.proj_name} -> {tx.proto.coin} address mappings for {src} (from {desc})')
-	d = (
-		MMGenList([keyaddr_list]) if keyaddr_list else
-		generate_kals_for_mmgen_addrs(need_keys, infiles, saved_seeds, tx.proto))
-	new_keys = []
-	for e in need_keys:
-		for kal in d:
-			for f in kal.data:
-				mmid = f'{kal.al_id}:{f.idx}'
-				if mmid == e.mmid:
-					if f.addr == e.addr:
-						e.have_wif = True
-						if src == 'inputs':
-							new_keys.append(f)
-					else:
-						die(3, fmt(f"""
-							{gc.proj_name} -> {tx.proto.coin} address mappings differ!
-							{src_desc:<23} {mmid} -> {f.addr}
-							{'tx file:':<23} {e.mmid} -> {e.addr}
-							""").strip())
-	if new_keys:
+
+	proto = need_keys[0].proto
+
+	if keyaddr_list:
+		desc = 'key-address file'
+		src_desc = 'From key-address file:'
+		d = MMGenList([keyaddr_list])
+	else:
+		desc = 'seed(s)'
+		src_desc = 'Generated from seed:'
+		d = generate_kals_for_mmgen_addrs(need_keys, infiles, saved_seeds, proto)
+
+	cfg._util.qmsg(f'Checking {gc.proj_name} -> {proto.coin} address mappings for {src} (from {desc})')
+
+	def gen_keys():
+		for e in need_keys:
+			for kal in d:
+				for f in kal.data:
+					if mmid := f'{kal.al_id}:{f.idx}' == e.mmid:
+						if f.addr == e.addr:
+							e.have_wif = True
+							if src == 'inputs':
+								yield f
+						else:
+							die(3, fmt(f"""
+								{gc.proj_name} -> {proto.coin} address mappings differ!
+								{src_desc:<23} {mmid} -> {f.addr}
+								{'tx file:':<23} {e.mmid} -> {e.addr}
+								""").strip())
+
+	if new_keys := list(gen_keys()):
 		cfg._util.vmsg(f'Added {len(new_keys)} wif key{suf(new_keys)} from {desc}')
+
 	return new_keys
 
 def _pop_matching_fns(args, cmplist): # strips found args
@@ -169,11 +179,11 @@ async def txsign(cfg_parm, tx, seed_files, kl, kal, tx_num_str='', passwd_file=N
 		keys += tmp.data
 
 	if cfg.mmgen_keys_from_file:
-		keys += add_keys(tx, 'inputs', keyaddr_list=kal)
-		add_keys(tx, 'outputs', keyaddr_list=kal)
+		keys += add_keys('inputs', tx.inputs, keyaddr_list=kal)
+		add_keys('outputs', tx.outputs, keyaddr_list=kal)
 
-	keys += add_keys(tx, 'inputs', seed_files, saved_seeds)
-	add_keys(tx, 'outputs', seed_files, saved_seeds)
+	keys += add_keys('inputs', tx.inputs, seed_files, saved_seeds)
+	add_keys('outputs', tx.outputs, seed_files, saved_seeds)
 
 	# this (boolean) attr isn't needed in transaction file
 	tx.delete_attrs('inputs', 'have_wif')

+ 1 - 1
mmgen/util.py

@@ -358,7 +358,7 @@ def secs_to_ms(secs):
 	return '{:02d}:{:02d}'.format(secs//60, secs % 60)
 
 def is_int(s): # actually is_nonnegative_int()
-	return set(str(s)) <= set(digits)
+	return set(str(s) or 'x') <= set(digits)
 
 def check_int_between(val, imin, imax, desc):
 	if not imin <= int(val) <= imax:

+ 2 - 2
mmgen/util2.py

@@ -135,14 +135,14 @@ def format_elapsed_days_hr(t, now=None, cached={}):
 		cached[e] = f'{days} day{suf(days)} ' + ('ago' if e > 0 else 'in the future')
 	return cached[e]
 
-def format_elapsed_hr(t, now=None, cached={}, rel_now=True, show_secs=False):
+def format_elapsed_hr(t, now=None, cached={}, rel_now=True, show_secs=False, future_msg='in the future'):
 	e = int((now or time.time()) - t)
 	key = f'{e}:{rel_now}:{show_secs}'
 	if not key in cached:
 		def add_suffix():
 			return (
 				((' ago'           if rel_now else '') if e > 0 else
-				(' in the future' if rel_now else ' (negative elapsed)'))
+				(f' {future_msg}' if rel_now else ' (negative elapsed)'))
 					if (abs_e if show_secs else abs_e // 60) else
 				('just now' if rel_now else ('0 ' + ('seconds' if show_secs else 'minutes')))
 			)

+ 2 - 0
setup.cfg

@@ -107,6 +107,8 @@ scripts =
 	cmds/mmgen-seedjoin
 	cmds/mmgen-seedsplit
 	cmds/mmgen-subwalletgen
+	cmds/mmgen-swaptxcreate
+	cmds/mmgen-swaptxdo
 	cmds/mmgen-tool
 	cmds/mmgen-txbump
 	cmds/mmgen-txcreate

+ 1 - 1
test/cmdtest.py

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

+ 1 - 10
test/cmdtest_d/ct_autosign.py

@@ -458,16 +458,7 @@ class CmdTestAutosignThreaded(CmdTestAutosignBase):
 		return 'silent'
 
 	def wait_loop_kill(self):
-		self.spawn('', msg_only=True)
-		pid = int(self.read_from_tmpfile('autosign_thread_pid'))
-		self.delete_tmpfile('autosign_thread_pid')
-		from signal import SIGTERM
-		imsg(purple(f'Killing autosign wait loop [PID {pid}]'))
-		try:
-			os.kill(pid, SIGTERM)
-		except:
-			imsg(yellow(f'{pid}: no such process'))
-		return 'ok'
+		return self._kill_process_from_pid_file('autosign_thread_pid', 'autosign wait loop')
 
 	def _wait_signed(self, desc):
 		oqmsg_r(gray(f'→ offline wallet{"s" if desc.endswith("s") else ""} waiting for {desc}'))

+ 19 - 2
test/cmdtest_d/ct_base.py

@@ -23,9 +23,9 @@ test.cmdtest_d.ct_base: Base class for the cmdtest.py test suite
 import sys, os
 
 from mmgen.util import msg
-from mmgen.color import gray
+from mmgen.color import gray, purple, yellow
 
-from ..include.common import cfg, write_to_file, read_from_file
+from ..include.common import cfg, write_to_file, read_from_file, imsg
 from .common import get_file_with_ext
 
 class CmdTestBase:
@@ -64,6 +64,11 @@ class CmdTestBase:
 		else:
 			self.spawn_env = {} # placeholder
 
+	def get_altcoin_pfx(self, coin, cashaddr=True):
+		coin = coin.lower()
+		fork = 'btc' if coin == 'bch' and not cashaddr else coin
+		return '' if fork == 'btc' else f'-{coin.upper()}'
+
 	@property
 	def tmpdir(self):
 		return os.path.join('test', 'tmp', '{}{}'.format(self.tmpdir_num, '-α' if cfg.debug_utf8 else ''))
@@ -117,3 +122,15 @@ class CmdTestBase:
 
 	def _cashaddr_opt(self, val):
 		return [f'--cashaddr={val}'] if self.proto.coin == 'BCH' else []
+
+	def _kill_process_from_pid_file(self, fn, desc):
+		self.spawn('', msg_only=True)
+		pid = int(self.read_from_tmpfile(fn))
+		self.delete_tmpfile(fn)
+		from signal import SIGTERM
+		imsg(purple(f'Killing {desc} [PID {pid}]'))
+		try:
+			os.kill(pid, SIGTERM)
+		except:
+			imsg(yellow(f'{pid}: no such process'))
+		return 'ok'

+ 1 - 1
test/cmdtest_d/ct_ethdev.py

@@ -975,7 +975,7 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 		if self.daemon.id == 'geth': # yet another Geth bug
 			await asyncio.sleep(0.5)
 		from mmgen.tx import NewTX
-		tx = await NewTX(cfg=cfg, proto=self.proto)
+		tx = await NewTX(cfg=cfg, proto=self.proto, target='tx')
 		tx.rpc = await self.rpc
 		res = await tx.get_receipt(txid)
 		imsg(f'Gas sent:  {res.gas_sent.hl():<9} {(res.gas_sent*res.gas_price).hl2(encl="()")}')

+ 40 - 31
test/cmdtest_d/ct_help.py

@@ -26,7 +26,7 @@ class CmdTestHelp(CmdTestBase):
 	passthru_opts = ('daemon_data_dir', 'rpc_port', 'coin', 'testnet')
 	cmd_group = (
 		('usage1',            (1, 'usage message (via --usage)', [])),
-		('usage2',            (1, 'usage message (via --usage)', [])),
+		('usage2',            (1, 'usage message (via --usage, with --coin)', [])),
 		('usage3',            (1, 'usage message (via bad invocation)', [])),
 		('usage4',            (1, 'usage message (via bad invocation, with --coin)', [])),
 		('version',           (1, 'version message', [])),
@@ -41,26 +41,27 @@ class CmdTestHelp(CmdTestBase):
 	)
 
 	def usage1(self):
-		t = self.spawn('mmgen-walletgen', ['--usage'], no_passthru_opts=True)
-		t.expect('USAGE: mmgen-walletgen')
-		return t
+		return self._usage('walletgen', ['--usage'], True, False, 0)
 
 	def usage2(self):
-		cmd = 'xmrwallet' if self.coin == 'xmr' else 'txcreate'
-		t = self.spawn(f'mmgen-{cmd}', ['--usage', f'--coin={self.coin}'], no_passthru_opts=True)
-		t.expect(f'USAGE: mmgen-{cmd}')
-		return t
+		return self._usage('tool' if self.coin == 'xmr' else 'txcreate', ['--usage'], True, True, 0)
 
 	def usage3(self):
-		t = self.spawn('mmgen-walletgen', ['foo'], exit_val=1, no_passthru_opts=True)
-		t.expect('USAGE: mmgen-walletgen')
-		return t
+		return self._usage('walletgen', ['foo'], True, False, 1)
 
 	def usage4(self):
-		cmd = 'xmrwallet' if self.coin == 'xmr' else 'addrgen'
-		t = self.spawn(f'mmgen-{cmd}', [f'--coin={self.coin}'], exit_val=1, no_passthru_opts=True)
-		t.expect(f'USAGE: mmgen-{cmd}')
-		return t
+		return self._usage('tool' if self.coin == 'xmr' else 'txcreate', [], True, True, 1)
+
+	def _usage(self, cmd_arg, args, no_passthru_opts, add_coin_opt, exit_val):
+		if cmd := (None if self._gen_skiplist(cmd_arg) else cmd_arg):
+			t = self.spawn(
+				f'mmgen-{cmd}',
+				([f'--coin={self.coin}'] if add_coin_opt else []) + args,
+				exit_val = exit_val,
+				no_passthru_opts = no_passthru_opts)
+			t.expect(f'USAGE: mmgen-{cmd}')
+			return t
+		return 'skip'
 
 	def version(self):
 		t = self.spawn('mmgen-tool', ['--version'], exit_val=0)
@@ -97,29 +98,37 @@ class CmdTestHelp(CmdTestBase):
 		t.skip_ok = True
 		return t
 
+	def _gen_skiplist(self, scripts):
+		def gen(scripts):
+			if isinstance(scripts, str):
+				scripts = [scripts]
+			for script in scripts:
+				d = gc.cmd_caps_data[script]
+				if sys.platform == 'win32' and 'w' not in d.platforms:
+					yield script
+				elif not (d.use_coin_opt or self.proto.coin.lower() == 'btc'):
+					yield script
+				else:
+					for cap in d.caps:
+						if cap not in self.proto.mmcaps:
+							yield script
+							break
+		return set(gen(scripts))
+
 	def helpscreens(self, arg='--help', scripts=(), expect='USAGE:.*OPTIONS:', pager=True):
 
 		scripts = list(scripts or gc.cmd_caps_data)
 
-		def gen_skiplist():
-			for script in scripts:
-				d = gc.cmd_caps_data[script]
-				for cap in d.caps:
-					if cap not in self.proto.mmcaps:
-						yield script
-						break
-				else:
-					if sys.platform == 'win32' and 'w' not in d.platforms:
-						yield script
-					elif d.coin and len(d.coin) > 1 and self.proto.coin.lower() not in (d.coin, 'btc'):
-						yield script
+		cmdlist = sorted(set(scripts) - self._gen_skiplist(scripts))
 
-		for cmdname in sorted(set(scripts) - set(list(gen_skiplist()))):
+		for cmdname in cmdlist:
+			cmd_caps = gc.cmd_caps_data[cmdname]
+			assert cmd_caps, cmdname
 			t = self.spawn(
 				f'mmgen-{cmdname}',
 				[arg],
 				extra_desc       = f'(mmgen-{cmdname})',
-				no_passthru_opts = not gc.cmd_caps_data[cmdname].proto)
+				no_passthru_opts = not cmd_caps.use_coin_opt)
 			t.expect(expect, regex=True)
 			if pager and t.pexpect_spawn:
 				time.sleep(0.2)
@@ -128,7 +137,7 @@ class CmdTestHelp(CmdTestBase):
 			t.ok()
 			t.skip_ok = True
 
-		return t
+		return 'silent'
 
 	def longhelpscreens(self):
 		return self.helpscreens(arg='--longhelp', expect='USAGE:.*GLOBAL OPTIONS:')
@@ -139,7 +148,7 @@ class CmdTestHelp(CmdTestBase):
 			scripts = (
 				'walletgen', 'walletconv', 'walletchk', 'passchg', 'subwalletgen',
 				'addrgen', 'keygen', 'passgen',
-				'txsign', 'txdo', 'txbump'),
+				'txdo', 'swaptxdo', 'txsign', 'txbump'),
 			expect = 'Available parameters.*Preset',
 			pager  = False)
 

+ 69 - 54
test/cmdtest_d/ct_regtest.py

@@ -27,7 +27,7 @@ from mmgen.proto.btc.regtest import MMGenRegtest
 from mmgen.proto.bch.cashaddr import b32a
 from mmgen.proto.btc.common import b58a
 from mmgen.color import yellow
-from mmgen.util import msg_r, die, gmsg, capfirst, fmt_list
+from mmgen.util import msg_r, die, gmsg, capfirst, suf, fmt_list
 from mmgen.protocol import init_proto
 from mmgen.addrlist import AddrList
 from mmgen.wallet import Wallet, get_wallet_cls
@@ -44,8 +44,9 @@ from ..include.common import (
 	cmp_or_die,
 	strip_ansi_escapes,
 	gr_uc,
-	getrandhex
-)
+	getrandhex,
+	make_burn_addr)
+
 from .common import (
 	ok_msg,
 	get_file_with_ext,
@@ -53,8 +54,8 @@ from .common import (
 	tw_comment_lat_cyr_gr,
 	tw_comment_zh,
 	tx_comment_jp,
-	get_env_without_debug_vars
-)
+	get_env_without_debug_vars)
+
 from .ct_base import CmdTestBase
 from .ct_shared import CmdTestShared
 
@@ -161,14 +162,6 @@ rt_data = {
 	}
 }
 
-def make_burn_addr(proto):
-	from mmgen.tool.coin import tool_cmd
-	return tool_cmd(
-		cfg     = cfg,
-		cmdname = 'pubhash2addr',
-		proto   = proto,
-		mmtype  = 'compressed').pubhash2addr('00'*20)
-
 class CmdTestRegtest(CmdTestBase, CmdTestShared):
 	'transacting and tracking wallet operations via regtest mode'
 	networks = ('btc', 'ltc', 'bch')
@@ -497,11 +490,12 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 		self.dfl_mmtype = 'C' if coin == 'bch' else 'B'
 		self.burn_addr = make_burn_addr(self.proto)
 		self.user_sids = {}
+		self.protos = (self.proto,)
 
-	def _add_comments_to_addr_file(self, addrfile, outfile, use_comments=False):
+	def _add_comments_to_addr_file(self, proto, addrfile, outfile, use_comments=False):
 		silence()
 		gmsg(f'Adding comments to address file {addrfile!r}')
-		a = AddrList(cfg, self.proto, addrfile)
+		a = AddrList(cfg, proto, addrfile)
 		for n, idx in enumerate(a.idxs(), 1):
 			if use_comments:
 				a.set_comment(idx, get_comment())
@@ -520,16 +514,21 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 		end_silence()
 
 	def setup(self):
-		stop_test_daemons(self.proto.network_id, force=True, remove_datadir=True)
-		from shutil import rmtree
-		try:
-			rmtree(joinpath(self.tr.data_dir, 'regtest'))
-		except:
-			pass
+		return self._setup(proto=self.proto, remove_datadir=True)
+
+	def _setup(self, proto, remove_datadir):
+		stop_test_daemons(proto.network_id, force=True, remove_datadir=True)
+		if remove_datadir:
+			from shutil import rmtree
+			try:
+				rmtree(joinpath(self.tr.data_dir, 'regtest'))
+			except:
+				pass
 		t = self.spawn(
 			'mmgen-regtest',
 			(['--bdb-wallet'] if self.use_bdb_wallet else [])
-			+ ['--setup-no-stop-daemon', 'setup'])
+			+ [f'--coin={proto.coin}', '--setup-no-stop-daemon', 'setup'],
+			no_passthru_opts = True)
 		t.expect('Starting')
 		for _ in range(3): t.expect('Creating')
 		for _ in range(5): t.expect('Mined')
@@ -556,6 +555,7 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 
 	def walletgen_bob(self):
 		return self.walletgen('bob')
+
 	def walletgen_alice(self):
 		return self.walletgen('alice')
 
@@ -582,16 +582,20 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 			wf          = None,
 			addr_range  = '1-5',
 			subseed_idx = None,
-			mmtypes     = []):
+			mmtypes     = [],
+			proto       = None):
 		from mmgen.addr import MMGenAddrType
-		for mmtype in mmtypes or self.proto.mmtypes:
+		proto = proto or self.proto
+		for mmtype in mmtypes or proto.mmtypes:
 			t = self.spawn(
 				'mmgen-addrgen',
 				['--quiet', f'--{user}', f'--type={mmtype}', f'--outdir={self._user_dir(user)}']
 				+ ([wf] if wf else [])
 				+ ([f'--subwallet={subseed_idx}'] if subseed_idx else [])
+				+ [f'--coin={proto.coin}']
 				+ [addr_range],
-				extra_desc = '({})'.format(MMGenAddrType.mmtypes[mmtype].name))
+				extra_desc = '({})'.format(MMGenAddrType.mmtypes[mmtype].name),
+				no_passthru_opts = True)
 			t.passphrase(dfl_wcls.desc, rt_pw)
 			t.written_to_file('Addresses')
 			ok_msg()
@@ -611,26 +615,28 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 			num_addrs  = 5,
 			mmtypes    = [],
 			batch      = True,
-			quiet      = True):
+			quiet      = True,
+			proto      = None):
+		proto = proto or self.proto
 		id_strs = {'legacy':'', 'compressed':'-C', 'segwit':'-S', 'bech32':'-B'}
 		if not sid:
 			sid = self._user_sid(user)
 		from mmgen.addr import MMGenAddrType
-		for mmtype in mmtypes or self.proto.mmtypes:
+		for mmtype in mmtypes or proto.mmtypes:
 			desc = MMGenAddrType.mmtypes[mmtype].name
 			addrfile = joinpath(self._user_dir(user),
 				'{}{}{}[{}]{x}.regtest.addrs'.format(
-					sid, self.altcoin_pfx, id_strs[desc], addr_range,
+					sid, self.get_altcoin_pfx(proto.coin), id_strs[desc], addr_range,
 					x='-α' if cfg.debug_utf8 else ''))
-			if mmtype == self.proto.mmtypes[0] and user == 'bob':
-				self._add_comments_to_addr_file(addrfile, addrfile, use_comments=True)
+			if mmtype == proto.mmtypes[0] and user == 'bob':
+				self._add_comments_to_addr_file(proto, addrfile, addrfile, use_comments=True)
 			t = self.spawn(
 				'mmgen-addrimport',
 				args = (
 					(['--quiet'] if quiet else []) +
 					['--'+user] +
 					(['--batch'] if batch else []) +
-					[addrfile]),
+					[f'--coin={proto.coin}', addrfile]),
 				extra_desc = f'({desc})')
 			if cfg.debug:
 				t.expect("Type uppercase 'YES' to confirm: ", 'YES\n')
@@ -707,14 +713,15 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 			return 'skip'
 		return self.addrimport('bob')
 
-	def fund_wallet(self, user, mmtype, amt, sid=None, addr_range='1-5'):
+	def fund_wallet(self, user, mmtype, amt, sid=None, addr_range='1-5', proto=None):
+		proto = proto or self.proto
 		if self.deterministic:
 			return 'skip'
 		if not sid:
 			sid = self._user_sid(user)
-		addr = self.get_addr_from_addrlist(user, sid, mmtype, 0, addr_range=addr_range)
-		t = self.spawn('mmgen-regtest', ['send', str(addr), str(amt)])
-		t.expect(f'Sending {amt} miner {self.proto.coin}')
+		addr = self.get_addr_from_addrlist(user, sid, mmtype, 0, addr_range=addr_range, proto=proto)
+		t = self.spawn('mmgen-regtest', [f'--coin={proto.coin}', 'send', str(addr), str(amt)], no_passthru_opts=True)
+		t.expect(f'Sending {amt} miner {proto.coin}')
 		t.expect('Mined 1 block')
 		return t
 
@@ -755,10 +762,11 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 	def bob_twview1(self):
 		return self.user_twview('bob', chk=('1', rtAmts[0]))
 
-	def user_bal(self, user, bal, opts=[], args=['showempty=1'], skip_check=False):
-		t = self.spawn('mmgen-tool', opts + [f'--{user}', 'listaddresses'] + args)
+	def user_bal(self, user, bal, opts=[], args=['showempty=1'], skip_check=False, proto=None):
+		proto = proto or self.proto
+		t = self.spawn('mmgen-tool', opts + [f'--{user}', f'--coin={proto.coin}', 'listaddresses'] + args)
 		if not skip_check:
-			cmp_or_die(f'{bal} {self.proto.coin}', strip_ansi_escapes(t.expect_getend('TOTAL: ')))
+			cmp_or_die(f'{bal} {proto.coin}', strip_ansi_escapes(t.expect_getend('TOTAL: ')))
 		return t
 
 	def alice_bal1(self):
@@ -809,26 +817,28 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 	def bob_subwallet_addrgen2(self):
 		return self.addrgen('bob', subseed_idx='127S', mmtypes=['C']) # 127S: '09E8E286'
 
-	def subwallet_addrimport(self, user, subseed_idx):
+	def _subwallet_addrimport(self, user, subseed_idx, mmtypes, proto=None):
 		sid = self._get_user_subsid(user, subseed_idx)
-		return self.addrimport(user, sid=sid, mmtypes=['C'])
+		return self.addrimport(user, sid=sid, mmtypes=mmtypes, proto=proto)
 
 	def bob_subwallet_addrimport1(self):
-		return self.subwallet_addrimport('bob', '29L')
+		return self._subwallet_addrimport('bob', '29L', ['C'])
+
 	def bob_subwallet_addrimport2(self):
-		return self.subwallet_addrimport('bob', '127S')
+		return self._subwallet_addrimport('bob', '127S', ['C'])
 
-	def bob_subwallet_fund(self):
+	def bob_subwallet_fund(self, proto=None):
+		proto = proto or self.proto
 		sid1 = self._get_user_subsid('bob', '29L')
 		sid2 = self._get_user_subsid('bob', '127S')
-		chg_addr = self._user_sid('bob') + (':B:1', ':L:1')[self.proto.coin=='BCH']
+		chg_addr = self._user_sid('bob') + (':B:1', ':L:1')[proto.coin=='BCH']
 		return self.user_txdo(
 			user               = 'bob',
 			fee                = rtFee[1],
 			outputs_cl         = [sid1+':C:2,0.29', sid2+':C:3,0.127', chg_addr],
-			outputs_list       = ('3', '1')[self.proto.coin=='BCH'],
+			outputs_list       = ('3', '1')[proto.coin=='BCH'],
 			extra_args         = ['--subseeds=127'],
-			used_chg_addr_resp = (None, 'y')[self.proto.coin=='BCH'])
+			used_chg_addr_resp = (None, 'y')[proto.coin=='BCH'])
 
 	def bob_twview2(self):
 		sid1 = self._get_user_subsid('bob', '29L')
@@ -1026,6 +1036,7 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 			wf                 = None,
 			add_comment        = tx_comment_jp,
 			return_early       = False,
+			tweaks             = [],
 			return_after_send  = False,
 			menu               = ['M'],
 			skip_passphrase    = False,
@@ -1050,6 +1061,7 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 				interactive_fee    = (tx_fee, '')[bool(fee)],
 				add_comment        = add_comment,
 				return_early       = return_early,
+				tweaks             = tweaks,
 				view               = 't',
 				save               = True,
 				used_chg_addr_resp = used_chg_addr_resp)
@@ -1072,13 +1084,14 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 		outputs_cl = [sid+':C:1,100', sid+':L:2,200', sid+':'+rtBobOp3]
 		return self.user_txdo('bob', rtFee[0], outputs_cl, '1', extra_args=['--locktime=500000001'])
 
-	def get_addr_from_addrlist(self, user, sid, mmtype, idx, addr_range='1-5'):
+	def get_addr_from_addrlist(self, user, sid, mmtype, idx, addr_range='1-5', proto=None):
+		proto = proto or self.proto
 		id_str = {'L':'', 'S':'-S', 'C':'-C', 'B':'-B'}[mmtype]
 		ext = '{}{}{}[{}]{x}.regtest.addrs'.format(
-			sid, self.altcoin_pfx, id_str, addr_range, x='-α' if cfg.debug_utf8 else '')
+			sid, self.get_altcoin_pfx(proto.coin), id_str, addr_range, x='-α' if cfg.debug_utf8 else '')
 		addrfile = get_file_with_ext(self._user_dir(user), ext, no_dot=True)
 		silence()
-		addr = AddrList(cfg, self.proto, addrfile).data[idx].addr
+		addr = AddrList(cfg, proto, addrfile).data[idx].addr
 		end_silence()
 		return addr
 
@@ -1120,12 +1133,13 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 	def bob_send_non_mmgen(self):
 		keyfile = joinpath(self.tmpdir, 'non-mmgen.keys')
 		atype = 'S' if self.proto.cap('segwit') else 'L'
-		outputs_cl = self._create_tx_outputs('alice', ((atype, 2, ', 10'), (atype, 3, '')))
+		outputs_cl = self._create_tx_outputs('alice', ((atype, 2, ',10'), (atype, 3, '')))
 		return self.user_txdo(
 			user         = 'bob',
 			fee          = rtFee[3],
 			outputs_cl   = outputs_cl, # alice_sid:S:2,10, alice_sid:S:3
 			outputs_list = '1,4-10',
+			tweaks       = ['confirm_chg_non_mmgen'],
 			extra_args   = [f'--keys-from-file={keyfile}', '--vsize-adj=1.02'])
 
 	def alice_send_estimatefee(self):
@@ -1164,9 +1178,9 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 		txfile = self.get_file_with_ext(ext, delete=False, no_dot=True)
 		return self.user_txbump('bob', self.tmpdir, txfile, rtFee[2], add_args=['--send'])
 
-	def generate(self, num_blocks=1):
+	def generate(self, num_blocks=1, add_opts=[]):
 		int(num_blocks)
-		t = self.spawn('mmgen-regtest', ['generate', str(num_blocks)])
+		t = self.spawn('mmgen-regtest', add_opts + ['generate', str(num_blocks)])
 		t.expect(f'Mined {num_blocks} block')
 		return t
 
@@ -2151,10 +2165,11 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 	def stop(self):
 		self.spawn('', msg_only=True)
 		if cfg.no_daemon_stop:
-			msg_r('(leaving regtest daemon running by user request)')
+			msg_r(f'(leaving regtest daemon{suf(self.protos)} running by user request)')
 			imsg('')
 		else:
-			stop_test_daemons(self.proto.network_id, remove_datadir=True)
+			for proto in self.protos:
+				stop_test_daemons(proto.network_id, remove_datadir=True)
 		return 'ok'
 
 class CmdTestRegtestBDBWallet(CmdTestRegtest):

+ 3 - 0
test/cmdtest_d/ct_shared.py

@@ -109,6 +109,9 @@ class CmdTestShared:
 		if 'confirm_non_mmgen' in tweaks:
 			t.expect('Continue? (Y/n)', '\n')
 
+		if 'confirm_chg_non_mmgen' in tweaks:
+			t.expect('to confirm: ', 'YES\n')
+
 		t.do_comment(add_comment)
 
 		if return_early:

+ 57 - 12
test/cmdtest_d/ct_swap.py

@@ -12,9 +12,14 @@
 test.cmdtest_d.ct_swap: asset swap tests for the cmdtest.py test suite
 """
 
-from .ct_regtest import CmdTestRegtest, rt_data, dfl_wcls, rt_pw
+from mmgen.protocol import init_proto
 
-rtFundAmt = rtFee = None # ruff
+from .ct_regtest import (
+	CmdTestRegtest,
+	rt_data,
+	dfl_wcls,
+	rt_pw,
+	cfg)
 
 sample1 = '=:ETH.ETH:0x86d526d6624AbC0178cF7296cD538Ecc080A95F1:0/1/0'
 sample2 = '00010203040506'
@@ -23,12 +28,14 @@ class CmdTestSwap(CmdTestRegtest):
 	bdb_wallet = True
 	networks = ('btc',)
 	tmpdir_nums = [37]
+	passthru_opts = ('rpc_backend',)
 
 	cmd_group_in = (
 		('setup',             'regtest (Bob and Alice) mode setup'),
 		('subgroup.init_bob', []),
 		('subgroup.fund_bob', ['init_bob']),
-		('subgroup.data',     ['init_bob']),
+		('subgroup.data',     ['fund_bob']),
+		('subgroup.swap',     ['fund_bob']),
 		('stop',              'stopping regtest daemon'),
 	)
 	cmd_subgroups = {
@@ -40,8 +47,9 @@ class CmdTestSwap(CmdTestRegtest):
 		),
 		'fund_bob': (
 			'funding Bob’s wallet',
-			('fund_bob', 'funding Bob’s wallet'),
-			('bob_bal1', 'Bob’s balance'),
+			('fund_bob1', 'funding Bob’s wallet (bech32)'),
+			('fund_bob2', 'funding Bob’s wallet (native Segwit)'),
+			('bob_bal',   'displaying Bob’s balance'),
 		),
 		'data': (
 			'OP_RETURN data operations',
@@ -53,27 +61,54 @@ class CmdTestSwap(CmdTestRegtest):
 			('data_tx2_do',      'Creating and sending a transaction with OP_RETURN data (binary)'),
 			('data_tx2_chk',     'Checking the sent transaction'),
 			('generate3',        'Generate 3 blocks'),
+			('bob_listaddrs',    'Display Bob’s addresses'),
+		),
+		'swap': (
+			'Swap operations',
+			('bob_swaptxcreate1', 'Create a swap transaction'),
 		),
 	}
 
 	def __init__(self, trunner, cfgs, spawn):
+
 		super().__init__(trunner, cfgs, spawn)
-		gldict = globals()
+
+		globals_dict = globals()
 		for k in rt_data:
-			gldict[k] = rt_data[k]['btc']
+			globals_dict[k] = rt_data[k]['btc']
+
+		self.protos = [init_proto(cfg, k, network='regtest', need_amt=True) for k in ('btc', 'ltc', 'bch')]
 
 	@property
 	def sid(self):
 		return self._user_sid('bob')
 
+	def _addrgen_bob(self, proto_idx, mmtypes, subseed_idx=None):
+		return self.addrgen('bob', subseed_idx=subseed_idx, mmtypes=mmtypes, proto=self.protos[proto_idx])
+
+	def _addrimport_bob(self, proto_idx):
+		return self.addrimport('bob', mmtypes=['S', 'B'], proto=self.protos[proto_idx])
+
+	def _fund_bob(self, proto_idx, addrtype_code, amt):
+		return self.fund_wallet('bob', addrtype_code, amt, proto=self.protos[proto_idx])
+
+	def _bob_bal(self, proto_idx, bal, skip_check=False):
+		return self.user_bal('bob', bal, proto=self.protos[proto_idx], skip_check=skip_check)
+
 	def addrgen_bob(self):
-		return self.addrgen('bob', mmtypes=['S', 'B'])
+		return self._addrgen_bob(0, ['S', 'B'])
 
 	def addrimport_bob(self):
-		return self.addrimport('bob', mmtypes=['S', 'B'])
+		return self._addrimport_bob(0)
 
-	def fund_bob(self):
-		return self.fund_wallet('bob', 'B', rtFundAmt)
+	def fund_bob1(self):
+		return self._fund_bob(0, 'B', '500')
+
+	def fund_bob2(self):
+		return self._fund_bob(0, 'S', '500')
+
+	def bob_bal(self):
+		return self._bob_bal(0, '1000')
 
 	def data_tx1_create(self):
 		return self._data_tx_create('1', 'B:2', 'B:3', 'data', sample1)
@@ -121,7 +156,7 @@ class CmdTestSwap(CmdTestRegtest):
 	def _data_tx_do(self, src, dest, chg, pfx, sample, view):
 		t = self.user_txdo(
 			user         = 'bob',
-			fee          = rtFee[0],
+			fee          = '30s',
 			outputs_cl   = [f'{self.sid}:{dest},1', f'{self.sid}:{chg}', f'{pfx}:{sample}'],
 			outputs_list = src,
 			add_comment  = 'Transaction with OP_RETURN data',
@@ -151,3 +186,13 @@ class CmdTestSwap(CmdTestRegtest):
 
 	def generate3(self):
 		return self.generate(3)
+
+	def bob_listaddrs(self):
+		t = self.spawn('mmgen-tool', ['--bob', 'listaddresses'])
+		return t
+
+	def bob_swaptxcreate1(self):
+		t = self.spawn(
+			'mmgen-swaptxcreate',
+			['-d', self.tmpdir, '-B', '--bob', 'BTC', '1.234', f'{self.sid}:S:3', 'LTC'])
+		return t

+ 1 - 1
test/daemontest_d/ut_tx.py

@@ -114,7 +114,7 @@ class unit_tests:
 		d.start()
 
 		proto = init_proto(cfg, 'btc', need_amt=True)
-		await NewTX(cfg=cfg, proto=proto)
+		await NewTX(cfg=cfg, proto=proto, target='tx')
 
 		d.stop()
 		d.remove_datadir()

+ 8 - 0
test/include/common.py

@@ -350,6 +350,14 @@ def in_nix_environment():
 			return True
 	return False
 
+def make_burn_addr(proto, mmtype='compressed', hexdata=None):
+	from mmgen.tool.coin import tool_cmd
+	return tool_cmd(
+		cfg     = cfg,
+		cmdname = 'pubhash2addr',
+		proto   = proto,
+		mmtype  = mmtype).pubhash2addr(hexdata or '00'*20)
+
 def VirtBlockDevice(img_path, size):
 	if sys.platform == 'linux':
 		return VirtBlockDeviceLinux(img_path, size)

+ 2 - 2
test/overlay/fakemods/mmgen/tx/new.py

@@ -5,8 +5,8 @@ if overlay_fake_os.getenv('MMGEN_BOGUS_UNSPENT_DATA'):
 
 	class overlay_fake_New(New):
 
-		async def warn_chg_addr_used(self, _):
+		async def warn_addr_used(self, proto, chg, desc):
 			from ..util import ymsg
 			ymsg('Bogus unspent data: skipping used change address check')
 
-	New.warn_chg_addr_used = overlay_fake_New.warn_chg_addr_used
+	New.warn_addr_used = overlay_fake_New.warn_addr_used

+ 1 - 1
test/test-release.sh

@@ -380,7 +380,7 @@ done
 
 in_nix_environment && parity --help >/dev/null 2>&1 || SKIP_PARITY=1
 
-[ "$MMGEN_DISABLE_COLOR" ] || {
+[ "$MMGEN_DISABLE_COLOR" -o ! -t 1 ] || {
 	RED="\e[31;1m" GREEN="\e[32;1m" YELLOW="\e[33;1m" BLUE="\e[34;1m" MAGENTA="\e[35;1m" CYAN="\e[36;1m"
 	RESET="\e[0m"
 }