6 Commits 037c6bfb6f ... 8fd463ecfe

Author SHA1 Message Date
  The MMGen Project 8fd463ecfe proto.btc.tx: support OP_RETURN data outputs 10 months ago
  The MMGen Project 1f1e0a1186 comments, whitespace, minor cleanups 10 months ago
  The MMGen Project 3e8615f6c6 M test/modtest_d/ut_tx.py 10 months ago
  The MMGen Project 1264d4eeba cmdtest.py regtest: minor cleanups 10 months ago
  The MMGen Project 02d736f101 whitespace, variable renames 10 months ago
  The MMGen Project e3dd55e909 proto.btc.tx.base: `scriptPubKey2addr()` -> `decodeScriptPubKey()` 10 months ago

+ 1 - 1
mmgen/data/release_date

@@ -1 +1 @@
-January 2025
+February 2025

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-15.1.dev13
+15.1.dev14

+ 5 - 3
mmgen/devinit.py

@@ -43,14 +43,15 @@ class MMGenObjectDevTools:
 	pexit = lambda *args, **kwargs: MMGenObject_call('pexit', *args, **kwargs)
 	pexit = lambda *args, **kwargs: MMGenObject_call('pexit', *args, **kwargs)
 	pfmt  = lambda *args, **kwargs: MMGenObject_call('pfmt', *args, **kwargs)
 	pfmt  = lambda *args, **kwargs: MMGenObject_call('pfmt', *args, **kwargs)
 
 
-	# Check that all immutables have been initialized.  Expensive, so do only when testing.
+	# Check that all immutable attrs in ‘valid_attrs’ exist and have been initialized.
+	# Expensive, so do only when testing.
 	def immutable_attr_init_check(self):
 	def immutable_attr_init_check(self):
 
 
 		cls = type(self)
 		cls = type(self)
 
 
 		for attrname in self.valid_attrs:
 		for attrname in self.valid_attrs:
-
-			for o in (cls, cls.__bases__[0]): # assume there's only one base class
+			# existence check:
+			for o in cls.__mro__[:3]: # allow for 2 levels of subclassing
 				if attrname in o.__dict__:
 				if attrname in o.__dict__:
 					attr = o.__dict__[attrname]
 					attr = o.__dict__[attrname]
 					break
 					break
@@ -58,6 +59,7 @@ class MMGenObjectDevTools:
 				from .util import die
 				from .util import die
 				die(4, f'unable to find descriptor {cls.__name__}.{attrname}')
 				die(4, f'unable to find descriptor {cls.__name__}.{attrname}')
 
 
+			# initialization check:
 			if type(attr).__name__ == 'ImmutableAttr' and attrname not in self.__dict__:
 			if type(attr).__name__ == 'ImmutableAttr' and attrname not in self.__dict__:
 				from .util import die
 				from .util import die
 				die(4, f'attribute {attrname!r} of {cls.__name__} has not been initialized in constructor!')
 				die(4, f'attribute {attrname!r} of {cls.__name__} has not been initialized in constructor!')

+ 59 - 38
mmgen/help/help_notes.py

@@ -22,11 +22,12 @@ class help_notes:
 
 
 	def txcreate_args(self):
 	def txcreate_args(self):
 		return (
 		return (
-			'<addr,amt>' if self.proto.base_coin == 'ETH' else
-			'[<addr,amt> ...] <change addr, addrlist ID or addr type>')
+			'[ADDR,AMT ... | DATA_SPEC] ADDR <change addr, addrlist ID or addr type>'
+				if self.proto.base_proto == 'Bitcoin' else
+			'ADDR,AMT')
 
 
 	def account_info_desc(self):
 	def account_info_desc(self):
-		return 'account info' if self.proto.base_coin == 'ETH' else 'unspent outputs'
+		return 'unspent outputs' if self.proto.base_proto == 'Bitcoin' else 'account info'
 
 
 	def fee_spec_letters(self, use_quotes=False):
 	def fee_spec_letters(self, use_quotes=False):
 		cu = self.proto.coin_amt.units
 		cu = self.proto.coin_amt.units
@@ -77,14 +78,20 @@ class help_notes:
 
 
 	def address_types(self):
 	def address_types(self):
 		from ..addr import MMGenAddrType
 		from ..addr import MMGenAddrType
-		return '\n  '.join([
-			"'{}','{:<12} - {}".format(k, v.name + "'", v.desc)
-				for k, v in MMGenAddrType.mmtypes.items()
-		])
+		return """
+ADDRESS TYPES:
+
+  Code Type           Description
+  ---- ----           -----------
+  """ + format('\n  '.join(['‘{}’  {:<12} - {}'.format(k, v.name, v.desc)
+				for k, v in MMGenAddrType.mmtypes.items()]))
 
 
 	def fmt_codes(self):
 	def fmt_codes(self):
 		from ..wallet import format_fmt_codes
 		from ..wallet import format_fmt_codes
-		return '\n  '.join(format_fmt_codes().splitlines())
+		return """
+FMT CODES:
+
+  """ + '\n  '.join(format_fmt_codes().splitlines())
 
 
 	def coin_id(self):
 	def coin_id(self):
 		return self.proto.coin_id
 		return self.proto.coin_id
@@ -147,20 +154,7 @@ seed, the same seed length and hash preset parameters must always be used.
 		addr = t.privhex2addr('bead' * 16)
 		addr = t.privhex2addr('bead' * 16)
 		sample_addr = addr.views[addr.view_pref]
 		sample_addr = addr.views[addr.view_pref]
 
 
-		if self.proto.base_coin == 'ETH':
-			return f"""
-EXAMPLES:
-
-  Send 0.123 {self.proto.coin} to an external {self.proto.name} address:
-
-    $ {gc.prog_name} {sample_addr},0.123
-
-  Send 0.123 {self.proto.coin} to another account in wallet 01ABCDEF:
-
-    $ {gc.prog_name} 01ABCDEF:{mmtype}:7,0.123
-"""
-		else:
-			return f"""
+		return f"""
 EXAMPLES:
 EXAMPLES:
 
 
   Send 0.123 {self.proto.coin} to an external {self.proto.name} address, returning the change to a
   Send 0.123 {self.proto.coin} to an external {self.proto.name} address, returning the change to a
@@ -190,23 +184,55 @@ EXAMPLES:
   address of specified type:
   address of specified type:
 
 
     $ {gc.prog_name} {mmtype}
     $ {gc.prog_name} {mmtype}
+""" if self.proto.base_proto == 'Bitcoin' else f"""
+EXAMPLES:
+
+  Send 0.123 {self.proto.coin} to an external {self.proto.name} address:
+
+    $ {gc.prog_name} {sample_addr},0.123
+
+  Send 0.123 {self.proto.coin} to another account in wallet 01ABCDEF:
+
+    $ {gc.prog_name} 01ABCDEF:{mmtype}:7,0.123
 """
 """
 
 
 	def txcreate(self):
 	def txcreate(self):
-		return f"""
+		outputs_info = (
+			"""
+Outputs are specified in the form ADDRESS,AMOUNT or ADDRESS.  The first form
+creates an output sending the given amount to the given address.  The bare
+address form designates the given address as either the change output or the
+sole output of the transaction (excluding any data output).  Exactly one bare
+address argument is required.
+
+For convenience, the bare address argument may be given as ADDRTYPE_CODE or
+SEED_ID:ADDRTYPE_CODE (see ADDRESS TYPES below). In the first form, the first
+unused address of type ADDRTYPE_CODE for each Seed ID in the tracking wallet
+will be displayed in a menu, with the user prompted to select one.  In the
+second form, the user specifies the Seed ID as well, allowing the script to
+select the transaction’s change output or single output without prompting.
+See EXAMPLES below.
+
+A single DATA_SPEC argument may also be given on the command line to create
+an OP_RETURN data output with a zero spend amount.  This is the preferred way
+to embed data in the blockchain.  DATA_SPEC may be of the form "data":DATA
+or "hexdata":DATA. In the first form, DATA is a string in your system’s native
+encoding, typically UTF-8.  In the second, DATA is a hexadecimal string (with
+the leading ‘0x’ omitted) encoding the binary data to be embedded.  In both
+cases, the resulting byte string must not exceed {bl} bytes in length.
+""".format(bl=self.proto.max_op_return_data_len)
+		if self.proto.base_proto == 'Bitcoin' else """
+The transaction output is specified in the form ADDRESS,AMOUNT.
+""")
+
+		return """
 The transaction’s outputs are listed on the command line, while its inputs
 The transaction’s outputs are listed on the command line, while its inputs
 are chosen from a list of the wallet’s unspent outputs via an interactive
 are chosen from a list of the wallet’s unspent outputs via an interactive
 menu.  Alternatively, inputs may be specified using the --inputs option.
 menu.  Alternatively, inputs may be specified using the --inputs option.
 
 
