Browse Source

baseconv: move Monero mnemonic code to new xmrseed class

- a new unit test has also been added

Testing:

    $ test/unit_tests.py mn_entry xmrseed
    $ test/tooltest2.py hex2mn mn2hex
    $ test/test.py ref ref3_addr
The MMGen Project 3 years ago
parent
commit
d6f82fb6c9

+ 7 - 51
mmgen/baseconv.py

@@ -30,9 +30,6 @@ def is_b58_str(s):
 def is_b32_str(s):
 def is_b32_str(s):
 	return set(list(s)) <= set(baseconv.digits['b32'])
 	return set(list(s)) <= set(baseconv.digits['b32'])
 
 
-def is_xmrseed(s):
-	return bool( baseconv('xmrseed').tobytes(s.split()) )
-
 class baseconv(object):
 class baseconv(object):
 
 
 	desc = {
 	desc = {
@@ -45,7 +42,6 @@ class baseconv(object):
 #		'tirosh':('Tirosh mnemonic',   'base1626 mnemonic using truncated Tirosh wordlist'), # not used by wallet
 #		'tirosh':('Tirosh mnemonic',   'base1626 mnemonic using truncated Tirosh wordlist'), # not used by wallet
 		'mmgen': ('MMGen native mnemonic',
 		'mmgen': ('MMGen native mnemonic',
 		'MMGen native mnemonic seed phrase created using old Electrum wordlist and simple base conversion'),
 		'MMGen native mnemonic seed phrase created using old Electrum wordlist and simple base conversion'),
-		'xmrseed': ('Monero mnemonic', 'Monero new-style mnemonic seed phrase'),
 	}
 	}
 	# https://en.wikipedia.org/wiki/Base32#RFC_4648_Base32_alphabet
 	# https://en.wikipedia.org/wiki/Base32#RFC_4648_Base32_alphabet
 	# https://tools.ietf.org/html/rfc4648
 	# https://tools.ietf.org/html/rfc4648
@@ -60,7 +56,6 @@ class baseconv(object):
 	mn_base = 1626
 	mn_base = 1626
 	wl_chksums = {
 	wl_chksums = {
 		'mmgen':  '5ca31424',
 		'mmgen':  '5ca31424',
-		'xmrseed':'3c381ebb',
 #		'tirosh': '48f05e1f', # tirosh truncated to mn_base
 #		'tirosh': '48f05e1f', # tirosh truncated to mn_base
 #		'tirosh1633': '1a5faeff' # tirosh list is 1633 words long!
 #		'tirosh1633': '1a5faeff' # tirosh list is 1633 words long!
 	}
 	}
@@ -68,13 +63,11 @@ class baseconv(object):
 		'b58': { 16:22, 24:33, 32:44 },
 		'b58': { 16:22, 24:33, 32:44 },
 		'b6d': { 16:50, 24:75, 32:100 },
 		'b6d': { 16:50, 24:75, 32:100 },
 		'mmgen': { 16:12, 24:18, 32:24 },
 		'mmgen': { 16:12, 24:18, 32:24 },
-		'xmrseed': { 32:25 },
 	}
 	}
 	seedlen_map_rev = {
 	seedlen_map_rev = {
 		'b58': { 22:16, 33:24, 44:32 },
 		'b58': { 22:16, 33:24, 44:32 },
 		'b6d': { 50:16, 75:24, 100:32 },
 		'b6d': { 50:16, 75:24, 100:32 },
 		'mmgen': { 12:16, 18:24, 24:32 },
 		'mmgen': { 12:16, 18:24, 24:32 },
-		'xmrseed': { 25:32 },
 	}
 	}
 
 
 	def __init__(self,wl_id):
 	def __init__(self,wl_id):
@@ -82,9 +75,6 @@ class baseconv(object):
 		if wl_id == 'mmgen':
 		if wl_id == 'mmgen':
 			from .mn_electrum import words
 			from .mn_electrum import words
 			self.digits[wl_id] = words
 			self.digits[wl_id] = words
-		elif wl_id == 'xmrseed':
-			from .mn_monero import words
-			self.digits[wl_id] = words
 		elif wl_id not in self.digits:
 		elif wl_id not in self.digits:
 			raise ValueError(f'{wl_id}: unrecognized mnemonic ID')
 			raise ValueError(f'{wl_id}: unrecognized mnemonic ID')
 
 
