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)
 	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):
 
 		cls = type(self)
 
 		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__:
 					attr = o.__dict__[attrname]
 					break
@@ -58,6 +59,7 @@ class MMGenObjectDevTools:
 				from .util import die
 				die(4, f'unable to find descriptor {cls.__name__}.{attrname}')
 
+			# initialization check:
 			if type(attr).__name__ == 'ImmutableAttr' and attrname not in self.__dict__:
 				from .util import die
 				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):
 		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):
-		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):
 		cu = self.proto.coin_amt.units
@@ -77,14 +78,20 @@ class help_notes:
 
 	def address_types(self):
 		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):
 		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):
 		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)
 		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:
 
   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:
 
     $ {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):
-		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
 are chosen from a list of the wallet’s unspent outputs via an interactive
 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
 SPECIFICATION below), it will be calculated dynamically using network fee
 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
 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):
 		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.
 
-ADDRESS TYPES:
-  {n_at}
-
 
                       NOTES FOR ALL GENERATOR COMMANDS
 
 {n_sw}{n_pw}{n_bw}
 
-FMT CODES:
+{n_at}
 
-  {n_fmt}
+{n_fmt}
 """
 	},
 	'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
 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

+ 1 - 3
mmgen/main_passgen.py

@@ -110,9 +110,7 @@ EXAMPLES:
 
 {n_bw}
 
-FMT CODES:
-
-  {n_fmt}
+{n_fmt}
 """
 	},
 	'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}
 