-All addresses on the command line can be either {self.proto.name} addresses or MMGen
-IDs in the form <seed ID>:<address type letter>:<index>.
-
-Outputs are specified in the form <address>,<amount>, with the change output
-specified by address only.  Alternatively, the change output may be an
-addrlist ID in the form <seed ID>:<address type letter>, in which case the
-first unused address in the tracking wallet matching the requested ID will
-be automatically selected as the change output.
-
+Addresses on the command line can be either native coin addresses or MMGen
+IDs in the form SEED_ID:ADDRTYPE_CODE:INDEX.
+{oinfo}
 If the transaction fee is not specified on the command line (see FEE
 If the transaction fee is not specified on the command line (see FEE
 SPECIFICATION below), it will be calculated dynamically using network fee
 SPECIFICATION below), it will be calculated dynamically using network fee
 estimation for the default (or user-specified) number of confirmations.
 estimation for the default (or user-specified) number of confirmations.
@@ -214,12 +240,7 @@ If network fee estimation fails, the user will be prompted for a fee.
 
 
 Network-estimated fees will be multiplied by the value of --fee-adjust, if
 Network-estimated fees will be multiplied by the value of --fee-adjust, if
 specified.
 specified.
-
-To send the value of all inputs (minus TX fee) to a single output, specify
-a single address with no amount on the command line.  Alternatively, an
-addrlist ID may be specified, and the address will be chosen automatically
-as described above for the change output.
-"""
+""".format(oinfo=outputs_info)
 
 
 	def txsign(self):
 	def txsign(self):
 		from ..proto.btc.params import mainnet
 		from ..proto.btc.params import mainnet

+ 2 - 5
mmgen/main_addrgen.py

@@ -90,17 +90,14 @@ range(s).
 
 
 {n_addrkey}If available, the libsecp256k1 library will be used for address generation.
 {n_addrkey}If available, the libsecp256k1 library will be used for address generation.
 
 
-ADDRESS TYPES:
-  {n_at}
-
 
 
                       NOTES FOR ALL GENERATOR COMMANDS
                       NOTES FOR ALL GENERATOR COMMANDS
 
 
 {n_sw}{n_pw}{n_bw}
 {n_sw}{n_pw}{n_bw}
 
 
-FMT CODES:
+{n_at}
 
 
-  {n_fmt}
+{n_fmt}
 """
 """
 	},
 	},
 	'code': {
 	'code': {

+ 5 - 8
mmgen/main_msg.py

@@ -126,16 +126,13 @@ export - dump signed MMGen message file to ‘signatures.json’, including only
 The `create` operation takes one or more ADDRESS_SPEC arguments with the
 The `create` operation takes one or more ADDRESS_SPEC arguments with the
 following format:
 following format:
 
 
-    SEED_ID:ADDR_TYPE:ADDR_IDX_SPEC
+    SEED_ID:ADDRTYPE_CODE:ADDR_IDX_SPEC
 
 
-where ADDR_TYPE is an address type letter from the list below, and
-ADDR_IDX_SPEC is a comma-separated list of address indexes or hyphen-
-separated address index ranges.
+where ADDRTYPE_CODE is a one-letter address type code from the list below, and
+ADDR_IDX_SPEC is a comma-separated list of address indexes or hyphen-separated
+address index ranges.
 
 
-
-                                ADDRESS TYPES
-
-  {n_at}
+{n_at}
 
 
 
 
                                     NOTES
                                     NOTES

+ 1 - 3
mmgen/main_passgen.py

@@ -110,9 +110,7 @@ EXAMPLES:
 
 
 {n_bw}
 {n_bw}
 
 
-FMT CODES:
-
-  {n_fmt}
+{n_fmt}
 """
 """
 	},
 	},
 	'code': {
 	'code': {

+ 1 - 3
mmgen/main_seedjoin.py

@@ -73,9 +73,7 @@ For usage examples, see the help screen for the 'mmgen-seedsplit' command.
 
 
 {n_pw}
 {n_pw}
 
 
-FMT CODES:
-
-  {f}
+{f}
 """
 """
 	},
 	},
 	'code': {
 	'code': {

+ 1 - 3
mmgen/main_txbump.py

@@ -84,9 +84,7 @@ opts_data = {
 Seed source files must have the canonical extensions listed in the 'FileExt'
 Seed source files must have the canonical extensions listed in the 'FileExt'
 column below:
 column below:
 
 
-FMT CODES:
-
-  {f}
+{f}
 """
 """
 	},
 	},
 	'code': {
 	'code': {

+ 3 - 2
mmgen/main_txcreate.py

@@ -66,7 +66,7 @@ opts_data = {
 			-- -y, --yes             Answer 'yes' to prompts, suppress non-essential output
 			-- -y, --yes             Answer 'yes' to prompts, suppress non-essential output
 			e- -X, --cached-balances Use cached balances
 			e- -X, --cached-balances Use cached balances
 		""",
 		""",
-		'notes': '\n{c}\n{F}\n{x}',
+		'notes': '\n{c}\n{n_at}\n\n{F}\n{x}',
 	},
 	},
 	'code': {
 	'code': {
 		'usage': lambda cfg, proto, help_notes, s: s.format(
 		'usage': lambda cfg, proto, help_notes, s: s.format(
@@ -82,7 +82,8 @@ opts_data = {
 		'notes': lambda cfg, help_notes, s: s.format(
 		'notes': lambda cfg, help_notes, s: s.format(
 			c      = help_notes('txcreate'),
 			c      = help_notes('txcreate'),
 			F      = help_notes('fee'),
 			F      = help_notes('fee'),
-			x      = help_notes('txcreate_examples'))
+			x      = help_notes('txcreate_examples'),
+			n_at   = help_notes('address_types'))
 	}
 	}
 }
 }
 
 

+ 3 - 2
mmgen/main_txdo.py

@@ -95,9 +95,9 @@ opts_data = {
 Seed source files must have the canonical extensions listed in the 'FileExt'
 Seed source files must have the canonical extensions listed in the 'FileExt'
 column below:
 column below:
 
 
-FMT CODES:
+{n_at}
 
 
-  {f}
+{f}
 
 
 {x}"""
 {x}"""
 	},
 	},
@@ -124,6 +124,7 @@ FMT CODES:
 			c       = help_notes('txcreate'),
 			c       = help_notes('txcreate'),
 			F       = help_notes('fee'),
 			F       = help_notes('fee'),
 			s       = help_notes('txsign'),
 			s       = help_notes('txsign'),
+			n_at    = help_notes('address_types'),
 			f       = help_notes('fmt_codes'),
 			f       = help_notes('fmt_codes'),
 			x       = help_notes('txcreate_examples')),
 			x       = help_notes('txcreate_examples')),
 	}
 	}

+ 1 - 3
mmgen/main_txsign.py