@@ -132,12 +122,6 @@ class baseconv(object):
 		else:
 		else:
 			raise BaseConversionPadError(f"{pad!r}: illegal value for 'pad' (must be None,'seed' or int)")
 			raise BaseConversionPadError(f"{pad!r}: illegal value for 'pad' (must be None,'seed' or int)")
 
 
-	@staticmethod
-	def monero_mn_checksum(words):
-		from binascii import crc32
-		wstr = ''.join(word[:3] for word in words)
-		return words[crc32(wstr.encode()) % len(words)]
-
 	def tohex(self,words_arg,pad=None):
 	def tohex(self,words_arg,pad=None):
 		"convert string or list data of instance base to hex string"
 		"convert string or list data of instance base to hex string"
 		return self.tobytes(words_arg,pad//2 if type(pad)==int else pad).hex()
 		return self.tobytes(words_arg,pad//2 if type(pad)==int else pad).hex()
@@ -168,21 +152,6 @@ class baseconv(object):
 				( 'seed data' if pad == 'seed' else f'{words_arg!r}:' ) +
 				( 'seed data' if pad == 'seed' else f'{words_arg!r}:' ) +
 				f' not in {desc} format' )
 				f' not in {desc} format' )
 
 
-		if self.wl_id == 'xmrseed':
-			if len(words) not in self.seedlen_map_rev['xmrseed']:
-				die(2,f'{len(words)}: invalid length for Monero mnemonic')
-
-			z = self.monero_mn_checksum(words[:-1])
-			assert z == words[-1],'invalid Monero mnemonic checksum'
-			words = tuple(words[:-1])
-
-			ret = b''
-			for i in range(len(words)//3):
-				w1,w2,w3 = [wl.index(w) for w in words[3*i:3*i+3]]
-				x = w1 + base*((w2-w1)%base) + base*base*((w3-w2)%base)
-				ret += x.to_bytes(4,'big')[::-1]
-			return ret
-
 		ret = sum([wl.index(words[::-1][i])*(base**i) for i in range(len(words))])
 		ret = sum([wl.index(words[::-1][i])*(base**i) for i in range(len(words))])
 		bl = ret.bit_length()
 		bl = ret.bit_length()
 		return ret.to_bytes(max(pad_val,bl//8+bool(bl%8)),'big')
 		return ret.to_bytes(max(pad_val,bl//8+bool(bl%8)),'big')
@@ -214,28 +183,15 @@ class baseconv(object):
 
 
 		pad = max(self.get_pad(pad,get_seed_pad),1)
 		pad = max(self.get_pad(pad,get_seed_pad),1)
 		wl = self.digits[self.wl_id]
 		wl = self.digits[self.wl_id]
-		base = len(wl)
-
-		if self.wl_id == 'xmrseed':
-			if len(bytestr) not in self.seedlen_map['xmrseed']:
-				die(2, f'{len(bytestr)}: invalid seed byte length for Monero mnemonic')
 
 
-			def num2base_monero(num):
-				w1 = num % base
-				w2 = (num//base + w1) % base
-				w3 = (num//base//base + w2) % base
-				return [wl[w1], wl[w2], wl[w3]]
-
-			o = []
-			for i in range(len(bytestr)//4):
-				o += num2base_monero(int.from_bytes(bytestr[i*4:i*4+4][::-1],'big'))
-			o.append(self.monero_mn_checksum(o))
-		else:
+		def gen():
 			num = int.from_bytes(bytestr,'big')
 			num = int.from_bytes(bytestr,'big')
-			ret = []
+			base = len(wl)
 			while num:
 			while num:
-				ret.append(num % base)
+				yield num % base
 				num //= base
 				num //= base
-			o = [wl[n] for n in [0] * (pad-len(ret)) + ret[::-1]]
 
 
-		return (' ' if self.wl_id in ('mmgen','xmrseed') else '').join(o) if tostr else o
+		ret = list(gen())
+		o = [wl[n] for n in [0] * (pad-len(ret)) + ret[::-1]]
+
+		return (' ' if self.wl_id == 'mmgen' else '').join(o) if tostr else o

+ 1 - 1
mmgen/mn_entry.py

@@ -420,7 +420,7 @@ class MnemonicEntryBIP39(MnemonicEntry):
 
 
 class MnemonicEntryMonero(MnemonicEntry):
 class MnemonicEntryMonero(MnemonicEntry):
 	wl_id = 'xmrseed'
 	wl_id = 'xmrseed'
-	modname = 'baseconv'
+	modname = 'xmrseed'
 	entry_modes = ('full','short')
 	entry_modes = ('full','short')
 	dfl_entry_mode = 'short'
 	dfl_entry_mode = 'short'
 	has_chksum = True
 	has_chksum = True

+ 8 - 5
mmgen/passwdlist.py

@@ -25,8 +25,9 @@ from collections import namedtuple
 from .exception import InvalidPasswdFormat
 from .exception import InvalidPasswdFormat
 from .util import ymsg,is_hex_str,is_int,keypress_confirm
 from .util import ymsg,is_hex_str,is_int,keypress_confirm
 from .obj import ImmutableAttr,ListItemAttr,MMGenPWIDString
 from .obj import ImmutableAttr,ListItemAttr,MMGenPWIDString
-from .baseconv import baseconv,is_b32_str,is_b58_str,is_xmrseed
+from .baseconv import baseconv,is_b32_str,is_b58_str
 from .bip39 import is_bip39_str
 from .bip39 import is_bip39_str
+from .xmrseed import is_xmrseed
 from .key import PrivKey
 from .key import PrivKey
 from .addr import MMGenPasswordType,AddrIdx,AddrListID
 from .addr import MMGenPasswordType,AddrIdx,AddrListID
 from .addrlist import (
 from .addrlist import (
@@ -158,9 +159,10 @@ class PasswordList(AddrList):
 			pw_bytes = bip39.nwords2seedlen(self.pw_len,in_bytes=True)
 			pw_bytes = bip39.nwords2seedlen(self.pw_len,in_bytes=True)
 			good_pw_len = bip39.seedlen2nwords(seed.byte_len,in_bytes=True)
 			good_pw_len = bip39.seedlen2nwords(seed.byte_len,in_bytes=True)
 		elif pf == 'xmrseed':
 		elif pf == 'xmrseed':
-			pw_bytes = baseconv.seedlen_map_rev['xmrseed'][self.pw_len]
+			from .xmrseed import xmrseed
+			pw_bytes = xmrseed.seedlen_map_rev['xmrseed'][self.pw_len]
 			try:
 			try:
-				good_pw_len = baseconv.seedlen_map['xmrseed'][seed.byte_len]
+				good_pw_len = xmrseed.seedlen_map['xmrseed'][seed.byte_len]
 			except:
 			except:
 				die(1,f'{seed.byte_len*8}: unsupported seed length for Monero new-style mnemonic')
 				die(1,f'{seed.byte_len*8}: unsupported seed length for Monero new-style mnemonic')
 		elif pf in ('b32','b58'):
 		elif pf in ('b32','b58'):
@@ -196,12 +198,13 @@ class PasswordList(AddrList):
 			# take most significant part
 			# take most significant part
 			return ' '.join( bip39().fromhex(secbytes[:pw_len_bytes].hex()) )
 			return ' '.join( bip39().fromhex(secbytes[:pw_len_bytes].hex()) )
 		elif self.pw_fmt == 'xmrseed':
 		elif self.pw_fmt == 'xmrseed':
-			pw_len_bytes = baseconv.seedlen_map_rev['xmrseed'][self.pw_len]
+			from .xmrseed import xmrseed
+			pw_len_bytes = xmrseed.seedlen_map_rev['xmrseed'][self.pw_len]
 			from .protocol import init_proto
 			from .protocol import init_proto
 			bytes_preproc = init_proto('xmr').preprocess_key(
 			bytes_preproc = init_proto('xmr').preprocess_key(
 				secbytes[:pw_len_bytes], # take most significant part
 				secbytes[:pw_len_bytes], # take most significant part
 				None )
 				None )
-			return ' '.join( baseconv('xmrseed').frombytes(bytes_preproc) )
+			return ' '.join( xmrseed().frombytes(bytes_preproc) )
 		else:
 		else:
 			# take least significant part
 			# take least significant part
 			return baseconv(self.pw_fmt).frombytes(
 			return baseconv(self.pw_fmt).frombytes(

+ 2 - 1
mmgen/tool.py

@@ -29,6 +29,7 @@ from .addr import *
 from .addrlist import AddrList,KeyAddrList
 from .addrlist import AddrList,KeyAddrList
 from .passwdlist import PasswordList
 from .passwdlist import PasswordList
 from .baseconv import baseconv
 from .baseconv import baseconv
+from .xmrseed import xmrseed
 from .bip39 import bip39
 from .bip39 import bip39
 
 
 NL = ('\n','\r\n')[g.platform=='win']
 NL = ('\n','\r\n')[g.platform=='win']
@@ -238,7 +239,7 @@ mft = namedtuple('mnemonic_format',['fmt','pad','conv_cls'])
 mnemonic_fmts = {
 mnemonic_fmts = {
 	'mmgen':   mft( 'words',  'seed', baseconv ),
 	'mmgen':   mft( 'words',  'seed', baseconv ),
 	'bip39':   mft( 'bip39',   None,  bip39 ),
 	'bip39':   mft( 'bip39',   None,  bip39 ),
-	'xmrseed': mft( 'xmrseed', None,  baseconv ),
+	'xmrseed': mft( 'xmrseed', None,  xmrseed ),
 }
 }
 mn_opts_disp = _options_annot_str(mnemonic_fmts)
 mn_opts_disp = _options_annot_str(mnemonic_fmts)
 
 

+ 101 - 0
mmgen/xmrseed.py

@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+#
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
+# Copyright (C)2013-2022 The MMGen Project <mmgen@tuta.io>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""
+xmrseed.py: Monero mnemonic conversion class for the MMGen suite
+"""
+
+from .exception import *
+from .baseconv import baseconv
+from .util import die
+
+def is_xmrseed(s):
+	return bool( xmrseed().tobytes(s.split()) )
+
+# implements a subset of the baseconv API
+class xmrseed(baseconv):
+
+	desc            = { 'xmrseed': ('Monero mnemonic', 'Monero new-style mnemonic seed phrase') }
+	wl_chksums      = { 'xmrseed': '3c381ebb' }
+	seedlen_map     = { 'xmrseed': { 32:25 } }
+	seedlen_map_rev = { 'xmrseed': { 25:32 } }
+
+	def __init__(self,wl_id='xmrseed'):
+		assert wl_id == 'xmrseed', "initialize with 'xmrseed' for compatibility with baseconv API"
+		from .mn_monero import words
+		self.digits = { 'xmrseed': words }
+		self.wl_id = 'xmrseed'
+
+	@staticmethod
+	def monero_mn_checksum(words):
+		from binascii import crc32
+		wstr = ''.join(word[:3] for word in words)
+		return words[crc32(wstr.encode()) % len(words)]
+
+	def tobytes(self,words,pad=None):
+		assert isinstance(words,(list,tuple)),'words must be list or tuple'
+		assert pad == None, f"{pad}: invalid 'pad' argument (must be None)"
+
+		desc = self.desc[self.wl_id][0]
+		wl = self.digits[self.wl_id]
+		base = len(wl)
+
+		if not set(words) <= set(wl):
+			raise MnemonicError( f'{words!r}: not in {desc} format' )
+
+		if len(words) not in self.seedlen_map_rev['xmrseed']:
+			raise MnemonicError( f'{len(words)}: invalid seed phrase length for {desc}' )
+
+		z = self.monero_mn_checksum(words[:-1])
+		if z != words[-1]:
+			raise MnemonicError(f'invalid {desc} checksum')
+		words = tuple(words[:-1])
+
+		def gen():
+			for i in range(len(words)//3):
+				w1,w2,w3 = [wl.index(w) for w in words[3*i:3*i+3]]
+				x = w1 + base*((w2-w1)%base) + base*base*((w3-w2)%base)
+				yield x.to_bytes(4,'big')[::-1]
+
+		return b''.join(gen())
+
+	def frombytes(self,bytestr,pad=None,tostr=False):
+		assert pad == None, f"{pad}: invalid 'pad' argument (must be None)"
+
+		desc = self.desc[self.wl_id][0]
+		wl = self.digits[self.wl_id]
+		base = len(wl)
+
+		if len(bytestr) not in self.seedlen_map['xmrseed']:
+			raise SeedLengthError(f'{len(bytestr)}: invalid seed byte length for {desc}')
+
+		def num2base_monero(num):
+			w1 = num % base
+			w2 = (num//base + w1) % base
+			w3 = (num//base//base + w2) % base
+			return ( wl[w1], wl[w2], wl[w3] )
+
+		def gen():
+			for i in range(len(bytestr)//4):
+				for e in num2base_monero( int.from_bytes( bytestr[i*4:i*4+4][::-1], 'big' ) ):
+					yield e
+
+		o = list(gen())
+		o.append( self.monero_mn_checksum(o) )
+
+		return ' '.join(o) if tostr else tuple(o)

+ 2 - 2
mmgen/xmrwallet.py

@@ -579,12 +579,12 @@ class MoneroWalletOps:
 		async def process_wallet(self,d,fn,last):
 		async def process_wallet(self,d,fn,last):
 			msg_r('') # for pexpect
 			msg_r('') # for pexpect
 
 
-			from .baseconv import baseconv
+			from .xmrseed import xmrseed
 			ret = await self.c.call(
 			ret = await self.c.call(
 				'restore_deterministic_wallet',
 				'restore_deterministic_wallet',
 				filename       = os.path.basename(fn),
 				filename       = os.path.basename(fn),
 				password       = d.wallet_passwd,
 				password       = d.wallet_passwd,
-				seed           = baseconv('xmrseed').fromhex(d.sec.wif,tostr=True),
+				seed           = xmrseed().fromhex(d.sec.wif,tostr=True),
 				restore_height = uopt.restore_height,
 				restore_height = uopt.restore_height,
 				language       = 'English' )
 				language       = 'English' )
 
 

+ 1 - 0
test/tooltest2.py

@@ -34,6 +34,7 @@ sys.path.insert(0,overlay_setup(repo_root))
 from mmgen.common import *
 from mmgen.common import *
 from test.include.common import *
 from test.include.common import *
 from mmgen.wallet import is_bip39_mnemonic,is_mmgen_mnemonic
 from mmgen.wallet import is_bip39_mnemonic,is_mmgen_mnemonic
+from mmgen.xmrseed import is_xmrseed
 from mmgen.baseconv import *
 from mmgen.baseconv import *
 
 
 skipped_tests = ['mn2hex_interactive']
 skipped_tests = ['mn2hex_interactive']

+ 1 - 20
test/unit_tests_d/ut_baseconv.py

@@ -9,23 +9,6 @@ from mmgen.exception import *
 class unit_test(object):
 class unit_test(object):
 
 
 	vectors = {
 	vectors = {
-		'xmrseed': (
-			# 42nsXK8WbVGTNayQ6Kjw5UdgqbQY5KCCufdxdCgF7NgTfjC69Mna7DJSYyie77hZTQ8H92G2HwgFhgEUYnDzrnLnQdF28r3
-			(('0000000000000000000000000000000000000000000000000000000000000001','seed'), # 0x1
-			'abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey bamboo jaws jerseys abbey'),
-
-			# 49voQEbjouUQSDikRWKUt1PGbS47TBde4hiGyftN46CvTDd8LXCaimjHRGtofCJwY5Ed5QhYwc12P15AH5w7SxUAMCz1nr1
-			(('1c95988d7431ecd670cf7d73f45befc6feffffffffffffffffffffffffffff0f','seed'), # 0xffffffff * 8
-			'powder directed sayings enmity bacon vapidly entrance bumper noodles iguana sleepless nasty flying soil software foamy solved soggy foamy solved soggy jury yawning ankle solved'),
-
-			# 41i7saPWA53EoHenmJVRt34dubPxsXwoWMnw8AdMyx4mTD1svf7qYzcVjxxRfteLNdYrAxWUMmiPegFW9EfoNgXx7vDMExv
-			(('e8164dda6d42bd1e261a3406b2038dcbddadbeefdeadbeefdeadbeefdeadbe0f','seed'), # 0xdeadbeef * 8
-			'viewpoint donuts ardent template unveil agile meant unafraid urgent athlete rustled mime azure jaded hawk baby jagged haystack baby jagged haystack ramped oncoming point template'),
-
-			# 42ey1afDFnn4886T7196doS9GPMzexD9gXpsZJDwVjeRVdFCSoHnv7KPbBeGpzJBzHRCAs9UxqeoyFQMYbqSWYTfJJQAWDm
-			(('148d78d2aba7dbca5cd8f6abcfb0b3c009ffbdbea1ff373d50ed94d78286640e','seed'), # Monero repo
-			'velvet lymph giddy number token physics poetry unquoted nibs useful sabotage limits benches lifestyle eden nitrogen anvil fewest avoid batch vials washing fences goat unquoted'),
-		),
 		'b58': (
 		'b58': (
 			(('00',None),'1'),
 			(('00',None),'1'),
 			(('00',1),'1'),
 			(('00',1),'1'),
@@ -180,9 +163,7 @@ class unit_test(object):
 			for (hexstr,pad),ret_chk in data:
 			for (hexstr,pad),ret_chk in data:
 				if type(pad) == int:
 				if type(pad) == int:
 					pad = len(hexstr)
 					pad = len(hexstr)
-				ret = baseconv(base).tohex(
-					ret_chk.split() if base == 'xmrseed' else ret_chk,
-					pad=pad)
+				ret = baseconv(base).tohex( ret_chk, pad=pad )
 				if pad == None:
 				if pad == None:
 					assert int(ret,16) == int(hexstr,16), rerr.format(int(ret,16),int(hexstr,16))
 					assert int(ret,16) == int(hexstr,16), rerr.format(int(ret,16),int(hexstr,16))
 				else:
 				else:

+ 2 - 2
test/unit_tests_d/ut_rpc.py

@@ -139,7 +139,7 @@ class unit_tests:
 					'restore_deterministic_wallet',
 					'restore_deterministic_wallet',
 					filename = fn,
 					filename = fn,
 					password = 'foo',
 					password = 'foo',
-					seed     = baseconv('xmrseed').fromhex('beadface'*8,tostr=True) )
+					seed     = xmrseed().fromhex('beadface'*8,tostr=True) )
 				qmsg(f'Opening {wd.network} wallet')
 				qmsg(f'Opening {wd.network} wallet')
 				await c.call( 'open_wallet', filename=fn, password='foo' )
 				await c.call( 'open_wallet', filename=fn, password='foo' )
 
 
@@ -152,7 +152,7 @@ class unit_tests:
 
 
 			gmsg('OK')
 			gmsg('OK')
 
 
-		from mmgen.baseconv import baseconv
+		from mmgen.xmrseed import xmrseed
 		import shutil
 		import shutil
 		shutil.rmtree('test/trash2',ignore_errors=True)
 		shutil.rmtree('test/trash2',ignore_errors=True)
 		os.makedirs('test/trash2')
 		os.makedirs('test/trash2')

+ 126 - 0
test/unit_tests_d/ut_xmrseed.py

@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+"""
+test/unit_tests_d/ut_xmrseed: Monero mnemonic unit test for the MMGen suite
+"""
+
+from mmgen.common import *
+from mmgen.exception import *
+
+class unit_test(object):
+
+	vectors = ( # private keys are reduced
+		(   '148d78d2aba7dbca5cd8f6abcfb0b3c009ffbdbea1ff373d50ed94d78286640e', # Monero repo
+			'velvet lymph giddy number token physics poetry unquoted nibs useful sabotage limits benches ' +
+			'lifestyle eden nitrogen anvil fewest avoid batch vials washing fences goat unquoted',
+		),
+		(   'e8164dda6d42bd1e261a3406b2038dcbddadbeefdeadbeefdeadbeefdeadbe0f',
+			'viewpoint donuts ardent template unveil agile meant unafraid urgent athlete rustled mime azure ' +
+			'jaded hawk baby jagged haystack baby jagged haystack ramped oncoming point template'
+		),
+		(   '6900dea9753f5c7ced87b53bdcfb109a8417bca6a2797a708194157b227fb60b',
+			'criminal bamboo scamper gnaw limits womanly wrong tuition birth mundane donuts square cohesive ' +
+			'dolphin titans narrate fuel saved wrap aloof magically mirror together update wrap'
+		),
+		(   '0000000000000000000000000000000000000000000000000000000000000001',
+			'abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey abbey ' +
+			'abbey abbey abbey abbey abbey bamboo jaws jerseys abbey'
+		),
+		(   '1c95988d7431ecd670cf7d73f45befc6feffffffffffffffffffffffffffff0f',
+			'powder directed sayings enmity bacon vapidly entrance bumper noodles iguana sleepless nasty flying ' +
+			'soil software foamy solved soggy foamy solved soggy jury yawning ankle solved'
+		),
+		(   '2c94988d7431ecd670cf7d73f45befc6feffffffffffffffffffffffffffff0f',
+			'memoir apart olive enmity bacon vapidly entrance bumper noodles iguana sleepless nasty flying soil ' +
+			'software foamy solved soggy foamy solved soggy jury yawning ankle foamy'
+		),
+		(   '4bb0288c9673b69fa68c2174851884abbaaedce6af48a03bbfd25e8cd0364102',
+			'rated bicycle pheasants dejected pouch fizzle shipped rash citadel queen avatar sample muzzle mews ' +
+			'jagged origin yeti dunes obtains godfather unbending pastry vortex washing citadel'
+		),
+		(   '4bb0288c9673b69fa68c2174851884abbaaedce6af48a03bbfd25e8cd0364100',
+			'rated bicycle pheasants dejected pouch fizzle shipped rash citadel queen avatar sample muzzle mews ' +
+			'jagged origin yeti dunes obtains godfather unbending kangaroo auctions audio citadel'
+		),
+		(   '1d95988d7431ecd670cf7d73f45befc6feffffffffffffffffffffffffffff0e',
+			'pram distance scamper enmity bacon vapidly entrance bumper noodles iguana sleepless nasty flying ' +
+			'soil software foamy solved soggy foamy solved soggy hashing mullet onboard solved'
+		),
+		(   'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0f',
+			'foamy solved soggy foamy solved soggy foamy solved soggy foamy solved soggy foamy solved soggy ' +
+			'foamy solved soggy foamy solved soggy jury yawning ankle soggy'
+		),
+	)
+
+	def run_test(self,name,ut):
+
+		def test_fromhex(b):
+			vmsg('')
+			qmsg('Checking seed to mnemonic conversion:')
+			for privhex,chk in self.vectors:
+				vmsg(f'    {chk}')
+				chk = tuple(chk.split())
+				res = b.fromhex(privhex)
+				if use_moneropy:
+					mp_chk = tuple( mnemonic.mn_encode(privhex) )
+					assert res[:24] == mp_chk, f'check failed:\nres: {res[:24]}\nchk: {chk}'
+				assert res == chk, f'check failed:\nres: {res}\nchk: {chk}'
+
+		def test_tohex(b):
+			vmsg('')
+			qmsg('Checking mnemonic to seed conversion:')
+			for chk,words in self.vectors:
+				vmsg(f'    {chk}')
+				res = b.tohex( words.split() )
+				if use_moneropy:
+					mp_chk = mnemonic.mn_decode( words.split() )
+					assert res == mp_chk, f'check failed:\nres: {res}\nchk: {mp_chk}'
+				assert res == chk, f'check failed:\nres: {res}\nchk: {chk}'
+
+		msg_r('Testing xmrseed conversion routines...')
+		qmsg('')
+
+		from mmgen.xmrseed import xmrseed
+
+		b = xmrseed()
+		b.check_wordlist()
+
+		try:
+			from moneropy import mnemonic
+		except ImportError:
+			use_moneropy = False
+			ymsg('Warning: unable to import moneropy, skipping external library checks')
+		else:
+			use_moneropy = True
+
+		test_fromhex(b)
+		test_tohex(b)
+
+		vmsg('')
+		qmsg('Checking error handling:')
+
+		bad_chksum_mn = ('abbey ' * 21 + 'bamboo jaws jerseys donuts').split()
+		bad_word_mn = "admire zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo".split()
+		bad_seed = 'deadbeef'
+		good_mn = self.vectors[0][1].split()
+		good_hex = self.vectors[0][0]
+		bad_len_mn = good_mn[:22]
+
+		th = b.tohex
+		fh = b.fromhex
+		bad_data = (
+('hex',               'HexadecimalStringError', 'not a hexadecimal',     lambda:fh('xx')),
+('seed len',          'SeedLengthError',        'invalid seed byte len', lambda:fh(bad_seed)),
+('mnemonic type',     'AssertionError',         'must be list',          lambda:th('string')),
+('pad arg (fromhex)', 'AssertionError',         "invalid 'pad' arg",     lambda:fh(good_hex,pad=23)),
+('pad arg (tohex)',   'AssertionError',         "invalid 'pad' arg",     lambda:th(good_mn,pad=23)),
+('word',              'MnemonicError',          "not in Monero",         lambda:th(bad_word_mn)),
+('checksum',          'MnemonicError',          "checksum",              lambda:th(bad_chksum_mn)),
+('seed phrase len',   'MnemonicError',          "phrase len",            lambda:th(bad_len_mn)),
+		)
+
+		ut.process_bad_data(bad_data)
+
+		vmsg('')
+		msg('OK')
+
+		return True