-FMT CODES:
-
-  {f}
+{f}
 """
 	},
 	'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'
 column below:
 
-FMT CODES:
-
-  {f}
+{f}
 """
 	},
 	'code': {

+ 3 - 2
mmgen/main_txcreate.py

@@ -66,7 +66,7 @@ opts_data = {
 			-- -y, --yes             Answer 'yes' to prompts, suppress non-essential output
 			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': {
 		'usage': lambda cfg, proto, help_notes, s: s.format(
@@ -82,7 +82,8 @@ opts_data = {
 		'notes': lambda cfg, help_notes, s: s.format(
 			c      = help_notes('txcreate'),
 			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'
 column below:
 
-FMT CODES:
+{n_at}
 
-  {f}
+{f}
 
 {x}"""
 	},
@@ -124,6 +124,7 @@ FMT CODES:
 			c       = help_notes('txcreate'),
 			F       = help_notes('fee'),
 			s       = help_notes('txsign'),
+			n_at    = help_notes('address_types'),
 			f       = help_notes('fmt_codes'),
 			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'
 column below:
 
-FMT CODES:
-
-  {f}
+{f}
 """
 	},
 	'code': {

+ 1 - 3
mmgen/main_wallet.py

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

+ 2 - 3
mmgen/obj.py

@@ -107,10 +107,9 @@ class ImmutableAttr: # Descriptor
 		self.typeconv = typeconv
 
 		assert isinstance(dtype, self.ok_dtypes), 'ImmutableAttr_check1'
-		if include_proto:
-			assert typeconv, 'ImmutableAttr_check2'
+
 		if set_none_ok:
-			assert typeconv and not isinstance(dtype, str), 'ImmutableAttr_check3'
+			assert typeconv and not isinstance(dtype, str), 'ImmutableAttr_check2'
 
 		if typeconv:
 			# 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
 	start_subsidy   = 50
 	max_int         = 0xffffffff
+	max_op_return_data_len = 80
 
 	coin_cfg_opts = (
 		'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 ....addr import CoinAddr
 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 .op_return_data import OpReturnData
+
+def data2scriptPubKey(data):
+	return '6a' + '{:02x}'.format(len(data)) + data.hex() # OP_RETURN data
+
 def addr2scriptPubKey(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)
 	}[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':
-		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':
-		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':
-		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:
-		raise NotImplementedError(f'Unknown scriptPubKey ({s})')
+		raise NotImplementedError(f'Unrecognized scriptPubKey ({s})')
 
 def DeserializeTX(proto, txhex):
 	"""
@@ -119,7 +141,7 @@ def DeserializeTX(proto, txhex):
 	} for i in range(d['num_txouts'])])
 
 	for o in d['txouts']:
-		o['address'] = scriptPubKey2addr(proto, o['scriptPubKey'])[0]
+		o.update(decodeScriptPubKey(proto, o['scriptPubKey'])._asdict())
 
 	if has_witness:
 		# https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki
@@ -152,6 +174,10 @@ class Base(TxBase.Base):
 	rel_fee_disp = 'sat/byte'
 	_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):
 
 		# Lexicographical Indexing of Transaction Inputs and Outputs
@@ -169,7 +195,9 @@ class Base(TxBase.Base):
 			def sort_func(a):
 				return (
 					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)
 
 	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)
 
 		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
 		# 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(
 			'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():
 			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 ....obj import HexStr
 from ....util import msg, die
-from .base import Base, scriptPubKey2addr
+from .base import Base, decodeScriptPubKey
 
 class Completed(Base, TxBase.Completed):
 	fn_fee_unit = 'satoshi'
@@ -45,17 +45,17 @@ class Completed(Base, TxBase.Completed):
 
 	def check_pubkey_scripts(self):
 		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 #{}'
-					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!'
 				die(3, (m+'\n  {:23}{}'*3).format(
 					n,
 					'address:',               i.addr,
 					'scriptPubKey:',          i.scriptPubKey,
-					'scriptPubKey->address:', addr))
+					'scriptPubKey->address:', ds.addr))
 
 #	def is_replaceable_from_rpc(self):
 #		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 ....util import fmt, die
-from ....color import red, green, pink
+from ....color import red, green, pink, blue
 from ....addr import MMGenID
 
 class TxInfo(TxInfo):
@@ -79,17 +79,20 @@ class TxInfo(TxInfo):
 				'raw':  lambda: io
 			}[sort]
 
+			def data_disp(data):
+				return f'OP_RETURN data ({len(data)} bytes)'
+
 			if terse:
 				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()):
 					yield '{:3} {} {} {} {}\n'.format(
 						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),
 						tx.dcoin)
-					if have_bch:
+					if have_bch and e.addr:
 						yield '{:3} [{}]\n'.format('', e.addr.hl(vp2, color=False))
 			else:
 				col1_w = len(str(len(io))) + 1
@@ -105,8 +108,11 @@ class TxInfo(TxInfo):
 							if have_bch:
 								yield ('', '', f'[{e.addr.hl(vp2, color=False)}]')
 						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)}]')
 						if e.comment:
 							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})'
 	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
 	def relay_fee(self):
 		kb_fee = self.proto.coin_amt(self.rpc.cached['networkinfo']['relayfee'])
@@ -134,7 +143,7 @@ class New(Base, TxBase.New):
 				'sequence': e.sequence
 			} 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)
 

+ 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
-from decimal import Decimal
 from collections import namedtuple
 
 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):
 	def default(self, o):
-		if isinstance(o, Decimal):
+		if type(o).__name__.endswith('Amt'):
 			return str(o)
 		else:
 			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'):
 		"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'):
 		"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
 			'B' if self.addr.addr_fmt == 'bech32' else
 			'S' if self.addr.addr_fmt == 'p2sh' else
-			None)
+			None
+		) if self.addr else None
 
 class MMGenTxIOList(list, MMGenObject):
 
@@ -91,12 +92,13 @@ class Base(MMGenObject):
 	file_format = 'json'
 
 	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'}
 
 	class Output(MMGenTxIO):
 		is_chg = ListItemAttr(bool, typeconv=False)
+		data   = ListItemAttr(None, typeconv=False) # placeholder
 
 	class InputList(MMGenTxIOList):
 		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 ..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):
-	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):
 	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:
 					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)):
-			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
 	# relative fee is N+<first letter of unit name>
@@ -161,12 +161,18 @@ class New(Base):
 			return False
 		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):
 
-		_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)
 
@@ -182,7 +188,7 @@ class New(Base):
 		else:
 			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):
 
@@ -208,17 +214,20 @@ class New(Base):
 
 		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:
 			desc = 'requested' if self.chg_autoselected else 'listed'
 			die(2, f'ERROR: More than one change address {desc} on command line')
 
 		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:
 			die(2,

+ 2 - 0
test/cmdtest_d/cfg.py

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

+ 6 - 3
test/cmdtest_d/ct_automount.py

@@ -15,7 +15,7 @@ import time
 
 from .ct_autosign import CmdTestAutosignThreaded
 from .ct_regtest import CmdTestRegtestBDBWallet, rt_pw
-from ..include.common import cfg
+from ..include.common import cfg, gr_uc
 
 class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet):
 	'automounted transacting operations via regtest mode'
@@ -78,7 +78,7 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
 
 		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():
 			if expect_str:
@@ -94,6 +94,7 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
 			'mmgen-txcreate',
 			opts
 			+ ['--alice', '--autosign']
+			+ ([data_arg] if data_arg else [])
 			+ [f'{self.burn_addr},1.23456', f'{sid}:{chg_addr}'],
 			exit_val = exit_val or None)
 
@@ -109,7 +110,9 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
 		return do_return()
 
 	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):
 		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')
 
-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_data = {
 	'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')
 		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(
 			'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):
-		mp = self._get_mempool()
+		mp = self._get_mempool(do_msg=True)
 		if len(mp) != 1:
 			die(4, 'Mempool has more or less than one TX!')
 		self.write_to_tmpfile('rbf_txid', mp[0]+'\n')
@@ -1200,7 +1210,7 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 	def get_mempool2(self):
 		if not self.proto.cap('rbf'):
 			return 'skip'
-		mp = self._get_mempool()
+		mp = self._get_mempool(do_msg=True)
 		if len(mp) != 1:
 			die(4, 'Mempool has more or less than one TX!')
 		chk = self.read_from_tmpfile('rbf_txid')
@@ -2145,7 +2155,6 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 			imsg('')
 		else:
 			stop_test_daemons(self.proto.network_id, remove_datadir=True)
-		ok()
 		return 'ok'
 
 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)):
 		if 'addresses' in a[i]['scriptPubKey']:
 			A = a[i]['scriptPubKey']['addresses'][0]
-			B = b[i]['address']
+			B = b[i]['addr']
 			fs = 'address of output {} does not match\nA: {}\nB: {}'
 			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):
 		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')
 
 	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.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):
-	qmsg(f'  Testing CompletedTX initializer ({desc})')
+	qmsg(f'\n  Testing CompletedTX initializer ({desc})')
 	for fn in fns:
 		qmsg(f'     parsing: {os.path.basename(fn)}')
 		fpath = os.path.join('test', 'ref', fn)
@@ -42,7 +42,7 @@ class unit_tests:
 
 	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(
 			'Bitcoin',
 			(
@@ -53,7 +53,7 @@ class unit_tests:
 			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(
 			'altcoins',
 			(
@@ -66,7 +66,7 @@ class unit_tests:
 			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(
 			'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(
 			'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():
 			await CompletedTX(cfg, filename='foo')
 		def bad2():
@@ -103,3 +103,60 @@ class unit_tests:
 		)
 		ut.process_bad_data(bad_data)
 		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