@@ -74,9 +74,7 @@ opts_data = {
 Seed source files must have the canonical extensions listed in the 'FileExt'
 Seed source files must have the canonical extensions listed in the 'FileExt'
 column below:
 column below:
 
 
-FMT CODES:
-
-  {f}
+{f}
 """
 """
 	},
 	},
 	'code': {
 	'code': {

+ 1 - 3
mmgen/main_wallet.py

@@ -122,9 +122,7 @@ opts_data = {
 
 
 {n_ss}{n_sw}{n_pw}{n_bw}
 {n_ss}{n_sw}{n_pw}{n_bw}
 
 
-FMT CODES:
-
-  {f}
+{f}
 """
 """
 	},
 	},
 	'code': {
 	'code': {

+ 2 - 3
mmgen/obj.py

@@ -107,10 +107,9 @@ class ImmutableAttr: # Descriptor
 		self.typeconv = typeconv
 		self.typeconv = typeconv
 
 
 		assert isinstance(dtype, self.ok_dtypes), 'ImmutableAttr_check1'
 		assert isinstance(dtype, self.ok_dtypes), 'ImmutableAttr_check1'
-		if include_proto:
-			assert typeconv, 'ImmutableAttr_check2'
+
 		if set_none_ok:
 		if set_none_ok:
-			assert typeconv and not isinstance(dtype, str), 'ImmutableAttr_check3'
+			assert typeconv and not isinstance(dtype, str), 'ImmutableAttr_check2'
 
 
 		if typeconv:
 		if typeconv:
 			# convert this attribute's type
 			# convert this attribute's type

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

@@ -51,6 +51,7 @@ class mainnet(CoinProtocol.Secp256k1): # chainparams.cpp
 	max_halvings    = 64
 	max_halvings    = 64
 	start_subsidy   = 50
 	start_subsidy   = 50
 	max_int         = 0xffffffff
 	max_int         = 0xffffffff
+	max_op_return_data_len = 80
 
 
 	coin_cfg_opts = (
 	coin_cfg_opts = (
 		'ignore_daemon_version',
 		'ignore_daemon_version',

+ 47 - 13
mmgen/proto/btc/tx/base.py

@@ -14,10 +14,16 @@ proto.btc.tx.base: Bitcoin base transaction class
 
 
 from collections import namedtuple
 from collections import namedtuple
 
 
+from ....addr import CoinAddr
 from ....tx import base as TxBase
 from ....tx import base as TxBase
-from ....obj import MMGenList, HexStr
+from ....obj import MMGenList, HexStr, ListItemAttr
 from ....util import msg, make_chksum_6, die, pp_fmt
 from ....util import msg, make_chksum_6, die, pp_fmt
 
 
+from .op_return_data import OpReturnData
+
+def data2scriptPubKey(data):
+	return '6a' + '{:02x}'.format(len(data)) + data.hex() # OP_RETURN data
+
 def addr2scriptPubKey(proto, addr):
 def addr2scriptPubKey(proto, addr):
 
 
 	def decode_addr(proto, addr):
 	def decode_addr(proto, addr):
@@ -31,15 +37,31 @@ def addr2scriptPubKey(proto, addr):
 		'bech32': proto.witness_vernum_hex + '14' + decode_addr(proto, addr)
 		'bech32': proto.witness_vernum_hex + '14' + decode_addr(proto, addr)
 	}[addr.addr_fmt]
 	}[addr.addr_fmt]
 
 
-def scriptPubKey2addr(proto, s):
+def decodeScriptPubKey(proto, s):
+	# src/wallet/rpc/addresses.cpp:
+	#   types: nonstandard, pubkey, pubkeyhash, scripthash, multisig, nulldata, witness_v0_keyhash
+	ret = namedtuple('decoded_scriptPubKey', ['type', 'addr_fmt', 'addr', 'data'])
+
 	if len(s) == 50 and s[:6] == '76a914' and s[-4:] == '88ac':
 	if len(s) == 50 and s[:6] == '76a914' and s[-4:] == '88ac':
-		return proto.pubhash2addr(bytes.fromhex(s[6:-4]), 'p2pkh'), 'p2pkh'
+		return ret('pubkeyhash', 'p2pkh', proto.pubhash2addr(bytes.fromhex(s[6:-4]), 'p2pkh'), None)
+
 	elif len(s) == 46 and s[:4] == 'a914' and s[-2:] == '87':
 	elif len(s) == 46 and s[:4] == 'a914' and s[-2:] == '87':
-		return proto.pubhash2addr(bytes.fromhex(s[4:-2]), 'p2sh'), 'p2sh'
+		return ret('scripthash', 'p2sh', proto.pubhash2addr(bytes.fromhex(s[4:-2]), 'p2sh'), None)
+
 	elif len(s) == 44 and s[:4] == proto.witness_vernum_hex + '14':
 	elif len(s) == 44 and s[:4] == proto.witness_vernum_hex + '14':
-		return proto.pubhash2bech32addr(bytes.fromhex(s[4:])), 'bech32'
+		return ret('witness_v0_keyhash', 'bech32', proto.pubhash2bech32addr(bytes.fromhex(s[4:])), None)
+
+	elif s[:2] == '6a': # OP_RETURN
+		# range 1-80 == hex 2-160, plus 4 for opcode byte + push byte
+		if 6 <= len(s) <= (proto.max_op_return_data_len * 2) + 6: # 2-160 -> 6-166
+			return ret('nulldata', None, None, s[4:]) # return data in hex format
+		else:
+			raise ValueError('{}: OP_RETURN data bytes length not in range 1-{}'.format(
+					len(s[4:]) // 2,
+					proto.max_op_return_data_len))
+
 	else:
 	else:
-		raise NotImplementedError(f'Unknown scriptPubKey ({s})')
+		raise NotImplementedError(f'Unrecognized scriptPubKey ({s})')
 
 
 def DeserializeTX(proto, txhex):
 def DeserializeTX(proto, txhex):
 	"""
 	"""
@@ -119,7 +141,7 @@ def DeserializeTX(proto, txhex):
 	} for i in range(d['num_txouts'])])
 	} for i in range(d['num_txouts'])])
 
 
 	for o in d['txouts']:
 	for o in d['txouts']:
-		o['address'] = scriptPubKey2addr(proto, o['scriptPubKey'])[0]
+		o.update(decodeScriptPubKey(proto, o['scriptPubKey'])._asdict())
 
 
 	if has_witness:
 	if has_witness:
 		# https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki
 		# https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki
@@ -152,6 +174,10 @@ class Base(TxBase.Base):
 	rel_fee_disp = 'sat/byte'
 	rel_fee_disp = 'sat/byte'
 	_deserialized = None
 	_deserialized = None
 
 
+	class Output(TxBase.Base.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
+
 	class InputList(TxBase.Base.InputList):
 	class InputList(TxBase.Base.InputList):
 
 
 		# Lexicographical Indexing of Transaction Inputs and Outputs
 		# Lexicographical Indexing of Transaction Inputs and Outputs
@@ -169,7 +195,9 @@ class Base(TxBase.Base):
 			def sort_func(a):
 			def sort_func(a):
 				return (
 				return (
 					int.to_bytes(a.amt.to_unit('satoshi'), 8, 'big')
 					int.to_bytes(a.amt.to_unit('satoshi'), 8, 'big')
-					+ bytes.fromhex(addr2scriptPubKey(self.parent.proto, a.addr)))
+					+ bytes.fromhex(
+						addr2scriptPubKey(self.parent.proto, a.addr) if a.addr else
+						data2scriptPubKey(a.data)))
 			self.sort(key=sort_func)
 			self.sort(key=sort_func)
 
 
 	def has_segwit_inputs(self):
 	def has_segwit_inputs(self):
@@ -215,9 +243,15 @@ class Base(TxBase.Base):
 			return ret + sum(input_size['C'] for i in self.inputs if not i.mmtype)
 			return ret + sum(input_size['C'] for i in self.inputs if not i.mmtype)
 
 
 		def get_outputs_size():
 		def get_outputs_size():
-			# output bytes = amt: 8, byte_count: 1+, pk_script
-			# pk_script bytes: p2pkh: 25, p2sh: 23, bech32: 22
-			return sum({'p2pkh':34, 'p2sh':32, 'bech32':31}[o.addr.addr_fmt] for o in self.outputs)
+			# output bytes:
+			#   8 (amt) + scriptlen_byte + script_bytes
+			#   script_bytes:
+			#     ADDR: p2pkh: 25, p2sh: 23, bech32: 22
+			#     DATA: opcode_byte ('6a') + push_byte + nulldata_bytes
+			return sum(
+				{'p2pkh':34, 'p2sh':32, 'bech32':31}[o.addr.addr_fmt] if o.addr else
+				(11 + len(o.data))
+					for o in self.outputs)
 
 
 		# https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki
 		# https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki
 		# The witness is a serialization of all witness data of the transaction. Each txin is
 		# The witness is a serialization of all witness data of the transaction. Each txin is
@@ -317,8 +351,8 @@ class Base(TxBase.Base):
 
 
 		check_equal(
 		check_equal(
 			'outputs',
 			'outputs',
-			sorted((o['address'], o['amt']) for o in dtx.txouts),
-			sorted((o.addr, o.amt) for o in self.outputs))
+			sorted((o['addr'] or o['data'], o['amt']) for o in dtx.txouts),
+			sorted((o.addr or o.data.hex(), o.amt) for o in self.outputs))
 
 
 		if str(self.txid) != make_chksum_6(bytes.fromhex(dtx.unsigned_hex)).upper():
 		if str(self.txid) != make_chksum_6(bytes.fromhex(dtx.unsigned_hex)).upper():
 			do_error(f'MMGen TxID ({self.txid}) does not match serialized transaction data!')
 			do_error(f'MMGen TxID ({self.txid}) does not match serialized transaction data!')

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

@@ -15,7 +15,7 @@ proto.btc.tx.completed: Bitcoin completed transaction class
 from ....tx import completed as TxBase
 from ....tx import completed as TxBase
 from ....obj import HexStr
 from ....obj import HexStr
 from ....util import msg, die
 from ....util import msg, die
-from .base import Base, scriptPubKey2addr
+from .base import Base, decodeScriptPubKey
 
 
 class Completed(Base, TxBase.Completed):
 class Completed(Base, TxBase.Completed):
 	fn_fee_unit = 'satoshi'
 	fn_fee_unit = 'satoshi'
@@ -45,17 +45,17 @@ class Completed(Base, TxBase.Completed):
 
 
 	def check_pubkey_scripts(self):
 	def check_pubkey_scripts(self):
 		for n, i in enumerate(self.inputs, 1):
 		for n, i in enumerate(self.inputs, 1):
-			addr, fmt = scriptPubKey2addr(self.proto, i.scriptPubKey)
-			if i.addr != addr:
-				if fmt != i.addr.addr_fmt:
+			ds = decodeScriptPubKey(self.proto, i.scriptPubKey)
+			if ds.addr != i.addr:
+				if ds.addr_fmt != i.addr.addr_fmt:
 					m = 'Address format of scriptPubKey ({}) does not match that of address ({}) in input #{}'
 					m = 'Address format of scriptPubKey ({}) does not match that of address ({}) in input #{}'
-					msg(m.format(fmt, i.addr.addr_fmt, n))
+					msg(m.format(ds.addr_fmt, i.addr.addr_fmt, n))
 				m = 'ERROR: Address and scriptPubKey of transaction input #{} do not match!'
 				m = 'ERROR: Address and scriptPubKey of transaction input #{} do not match!'
 				die(3, (m+'\n  {:23}{}'*3).format(
 				die(3, (m+'\n  {:23}{}'*3).format(
 					n,
 					n,
 					'address:',               i.addr,
 					'address:',               i.addr,
 					'scriptPubKey:',          i.scriptPubKey,
 					'scriptPubKey:',          i.scriptPubKey,
-					'scriptPubKey->address:', addr))
+					'scriptPubKey->address:', ds.addr))
 
 
 #	def is_replaceable_from_rpc(self):
 #	def is_replaceable_from_rpc(self):
 #		dec_tx = await self.rpc.call('decoderawtransaction', self.serialized)
 #		dec_tx = await self.rpc.call('decoderawtransaction', self.serialized)

+ 13 - 7
mmgen/proto/btc/tx/info.py

@@ -14,7 +14,7 @@ proto.btc.tx.info: Bitcoin transaction info class
 
 
 from ....tx.info import TxInfo
 from ....tx.info import TxInfo
 from ....util import fmt, die
 from ....util import fmt, die
-from ....color import red, green, pink
+from ....color import red, green, pink, blue
 from ....addr import MMGenID
 from ....addr import MMGenID
 
 
 class TxInfo(TxInfo):
 class TxInfo(TxInfo):
@@ -79,17 +79,20 @@ class TxInfo(TxInfo):
 				'raw':  lambda: io
 				'raw':  lambda: io
 			}[sort]
 			}[sort]
 
 
+			def data_disp(data):
+				return f'OP_RETURN data ({len(data)} bytes)'
+
 			if terse:
 			if terse:
 				iwidth = max(len(str(int(e.amt))) for e in io)
 				iwidth = max(len(str(int(e.amt))) for e in io)
-				addr_w = max(len(e.addr.views[vp1]) for f in (tx.inputs, tx.outputs) for e in f)
+				addr_w = max((len(e.addr.views[vp1]) if e.addr else len(data_disp(e.data))) for f in (tx.inputs, tx.outputs) for e in f)
 				for n, e in enumerate(io_sorted()):
 				for n, e in enumerate(io_sorted()):
 					yield '{:3} {} {} {} {}\n'.format(
 					yield '{:3} {} {} {} {}\n'.format(
 						n+1,
 						n+1,
-						e.addr.fmt(vp1, width=addr_w, color=True),
-						get_mmid_fmt(e, is_input),
+						e.addr.fmt(vp1, width=addr_w, color=True) if e.addr else blue(data_disp(e.data).ljust(addr_w)),
+						get_mmid_fmt(e, is_input) if e.addr else ''.ljust(max_mmwid),
 						e.amt.fmt(iwidth=iwidth, color=True),
 						e.amt.fmt(iwidth=iwidth, color=True),
 						tx.dcoin)
 						tx.dcoin)
-					if have_bch:
+					if have_bch and e.addr:
 						yield '{:3} [{}]\n'.format('', e.addr.hl(vp2, color=False))
 						yield '{:3} [{}]\n'.format('', e.addr.hl(vp2, color=False))
 			else:
 			else:
 				col1_w = len(str(len(io))) + 1
 				col1_w = len(str(len(io))) + 1
@@ -105,8 +108,11 @@ class TxInfo(TxInfo):
 							if have_bch:
 							if have_bch:
 								yield ('', '', f'[{e.addr.hl(vp2, color=False)}]')
 								yield ('', '', f'[{e.addr.hl(vp2, color=False)}]')
 						else:
 						else:
-							yield (n+1, 'address:', f'{e.addr.hl(vp1)} {mmid_fmt}')
-							if have_bch:
+							yield (
+								n + 1,
+								'address:',
+								(f'{e.addr.hl(vp1)} {mmid_fmt}' if e.addr else e.data.hl(add_label=True)))
+							if have_bch and e.addr:
 								yield ('', '', f'[{e.addr.hl(vp2, color=False)}]')
 								yield ('', '', f'[{e.addr.hl(vp2, color=False)}]')
 						if e.comment:
 						if e.comment:
 							yield ('',  'comment:', e.comment.hl())
 							yield ('',  'comment:', e.comment.hl())

+ 10 - 1
mmgen/proto/btc/tx/new.py

@@ -23,6 +23,15 @@ class New(Base, TxBase.New):
 	fee_fail_fs = 'Network fee estimation for {c} confirmations failed ({t})'
 	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'
 	no_chg_msg = 'Warning: Change address will be deleted as transaction produces no change'
 
 
+	def process_data_output_arg(self, arg):
+		if any(arg.startswith(pfx) for pfx in ('data:', 'hexdata:')):
+			if hasattr(self, '_have_op_return_data'):
+				die(1, 'Transaction may have at most one OP_RETURN data output!')
+			self._have_op_return_data = True
+			from .op_return_data import OpReturnData
+			OpReturnData(self.proto, arg) # test data for validity
+			return arg
+
 	@property
 	@property
 	def relay_fee(self):
 	def relay_fee(self):
 		kb_fee = self.proto.coin_amt(self.rpc.cached['networkinfo']['relayfee'])
 		kb_fee = self.proto.coin_amt(self.rpc.cached['networkinfo']['relayfee'])
@@ -134,7 +143,7 @@ class New(Base, TxBase.New):
 				'sequence': e.sequence
 				'sequence': e.sequence
 			} for e in self.inputs]
 			} for e in self.inputs]
 
 
-		outputs_dict = {e.addr:e.amt for e in self.outputs}
+		outputs_dict = dict((e.addr, e.amt) if e.addr else ('data', e.data.hex()) for e in self.outputs)
 
 
 		ret = await self.rpc.call('createrawtransaction', inputs_list, outputs_dict)
 		ret = await self.rpc.call('createrawtransaction', inputs_list, outputs_dict)
 
 

+ 71 - 0
mmgen/proto/btc/tx/op_return_data.py

@@ -0,0 +1,71 @@
+#!/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.op_return_data: Bitcoin OP_RETURN data class
+"""
+
+from ....obj import InitErrors
+
+class OpReturnData(bytes, InitErrors):
+
+	def __new__(cls, proto, data_spec):
+
+		desc = 'OpReturnData initializer'
+
+		assert isinstance(data_spec, str), f'{desc} must be a string'
+
+		if data_spec.startswith('hexdata:'):
+			hexdata = data_spec[8:]
+			from ....util import is_hex_str
+			assert is_hex_str(hexdata), f'{hexdata!r}: {desc} hexdata not in hexadecimal format'
+			assert not len(hexdata) % 2, f'{len(hexdata)}: {desc} hexdata of non-even length'
+			ret = bytes.fromhex(hexdata)
+		elif data_spec.startswith('data:'):
+			try:
+				ret = data_spec[5:].encode('utf8')
+			except:
+				raise ValueError(f'{desc} string be UTF-8 encoded')
+		else:
+			raise ValueError(f'{desc} string must start with ‘data:’ or ‘hexdata:’')
+
+		assert 1 <= len(ret) <= proto.max_op_return_data_len, (
+			f'{len(ret)}: {desc} string encoded byte length not in range 1-{proto.max_op_return_data_len}')
+
+		return bytes.__new__(cls, ret)
+
+	def __repr__(self):
+		'return an initialization string'
+		ret = str(self)
+		return ('hexdata:' if self.display_hex else 'data:') + ret
+
+	def __str__(self):
+		'return something suitable for display to the user'
+		self.display_hex = True
+		try:
+			ret = self.decode('utf8')
+		except:
+			return self.hex()
+		else:
+			import unicodedata
+			for ch in ret:
+				if ch == '\n' or unicodedata.category(ch)[0] in ('C', 'M'): # see MMGenLabel
+					return self.hex()
+			self.display_hex = False
+			return ret
+
+	def hl(self, add_label=False):
+		'colorize and optionally label the result of str()'
+		from ....color import blue, pink
+		ret = str(self)
+		if add_label:
+			return blue('OP_RETURN data' + (' (hex): ' if self.display_hex else ': ')) + pink(ret)
+		else:
+			return pink(ret)

+ 1 - 2
mmgen/rpc.py

@@ -21,7 +21,6 @@ rpc: Cryptocoin RPC library for the MMGen suite
 """
 """
 
 
 import sys, re, base64, json, asyncio, importlib
 import sys, re, base64, json, asyncio, importlib
-from decimal import Decimal
 from collections import namedtuple
 from collections import namedtuple
 
 
 from .util import msg, ymsg, die, fmt, fmt_list, pp_fmt, oneshot_warning
 from .util import msg, ymsg, die, fmt, fmt_list, pp_fmt, oneshot_warning
@@ -75,7 +74,7 @@ class IPPort(HiliteStr, InitErrors):
 
 
 class json_encoder(json.JSONEncoder):
 class json_encoder(json.JSONEncoder):
 	def default(self, o):
 	def default(self, o):
-		if isinstance(o, Decimal):
+		if type(o).__name__.endswith('Amt'):
 			return str(o)
 			return str(o)
 		else:
 		else:
 			return json.JSONEncoder.default(self, o)
 			return json.JSONEncoder.default(self, o)

+ 2 - 2
mmgen/tool/coin.py

@@ -184,8 +184,8 @@ class tool_cmd(tool_cmd_base):
 
 
 	def scriptpubkey2addr(self, hexstr: 'sstr'):
 	def scriptpubkey2addr(self, hexstr: 'sstr'):
 		"convert scriptPubKey to coin address"
 		"convert scriptPubKey to coin address"
-		from ..proto.btc.tx.base import scriptPubKey2addr
-		return scriptPubKey2addr(self.proto, hexstr)[0]
+		from ..proto.btc.tx.base import decodeScriptPubKey
+		return decodeScriptPubKey(self.proto, hexstr).addr
 
 
 	def eth_checksummed_addr(self, addr: 'sstr'):
 	def eth_checksummed_addr(self, addr: 'sstr'):
 		"create a checksummed Ethereum address"
 		"create a checksummed Ethereum address"

+ 5 - 3
mmgen/tx/base.py

@@ -55,7 +55,8 @@ class MMGenTxIO(MMGenListItem):
 			str(self.mmid.mmtype) if self.mmid else
 			str(self.mmid.mmtype) if self.mmid else
 			'B' if self.addr.addr_fmt == 'bech32' else
 			'B' if self.addr.addr_fmt == 'bech32' else
 			'S' if self.addr.addr_fmt == 'p2sh' else
 			'S' if self.addr.addr_fmt == 'p2sh' else
-			None)
+			None
+		) if self.addr else None
 
 
 class MMGenTxIOList(list, MMGenObject):
 class MMGenTxIOList(list, MMGenObject):
 
 
@@ -91,12 +92,13 @@ class Base(MMGenObject):
 	file_format = 'json'
 	file_format = 'json'
 
 
 	class Input(MMGenTxIO):
 	class Input(MMGenTxIO):
-		scriptPubKey = ListItemAttr(HexStr)
-		sequence     = ListItemAttr(int, typeconv=False)
+		scriptPubKey  = ListItemAttr(HexStr)
+		sequence      = ListItemAttr(int, typeconv=False)
 		tw_copy_attrs = {'scriptPubKey', 'vout', 'amt', 'comment', 'mmid', 'addr', 'confs', 'txid'}
 		tw_copy_attrs = {'scriptPubKey', 'vout', 'amt', 'comment', 'mmid', 'addr', 'confs', 'txid'}
 
 
 	class Output(MMGenTxIO):
 	class Output(MMGenTxIO):
 		is_chg = ListItemAttr(bool, typeconv=False)
 		is_chg = ListItemAttr(bool, typeconv=False)
+		data   = ListItemAttr(None, typeconv=False) # placeholder
 
 
 	class InputList(MMGenTxIOList):
 	class InputList(MMGenTxIOList):
 		desc = 'transaction inputs'
 		desc = 'transaction inputs'

+ 10 - 2
mmgen/tx/file.py

@@ -24,10 +24,18 @@ import os, json
 
 
 from ..util import ymsg, make_chksum_6, die
 from ..util import ymsg, make_chksum_6, die
 from ..obj import MMGenObject, HexStr, MMGenTxID, CoinTxID, MMGenTxComment
 from ..obj import MMGenObject, HexStr, MMGenTxID, CoinTxID, MMGenTxComment
-from ..rpc import json_encoder
+
+class txdata_json_encoder(json.JSONEncoder):
+	def default(self, o):
+		if type(o).__name__.endswith('Amt'):
+			return str(o)
+		elif type(o).__name__ == 'OpReturnData':
+			return repr(o)
+		else:
+			return json.JSONEncoder.default(self, o)
 
 
 def json_dumps(data):
 def json_dumps(data):
-	return json.dumps(data, separators = (',', ':'), cls=json_encoder)
+	return json.dumps(data, separators = (',', ':'), cls=txdata_json_encoder)
 
 
 def get_proto_from_coin_id(tx, coin_id, chain):
 def get_proto_from_coin_id(tx, coin_id, chain):
 	coin, tokensym = coin_id.split(':') if ':' in coin_id else (coin_id, None)
 	coin, tokensym = coin_id.split(':') if ':' in coin_id else (coin_id, None)

+ 22 - 13
mmgen/tx/new.py

@@ -101,11 +101,11 @@ class New(Base):
 				if f:
 				if f:
 					e.comment = f
 					e.comment = f
 
 
-	def check_dup_addrs(self, io_str):
-		assert io_str in ('inputs', 'outputs')
-		addrs = [e.addr for e in getattr(self, io_str)]
+	def check_dup_addrs(self, io_desc):
+		assert io_desc in ('inputs', 'outputs')
+		addrs = [e.addr for e in getattr(self, io_desc) if e.addr]
 		if len(addrs) != len(set(addrs)):
 		if len(addrs) != len(set(addrs)):
-			die(2, f'{addrs}: duplicate address in transaction {io_str}')
+			die(2, f'{addrs}: duplicate address in transaction {io_desc}')
 
 
 	# given tx size and absolute fee or fee spec, return absolute fee
 	# given tx size and absolute fee or fee spec, return absolute fee
 	# relative fee is N+<first letter of unit name>
 	# relative fee is N+<first letter of unit name>
@@ -161,12 +161,18 @@ class New(Base):
 			return False
 			return False
 		return True
 		return True
 
 
-	def add_output(self, coinaddr, amt, is_chg=None):
-		self.outputs.append(self.Output(self.proto, addr=coinaddr, amt=amt, is_chg=is_chg))
+	def add_output(self, coinaddr, amt, is_chg=False, data=None):
+		self.outputs.append(self.Output(self.proto, addr=coinaddr, amt=amt, is_chg=is_chg, data=data))
+
+	def process_data_output_arg(self, arg):
+		return None
 
 
 	def parse_cmd_arg(self, arg_in, ad_f, ad_w):
 	def parse_cmd_arg(self, arg_in, ad_f, ad_w):
 
 
-		_pa = namedtuple('parsed_txcreate_cmdline_arg', ['arg', 'mmid', 'coin_addr', 'amt'])
+		_pa = namedtuple('parsed_txcreate_cmdline_arg', ['arg', 'mmid', 'coin_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)
 		arg, amt = arg_in.split(',', 1) if ',' in arg_in else (arg_in, None)
 
 
@@ -182,7 +188,7 @@ class New(Base):
 		else:
 		else:
 			die(2, f'{arg_in}: invalid command-line argument')
 			die(2, f'{arg_in}: invalid command-line argument')
 
 
-		return _pa(arg, mmid, coin_addr, amt)
+		return _pa(arg, mmid, coin_addr, amt, None)
 
 
 	async def process_cmd_args(self, cmd_args, ad_f, ad_w):
 	async def process_cmd_args(self, cmd_args, ad_f, ad_w):
 
 
@@ -208,17 +214,20 @@ class New(Base):
 
 
 		parsed_args = [self.parse_cmd_arg(a, ad_f, ad_w) for a in cmd_args]
 		parsed_args = [self.parse_cmd_arg(a, ad_f, ad_w) for a in cmd_args]
 
 
-		chg_args = [a for a in parsed_args if not (a.amt and a.coin_addr)]
+		chg_args = [a for a in parsed_args if not ((a.amt and a.coin_addr) or a.data)]
 
 
 		if len(chg_args) > 1:
 		if len(chg_args) > 1:
 			desc = 'requested' if self.chg_autoselected else 'listed'
 			desc = 'requested' if self.chg_autoselected else 'listed'
 			die(2, f'ERROR: More than one change address {desc} on command line')
 			die(2, f'ERROR: More than one change address {desc} on command line')
 
 
 		for a in parsed_args:
 		for a in parsed_args:
-			self.add_output(
-				coinaddr = a.coin_addr or (await get_autochg_addr(a.arg, parsed_args)).addr,
-				amt      = self.proto.coin_amt(a.amt or '0'),
-				is_chg   = not a.amt)
+			if a.data:
+				self.add_output(None, self.proto.coin_amt('0'), data=a.data)
+			else:
+				self.add_output(
+					coinaddr = a.coin_addr or (await get_autochg_addr(a.arg, parsed_args)).addr,
+					amt      = self.proto.coin_amt(a.amt or '0'),
+					is_chg   = not a.amt)
 
 
 		if self.chg_idx is None:
 		if self.chg_idx is None:
 			die(2,
 			die(2,

+ 2 - 0
test/cmdtest_d/cfg.py

@@ -38,6 +38,7 @@ cmd_groups_dfl = {
 	'autosign_automount': ('CmdTestAutosignAutomount', {'modname': 'automount'}),
 	'autosign_automount': ('CmdTestAutosignAutomount', {'modname': 'automount'}),
 	'autosign_eth':       ('CmdTestAutosignETH',       {'modname': 'automount_eth'}),
 	'autosign_eth':       ('CmdTestAutosignETH',       {'modname': 'automount_eth'}),
 	'regtest':            ('CmdTestRegtest',           {}),
 	'regtest':            ('CmdTestRegtest',           {}),
+	'swap':               ('CmdTestSwap',              {}),
 	# 'chainsplit':         ('CmdTestChainsplit',      {}),
 	# 'chainsplit':         ('CmdTestChainsplit',      {}),
 	'ethdev':             ('CmdTestEthdev',            {}),
 	'ethdev':             ('CmdTestEthdev',            {}),
 	'xmrwallet':          ('CmdTestXMRWallet',         {}),
 	'xmrwallet':          ('CmdTestXMRWallet',         {}),
@@ -235,6 +236,7 @@ cfgs = { # addr_idx_lists (except 31, 32, 33, 34) must contain exactly 8 address
 	'32': {}, # ref_tx
 	'32': {}, # ref_tx
 	'33': {}, # ref_tx
 	'33': {}, # ref_tx
 	'34': {}, # ref_tx
 	'34': {}, # ref_tx
+	'37': {}, # swap
 	'38': {}, # autosign_clean
 	'38': {}, # autosign_clean
 	'39': {}, # xmr_autosign
 	'39': {}, # xmr_autosign
 	'40': {}, # cfgfile
 	'40': {}, # cfgfile

+ 6 - 3
test/cmdtest_d/ct_automount.py

@@ -15,7 +15,7 @@ import time
 
 
 from .ct_autosign import CmdTestAutosignThreaded
 from .ct_autosign import CmdTestAutosignThreaded
 from .ct_regtest import CmdTestRegtestBDBWallet, rt_pw
 from .ct_regtest import CmdTestRegtestBDBWallet, rt_pw
-from ..include.common import cfg
+from ..include.common import cfg, gr_uc
 
 
 class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet):
 class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet):
 	'automounted transacting operations via regtest mode'
 	'automounted transacting operations via regtest mode'
@@ -78,7 +78,7 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
 
 
 		self.opts.append('--alice')
 		self.opts.append('--alice')
 
 
-	def _alice_txcreate(self, chg_addr, opts=[], exit_val=0, expect_str=None):
+	def _alice_txcreate(self, chg_addr, opts=[], exit_val=0, expect_str=None, data_arg=None):
 
 
 		def do_return():
 		def do_return():
 			if expect_str:
 			if expect_str:
@@ -94,6 +94,7 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
 			'mmgen-txcreate',
 			'mmgen-txcreate',
 			opts
 			opts
 			+ ['--alice', '--autosign']
 			+ ['--alice', '--autosign']
+			+ ([data_arg] if data_arg else [])
 			+ [f'{self.burn_addr},1.23456', f'{sid}:{chg_addr}'],
 			+ [f'{self.burn_addr},1.23456', f'{sid}:{chg_addr}'],
 			exit_val = exit_val or None)
 			exit_val = exit_val or None)
 
 
@@ -109,7 +110,9 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
 		return do_return()
 		return do_return()
 
 
 	def alice_txcreate1(self):
 	def alice_txcreate1(self):
-		return self._alice_txcreate(chg_addr='C:5')
+		return self._alice_txcreate(
+			chg_addr = 'C:5',
+			data_arg = 'data:'+gr_uc[:24])
 
 
 	def alice_txcreate2(self):
 	def alice_txcreate2(self):
 		return self._alice_txcreate(chg_addr='L:5')
 		return self._alice_txcreate(chg_addr='L:5')

+ 19 - 10
test/cmdtest_d/ct_regtest.py

@@ -63,7 +63,8 @@ pat_date_time = r'\b\d\d\d\d-\d\d-\d\d\s+\d\d:\d\d\b'
 
 
 dfl_wcls = get_wallet_cls('mmgen')
 dfl_wcls = get_wallet_cls('mmgen')
 
 
-tx_fee = rtFundAmt = rtFee = rtBals = rtBals_gb = rtBobOp3 = rtAmts = {} # ruff
+tx_fee = rtFundAmt = rtFee = rtBals = rtBals_gb = rtBobOp3 = rtAmts = None # ruff
+
 rt_pw = 'abc-α'
 rt_pw = 'abc-α'
 rt_data = {
 rt_data = {
 	'tx_fee': {'btc':'0.0001', 'bch':'0.001', 'ltc':'0.01'},
 	'tx_fee': {'btc':'0.0001', 'bch':'0.001', 'ltc':'0.01'},
@@ -1169,17 +1170,26 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 		t.expect(f'Mined {num_blocks} block')
 		t.expect(f'Mined {num_blocks} block')
 		return t
 		return t
 
 
-	def _get_mempool(self):
+	def _do_cli(self, cmd_args, decode_json=False):
+		return self._do_mmgen_regtest(['cli'] + cmd_args, decode_json=decode_json)
+
+	def _do_mmgen_regtest(self, cmd_args, decode_json=False):
 		ret = self.spawn(
 		ret = self.spawn(
 			'mmgen-regtest',
 			'mmgen-regtest',
-			['mempool'],
-			env = os.environ if cfg.debug_utf8 else get_env_without_debug_vars(),
-		).read()
-		m = re.search(r'(\[\s*"[a-f0-9]{64}"\s*])', ret) # allow for extra output by handler at end
-		return json.loads(m.group(1))
+			cmd_args,
+			env = (os.environ if cfg.debug_utf8 else get_env_without_debug_vars()) | (
+				{'EXEC_WRAPPER_DO_RUNTIME_MSG': ''}),
+			no_msg = True
+		).read().strip()
+		return json.loads(ret) if decode_json else ret
+
+	def _get_mempool(self, do_msg=False):
+		if do_msg:
+			self.spawn('', msg_only=True)
+		return self._do_mmgen_regtest(['mempool'], decode_json=True)
 
 
 	def get_mempool1(self):
 	def get_mempool1(self):
-		mp = self._get_mempool()
+		mp = self._get_mempool(do_msg=True)
 		if len(mp) != 1:
 		if len(mp) != 1:
 			die(4, 'Mempool has more or less than one TX!')
 			die(4, 'Mempool has more or less than one TX!')
 		self.write_to_tmpfile('rbf_txid', mp[0]+'\n')
 		self.write_to_tmpfile('rbf_txid', mp[0]+'\n')
@@ -1200,7 +1210,7 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 	def get_mempool2(self):
 	def get_mempool2(self):
 		if not self.proto.cap('rbf'):
 		if not self.proto.cap('rbf'):
 			return 'skip'
 			return 'skip'
-		mp = self._get_mempool()
+		mp = self._get_mempool(do_msg=True)
 		if len(mp) != 1:
 		if len(mp) != 1:
 			die(4, 'Mempool has more or less than one TX!')
 			die(4, 'Mempool has more or less than one TX!')
 		chk = self.read_from_tmpfile('rbf_txid')
 		chk = self.read_from_tmpfile('rbf_txid')
@@ -2145,7 +2155,6 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 			imsg('')
 			imsg('')
 		else:
 		else:
 			stop_test_daemons(self.proto.network_id, remove_datadir=True)
 			stop_test_daemons(self.proto.network_id, remove_datadir=True)
-		ok()
 		return 'ok'
 		return 'ok'
 
 
 class CmdTestRegtestBDBWallet(CmdTestRegtest):
 class CmdTestRegtestBDBWallet(CmdTestRegtest):

+ 153 - 0
test/cmdtest_d/ct_swap.py

@@ -0,0 +1,153 @@
+#!/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
+
+"""
+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
+
+rtFundAmt = rtFee = None # ruff
+
+sample1 = '=:ETH.ETH:0x86d526d6624AbC0178cF7296cD538Ecc080A95F1:0/1/0'
+sample2 = '00010203040506'
+
+class CmdTestSwap(CmdTestRegtest):
+	bdb_wallet = True
+	networks = ('btc',)
+	tmpdir_nums = [37]
+
+	cmd_group_in = (
+		('setup',             'regtest (Bob and Alice) mode setup'),
+		('subgroup.init_bob', []),
+		('subgroup.fund_bob', ['init_bob']),
+		('subgroup.data',     ['init_bob']),
+		('stop',              'stopping regtest daemon'),
+	)
+	cmd_subgroups = {
+		'init_bob': (
+			'creating Bob’s MMGen wallet and tracking wallet',
+			('walletgen_bob',       'wallet generation (Bob)'),
+			('addrgen_bob',         'address generation (Bob)'),
+			('addrimport_bob',      'importing Bob’s addresses'),
+		),
+		'fund_bob': (
+			'funding Bob’s wallet',
+			('fund_bob', 'funding Bob’s wallet'),
+			('bob_bal1', 'Bob’s balance'),
+		),
+		'data': (
+			'OP_RETURN data operations',
+			('data_tx1_create',  'Creating a transaction with OP_RETURN data (hex-encoded ascii)'),
+			('data_tx1_sign',    'Signing the transaction'),
+			('data_tx1_send',    'Sending the transaction'),
+			('data_tx1_chk',     'Checking the sent transaction'),
+			('generate3',        'Generate 3 blocks'),
+			('data_tx2_do',      'Creating and sending a transaction with OP_RETURN data (binary)'),
+			('data_tx2_chk',     'Checking the sent transaction'),
+			('generate3',        'Generate 3 blocks'),
+		),
+	}
+
+	def __init__(self, trunner, cfgs, spawn):
+		super().__init__(trunner, cfgs, spawn)
+		gldict = globals()
+		for k in rt_data:
+			gldict[k] = rt_data[k]['btc']
+
+	@property
+	def sid(self):
+		return self._user_sid('bob')
+
+	def addrgen_bob(self):
+		return self.addrgen('bob', mmtypes=['S', 'B'])
+
+	def addrimport_bob(self):
+		return self.addrimport('bob', mmtypes=['S', 'B'])
+
+	def fund_bob(self):
+		return self.fund_wallet('bob', 'B', rtFundAmt)
+
+	def data_tx1_create(self):
+		return self._data_tx_create('1', 'B:2', 'B:3', 'data', sample1)
+
+	def _data_tx_create(self, src, dest, chg, pfx, sample):
+		t = self.spawn(
+			'mmgen-txcreate',
+			['-d', self.tmpdir, '-B', '--bob', f'{self.sid}:{dest},1', f'{self.sid}:{chg}', f'{pfx}:{sample}'])
+		return self.txcreate_ui_common(t, menu=[], inputs='1', interactive_fee='3s')
+
+	def data_tx1_sign(self):
+		return self._data_tx_sign()
+
+	def _data_tx_sign(self):
+		fn = self.get_file_with_ext('rawtx')
+		t = self.spawn('mmgen-txsign', ['-d', self.tmpdir, '--bob', fn])
+		t.view_tx('v')
+		t.passphrase(dfl_wcls.desc, rt_pw)
+		t.do_comment(None)
+		t.expect('(Y/n): ', 'y')
+		t.written_to_file('Signed transaction')
+		return t
+
+	def data_tx1_send(self):
+		return self._data_tx_send()
+
+	def _data_tx_send(self):
+		fn = self.get_file_with_ext('sigtx')
+		t = self.spawn('mmgen-txsend', ['-q', '-d', self.tmpdir, '--bob', fn])
+		t.expect('view: ', 'n')
+		t.expect('(y/N): ', '\n')
+		t.expect('to confirm: ', 'YES\n')
+		t.written_to_file('Sent transaction')
+		return t
+
+	def data_tx1_chk(self):
+		return self._data_tx_chk(sample1.encode().hex())
+
+	def data_tx2_do(self):
+		return self._data_tx_do('2', 'B:4', 'B:5', 'hexdata', sample2, 'v')
+
+	def data_tx2_chk(self):
+		return self._data_tx_chk(sample2)
+
+	def _data_tx_do(self, src, dest, chg, pfx, sample, view):
+		t = self.user_txdo(
+			user         = 'bob',
+			fee          = rtFee[0],
+			outputs_cl   = [f'{self.sid}:{dest},1', f'{self.sid}:{chg}', f'{pfx}:{sample}'],
+			outputs_list = src,
+			add_comment  = 'Transaction with OP_RETURN data',
+			return_early = True)
+
+		t.view_tx(view)
+		if view == 'v':
+			t.expect(sample)
+			t.expect('amount:')
+		t.passphrase(dfl_wcls.desc, rt_pw)
+		t.written_to_file('Signed transaction')
+		self._do_confirm_send(t)
+		t.expect('Transaction sent')
+		return t
+
+	def _data_tx_chk(self, sample):
+		mp = self._get_mempool(do_msg=True)
+		assert len(mp) == 1
+		self.write_to_tmpfile('data_tx1_id', mp[0]+'\n')
+		tx_hex = self._do_cli(['getrawtransaction', mp[0]])
+		tx = self._do_cli(['decoderawtransaction', tx_hex], decode_json=True)
+		v0 = tx['vout'][0]
+		assert v0['scriptPubKey']['hex'] == f'6a{(len(sample) // 2):02x}{sample}'
+		assert v0['scriptPubKey']['type'] == 'nulldata'
+		assert v0['value'] == "0.00000000"
+		return 'ok'
+
+	def generate3(self):
+		return self.generate(3)

+ 1 - 1
test/daemontest_d/ut_tx.py

@@ -75,7 +75,7 @@ async def test_tx(tx_proto, tx_hex, desc, n):
 	for i in range(len(a)):
 	for i in range(len(a)):
 		if 'addresses' in a[i]['scriptPubKey']:
 		if 'addresses' in a[i]['scriptPubKey']:
 			A = a[i]['scriptPubKey']['addresses'][0]
 			A = a[i]['scriptPubKey']['addresses'][0]
-			B = b[i]['address']
+			B = b[i]['addr']
 			fs = 'address of output {} does not match\nA: {}\nB: {}'
 			fs = 'address of output {} does not match\nA: {}\nB: {}'
 			assert A == B, fs.format(i, A, B)
 			assert A == B, fs.format(i, A, B)
 
 

+ 1 - 1
test/include/pexpect.py

@@ -82,7 +82,7 @@ class MMGenPexpect:
 
 
 	def view_tx(self, view):
 	def view_tx(self, view):
 		self.expect(r'View.* transaction.*\? .*: ', view, regex=True)
 		self.expect(r'View.* transaction.*\? .*: ', view, regex=True)
-		if view not in 'n\n':
+		if view not in 'vn\n':
 			self.expect('to continue: ', '\n')
 			self.expect('to continue: ', '\n')
 
 
 	def do_comment(self, add_comment, has_label=False):
 	def do_comment(self, add_comment, has_label=False):

+ 64 - 7
test/modtest_d/ut_tx.py

@@ -10,10 +10,10 @@ from mmgen.tx import CompletedTX, UnsignedTX
 from mmgen.tx.file import MMGenTxFile
 from mmgen.tx.file import MMGenTxFile
 from mmgen.cfg import Config
 from mmgen.cfg import Config
 
 
-from ..include.common import cfg, qmsg, vmsg
+from ..include.common import cfg, qmsg, vmsg, gr_uc
 
 
 async def do_txfile_test(desc, fns, cfg=cfg, check=False):
 async def do_txfile_test(desc, fns, cfg=cfg, check=False):
-	qmsg(f'  Testing CompletedTX initializer ({desc})')
+	qmsg(f'\n  Testing CompletedTX initializer ({desc})')
 	for fn in fns:
 	for fn in fns:
 		qmsg(f'     parsing: {os.path.basename(fn)}')
 		qmsg(f'     parsing: {os.path.basename(fn)}')
 		fpath = os.path.join('test', 'ref', fn)
 		fpath = os.path.join('test', 'ref', fn)
@@ -42,7 +42,7 @@ class unit_tests:
 
 
 	altcoin_deps = ('txfile_alt', 'txfile_alt_legacy')
 	altcoin_deps = ('txfile_alt', 'txfile_alt_legacy')
 
 
-	async def txfile(self, name, ut):
+	async def txfile(self, name, ut, desc='displaying transaction files (BTC)'):
 		return await do_txfile_test(
 		return await do_txfile_test(
 			'Bitcoin',
 			'Bitcoin',
 			(
 			(
@@ -53,7 +53,7 @@ class unit_tests:
 			check = True
 			check = True
 		)
 		)
 
 
-	async def txfile_alt(self, name, ut):
+	async def txfile_alt(self, name, ut, desc='displaying transaction files (LTC, BCH, ETH)'):
 		return await do_txfile_test(
 		return await do_txfile_test(
 			'altcoins',
 			'altcoins',
 			(
 			(
@@ -66,7 +66,7 @@ class unit_tests:
 			check = True
 			check = True
 		)
 		)
 
 
-	async def txfile_legacy(self, name, ut):
+	async def txfile_legacy(self, name, ut, desc='displaying transaction files (legacy format, BTC)'):
 		return await do_txfile_test(
 		return await do_txfile_test(
 			'Bitcoin - legacy file format',
 			'Bitcoin - legacy file format',
 			(
 			(
@@ -77,7 +77,7 @@ class unit_tests:
 			)
 			)
 		)
 		)
 
 
-	async def txfile_alt_legacy(self, name, ut):
+	async def txfile_alt_legacy(self, name, ut, desc='displaying transaction files (legacy format, LTC, BCH, ETH)'):
 		return await do_txfile_test(
 		return await do_txfile_test(
 			'altcoins - legacy file format',
 			'altcoins - legacy file format',
 			(
 			(
@@ -92,7 +92,7 @@ class unit_tests:
 			)
 			)
 		)
 		)
 
 
-	def errors(self, name, ut):
+	def errors(self, name, ut, desc='reading transaction files (error handling)'):
 		async def bad1():
 		async def bad1():
 			await CompletedTX(cfg, filename='foo')
 			await CompletedTX(cfg, filename='foo')
 		def bad2():
 		def bad2():
@@ -103,3 +103,60 @@ class unit_tests:
 		)
 		)
 		ut.process_bad_data(bad_data)
 		ut.process_bad_data(bad_data)
 		return True
 		return True
+
+	def op_return_data(self, name, ut, desc='OpReturnData class'):
+		from mmgen.proto.btc.tx.op_return_data import OpReturnData
+		vecs = [
+			'data:=:ETH.ETH:0x86d526d6624AbC0178cF7296cD538Ecc080A95F1:0/1/0',
+			'hexdata:3d3a4554482e4554483a30783836643532366436363234416243303137'
+				'38634637323936634435333845636330383041393546313a302f312f30',
+			'hexdata:00010203040506',
+			'data:a\n',
+			'data:a\tb',
+			'data:' + gr_uc[:24],
+		]
+
+		assert OpReturnData(cfg._proto, vecs[0]) == OpReturnData(cfg._proto, vecs[1])
+
+		for vec in vecs:
+			d = OpReturnData(cfg._proto, vec)
+			assert d == OpReturnData(cfg._proto, repr(d)) # repr() must return a valid initializer
+			assert isinstance(d, bytes)
+			assert isinstance(str(d), str)
+			vmsg('-' * 80)
+			vmsg(vec)
+			vmsg(repr(d))
+			vmsg(d.hl())
+			vmsg(d.hl(add_label=True))
+
+		bad_data = [
+			'data:',
+			'hexdata:',
+			'data:' + ('x' * 81),
+			'hexdata:' + ('deadbeef' * 20) + 'ee',
+			'hex:0abc',
+			'da:xyz',
+			'hexdata:xyz',
+			'hexdata:abcde',
+			b'data:abc',
+		]
+
+		def bad(n):
+			return lambda: OpReturnData(cfg._proto, bad_data[n])
+
+		vmsg('-' * 80)
+		vmsg('Testing error handling:')
+
+		ut.process_bad_data((
+			('bad1',    'AssertionError', 'not in range', bad(0)),
+			('bad2',    'AssertionError', 'not in range', bad(1)),
+			('bad3',    'AssertionError', 'not in range', bad(2)),
+			('bad4',    'AssertionError', 'not in range', bad(3)),
+			('bad5',    'ValueError',     'must start',   bad(4)),
+			('bad6',    'ValueError',     'must start',   bad(5)),
+			('bad7',    'AssertionError', 'not in hex',   bad(6)),
+			('bad8',    'AssertionError', 'even',         bad(7)),
+			('bad9',    'AssertionError', 'a string',     bad(8)),
+		), pfx='')
+
+		return True