Browse Source

test.py: new `xmrwallet` test

Testing:

    $ test/test.py --coin=xmr -e xmrwallet
The MMGen Project 3 years ago
parent
commit
d6def8f819
4 changed files with 543 additions and 46 deletions
  1. 5 2
      mmgen/xmrwallet.py
  2. 10 41
      test/test-release.sh
  3. 5 3
      test/test.py
  4. 523 0
      test/test_py_d/ts_xmrwallet.py

+ 5 - 2
mmgen/xmrwallet.py

@@ -241,6 +241,7 @@ class MoneroWalletOps:
 				wallet_dir = opt.outdir or '.',
 				test_suite = g.test_suite,
 				daemon_addr = uarg.daemon or None,
+				testnet = g.testnet,
 			)
 
 			if uarg.start_wallet_daemon:
@@ -275,10 +276,11 @@ class MoneroWalletOps:
 
 		def get_wallet_fn(self,d):
 			return os.path.join(
-				opt.outdir or '.','{}-{}-MoneroWallet{}'.format(
+				opt.outdir or '.','{}-{}-MoneroWallet{}{}'.format(
 					self.kal.al_id.sid,
 					d.idx,
-					'-α' if g.debug_utf8 else ''))
+					'.testnet' if g.testnet else '',
+					'-α' if g.debug_utf8 else '' ))
 
 		async def process_wallets(self):
 			gmsg('\n{}ing {} wallet{}'.format(self.desc,len(self.addr_data),suf(self.addr_data)))
@@ -441,6 +443,7 @@ class MoneroWalletOps:
 					daemon_addr = m[1],
 					proxy = m[2],
 					port_shift = 16,
+					testnet = g.testnet,
 				)
 
 				if g.test_suite:

+ 10 - 41
test/test-release.sh

@@ -34,7 +34,6 @@ mmgen_tool='cmds/mmgen-tool'
 mmgen_keygen='cmds/mmgen-keygen'
 python='python3'
 rounds=100 rounds_min=20 rounds_mid=250 rounds_max=500
-xmr_addrs='3,99,2,22-24,101-104'
 
 dfl_tests='misc obj color unit hash ref altref alts xmr eth autosign btc btc_tn btc_rt bch bch_rt ltc ltc_rt tool tool2 gen'
 extra_tests='autosign_btc autosign_live etc ltc_tn bch_tn'
@@ -43,7 +42,7 @@ quick_tests='misc obj color unit hash ref altref alts xmr eth autosign btc btc_r
 qskip_tests='btc_tn bch bch_rt ltc ltc_rt'
 
 PROGNAME=$(basename $0)
-while getopts hAbCfFi:I:lOpRtvV OPT
+while getopts hAbCfFi:I:lOptvV OPT
 do
 	case "$OPT" in
 	h)  printf "  %-16s Test MMGen release\n" "${PROGNAME}:"
@@ -60,7 +59,6 @@ do
 		echo   "           '-l'  List the test name symbols"
 		echo   "           '-O'  Use pexpect.spawn rather than popen_spawn for applicable tests"
 		echo   "           '-p'  Pause between tests"
-		echo   "           '-R'  Re-run fast XMR test with existing tmp data, leave daemon running"
 		echo   "           '-t'  Print the tests without running them"
 		echo   "           '-v'  Run test/test.py with '--exact-output' and other commands with"
 		echo   "                 '--verbose' switch"
@@ -74,7 +72,7 @@ do
 		echo   "     ref      - reference file checks"
 		echo   "     altref   - altcoin reference file checks"
 		echo   "     alts     - operations for all supported gen-only altcoins"
-		echo   "     xmr      - operations for Monero"
+		echo   "     xmr      - Monero xmrwallet operations"
 		echo   "     eth      - operations for Ethereum"
 		echo   "     etc      - operations for Ethereum Classic"
 		echo   "     autosign - autosign"
@@ -116,8 +114,8 @@ do
 		gentest_py="$python $gentest_py"
 		mmgen_tool="$python $mmgen_tool"
 		mmgen_keygen="$python $mmgen_keygen" ;&
-	f)  FAST=1 rounds=10 rounds_min=3 rounds_mid=25 rounds_max=50 xmr_addrs='3,23' unit_tests_py+=" --fast" ;;
-	F)  FAST=1 rounds=3 rounds_min=1 rounds_mid=3 rounds_max=5 xmr_addrs='3,23' unit_tests_py+=" --fast" ;;
+	f)  FAST=1 rounds=10 rounds_min=3 rounds_mid=25 rounds_max=50 unit_tests_py+=" --fast" ;;
+	F)  FAST=1 rounds=3 rounds_min=1 rounds_mid=3 rounds_max=5 unit_tests_py+=" --fast" ;;
 	i)  INSTALL=$OPTARG ;;
 	I)  INSTALL=$OPTARG INSTALL_ONLY=1 ;;
 	l)  echo -e "Default tests:\n  $dfl_tests"
@@ -128,7 +126,6 @@ do
 		exit ;;
 	O)  test_py+=" --pexpect-spawn" ;;
 	p)  PAUSE=1 ;;
-	R)  RERUN_XMR=1 ;;
 	t)  LIST_CMDS=1 ;;
 	v)  EXACT_OUTPUT=1 test_py+=" --exact-output" ;&
 	V)  VERBOSE=1
@@ -332,41 +329,15 @@ create_tmpdir() {
 	mkdir -p $TMPDIR
 }
 
-if [ "$RERUN_XMR" ]; then
-	TMPDIR=/tmp/mmgen-test-release*
-	if [ -e $TMPDIR ]; then HAVE_XMR_DATA=1; else create_tmpdir; fi
-else
-	rm -rf /tmp/mmgen-test-release*
-	create_tmpdir
-fi
+rm -rf /tmp/mmgen-test-release*
+create_tmpdir
 
-# xmr_addrs: normal: 3,99,2,22-24,101-104 fast: 3,23
-mmgen_tool_xmr="$mmgen_tool -q --yes --outdir $TMPDIR --monero-wallet-rpc-password=passw0rd"
-i_xmr='Monero'
-s_xmr='Testing key-address file generation and wallet creation and sync operations for Monero'
-s_xmr='The monerod (mainnet) daemon must be running for the following tests'
+i_xmr='Monero xmrwallet'
+s_xmr='Testing xmrwallet operations'
 t_xmr="
-	a cmds/mmgen-walletgen -q -r0 -p1 -Llabel --outdir $TMPDIR -o words
-	a $mmgen_keygen -q --accept-defaults --use-internal-keccak-module --outdir $TMPDIR --coin=xmr $TMPDIR/*.mmwords $xmr_addrs
-	a cs1=\$(cmds/mmgen-tool -q --accept-defaults --coin=xmr keyaddrfile_chksum $TMPDIR/*-XMR*.akeys)
-	x $mmgen_keygen -q --use-old-ed25519 --accept-defaults --outdir $TMPDIR --coin=xmr $TMPDIR/*.mmwords $xmr_addrs
-	x cs2=\$(cmds/mmgen-tool -q --accept-defaults --coin=xmr keyaddrfile_chksum $TMPDIR/*-XMR*.akeys)
-	x [ \"\$cs1\" == \"\$cs2\" ]
-	a test/start-coin-daemons.py xmr
-	x $mmgen_tool_xmr xmrwallet create $TMPDIR/*-XMR*.akeys wallets=23
-	x $mmgen_tool_xmr xmrwallet create $TMPDIR/*-XMR*.akeys wallets=101-104
-	x rm $TMPDIR/*-MoneroWallet*
-	a $mmgen_tool_xmr xmrwallet create $TMPDIR/*-XMR*.akeys
-	- $mmgen_tool_xmr xmrwallet sync $TMPDIR/*-XMR*.akeys wallets=3,23
-	x $mmgen_tool_xmr xmrwallet sync $TMPDIR/*-XMR*.akeys wallets=101-104
-	x $mmgen_tool_xmr xmrwallet sync $TMPDIR/*-XMR*.akeys
-	s test/stop-coin-daemons.py -W xmr
+	- $test_py --coin=xmr xmrwallet
 "
-f_xmr='Monero tests completed'
-
-[ "$FAST" ]                          && t_xmr_skip='x'
-[ "$RERUN_XMR" ]                     && t_xmr_skip='x s'
-[ "$RERUN_XMR" -a "$HAVE_XMR_DATA" ] && t_xmr_skip='a x s'
+f_xmr='Monero xmrwallet tests completed'
 
 i_eth='Ethereum'
 s_eth='Testing transaction and tracking wallet operations for Ethereum'
@@ -593,6 +564,4 @@ run_tests "$tests"
 TIME=$(($(date +%s)-START))
 MS=$(printf %02d:%02d $((TIME/60)) $((TIME%60)))
 
-[ "$RERUN_XMR" ] || rm -rf /tmp/mmgen-test-release-*
-
 [ "$LIST_CMDS" ] || echo -e "${GREEN}All OK.  Total elapsed time: $MS$RESET"

+ 5 - 3
test/test.py

@@ -356,6 +356,7 @@ cfgs = { # addr_idx_lists (except 31,32,33,34) must contain exactly 8 addresses
 	'22': {},
 	'23': {},
 	# 26,27,28 are taken
+	'29': {}, # xmrwallet
 	'31': {},
 	'32': {},
 	'33': {},
@@ -509,6 +510,7 @@ class CmdGroupMgr(object):
 		'regtest':          ('TestSuiteRegtest',{}),
 #		'chainsplit':       ('TestSuiteChainsplit',{}),
 		'ethdev':           ('TestSuiteEthdev',{}),
+		'xmrwallet':        ('TestSuiteXMRWallet',{}),
 	}
 
 	cmd_groups_extra = {
@@ -1000,17 +1002,17 @@ if opt.pause:
 	set_restore_term_at_exit()
 
 set_environ_for_spawned_scripts()
-if network_id not in ('eth','etc'):
+if network_id not in ('eth','etc','xmr'):
 	start_test_daemons(network_id,remove_datadir=True)
 
 try:
 	tr = TestSuiteRunner(data_dir,trash_dir)
 	tr.run_tests(usr_args)
 	tr.warn_skipped()
-	if network_id not in ('eth','etc'):
+	if network_id not in ('eth','etc','xmr'):
 		stop_test_daemons(network_id)
 except KeyboardInterrupt:
-	if network_id not in ('eth','etc'):
+	if network_id not in ('eth','etc','xmr'):
 		stop_test_daemons(network_id)
 	tr.warn_skipped()
 	die(1,'\ntest.py exiting at user request')

+ 523 - 0
test/test_py_d/ts_xmrwallet.py

@@ -0,0 +1,523 @@
+#!/usr/bin/env python3
+#
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
+# Copyright (C)2013-2021 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/>.
+
+"""
+ts_xmrwallet.py: xmrwallet tests for the test.py test suite
+"""
+
+import sys,os,atexit,asyncio
+from subprocess import run,PIPE
+
+from mmgen.globalvars import g
+from mmgen.opts import opt
+from mmgen.obj import MMGenRange,XMRAmt
+from mmgen.addr import KeyAddrList,AddrIdxList
+from ..include.common import *
+from .common import *
+
+from .ts_base import *
+
+class TestSuiteXMRWallet(TestSuiteBase):
+	"""
+	Monero wallet operations
+	"""
+	networks = ('xmr',)
+	passthru_opts = ('coin',)
+	tmpdir_nums = [29]
+	dfl_random_txs = 3
+	cmd_group = (
+		('gen_kafiles',               'generating key-address files'),
+		('create_wallets',            'creating Monero wallets'),
+
+		('set_dest_miner',            'opening miner wallet'),
+		('mine_blocks',               'mining blocks'),
+
+		('fund_alice',                'sending funds'),
+		('mine_blocks_tx',            'mining blocks'),
+
+		('sync_wallets',              'syncing all wallets'),
+		('sync_wallets_selected',     'syncing selected wallets'),
+
+		('sweep_to_address_proxy',    'sweeping to new address (via TX relay + proxy)'),
+		('mine_blocks',               'mining blocks'),
+
+		('sweep_to_account',          'sweeping to new account'),
+		('mine_blocks',               'mining blocks'),
+
+		('sweep_to_address_noproxy',  'sweeping to new address (via TX relay)'),
+		('mine_blocks',               'mining blocks'),
+	)
+
+	def __init__(self,trunner,cfgs,spawn):
+		TestSuiteBase.__init__(self,trunner,cfgs,spawn)
+		if trunner == None:
+			return
+
+		from mmgen.protocol import init_proto
+		self.proto = init_proto('XMR',network='testnet')
+		self.datadir_base  = os.path.join('test','daemons','xmrtest')
+		self.tool_args = ['--testnet=1', '--monero-wallet-rpc-password=passw0rd']
+		self.init_users()
+		self.init_daemon_args()
+
+		for v in self.users.values():
+			run(['mkdir','-p',v.udir])
+
+		if not opt.no_daemon_autostart:
+			self.start_daemons()
+			self.start_wallet_daemons()
+
+		if not opt.no_daemon_stop:
+			atexit.register(self.stop_daemons)
+			atexit.register(self.stop_wallet_daemons)
+
+		self.init_proxy()
+		self.balance = None
+
+	# init methods
+
+	def init_proxy(self):
+
+		def kill_proxy():
+			omsg(f'Killing SSH SOCKS server at localhost:{self.socks_port}')
+			cmd = [ 'pkill', '-f', ' '.join(a + b2) ]
+			run(cmd)
+
+		self.use_proxy = False
+		self.socks_port = 9060
+		a = ['ssh','-x','-o','ExitOnForwardFailure=True','-D',f'localhost:{self.socks_port}']
+		b1 = ['localhost','true']
+		b2 = ['-fN','-E','txrelay-proxy.debug','localhost']
+
+		cp = run(a+b1,stdout=PIPE,stderr=PIPE)
+
+		if cp.returncode == 0:
+			if not opt.no_daemon_autostart:
+				run(a+b2)
+				omsg(f'SSH SOCKS server started, listening at localhost:{self.socks_port}')
+				self.use_proxy = True
+		elif b'already in use' in cp.stderr:
+			omsg(f'Port {self.socks_port} already in use.  Assuming SSH SOCKS server is running')
+			self.use_proxy = True
+		else:
+			m1 = 'Unable to start command {!r}\n'.format(' '.join(a + b))
+			m2 = 'Will not test proxied TX relay daemon'
+			omsg(cp.stderr.decode())
+			omsg(yellow(m1+m2))
+
+		if not opt.no_daemon_stop:
+			atexit.register(kill_proxy)
+
+	def init_users(self):
+		from mmgen.daemon import CoinDaemon,MoneroWalletDaemon
+		from mmgen.rpc import MoneroRPCClient,MoneroRPCClientRaw,MoneroWalletRPCClient
+		self.users = {}
+		n = self.tmpdir_nums[0]
+		ud = namedtuple('user_data',[
+			'sid',
+			'mmwords',
+			'udir',
+			'datadir',
+			'kal_range',
+			'kafile',
+			'walletfile_fs',
+			'addrfile_fs',
+			'md',
+			'md_rpc',
+			'md_json_rpc',
+			'wd',
+			'wd_rpc',
+		])
+		for user,sid,shift,kal_range in ( # kal_range must be None, a single digit, or a single hyphenated range
+				('miner', '98831F3A', 130,  '1'),
+				('bob',   '1378FC64', 140, None),
+				('alice', 'FE3C6545', 150, '1-4'),
+			):
+			udir = os.path.join('test',f'tmp{n}',user)
+			datadir = os.path.join(self.datadir_base,user)
+			md = CoinDaemon(
+				proto      = self.proto,
+				test_suite = True,
+				port_shift = shift,
+				opts       = ['online'],
+				datadir    = datadir
+			)
+			md_rpc = MoneroRPCClientRaw(
+				host   = md.host,
+				port   = md.rpc_port,
+				user   = None,
+				passwd = None,
+				test_connection = False,
+			)
+			md_json_rpc = MoneroRPCClient(
+				host   = md.host,
+				port   = md.rpc_port,
+				user   = None,
+				passwd = None,
+				test_connection = False,
+			)
+			wd = MoneroWalletDaemon(
+				user = 'foo',
+				passwd = 'bar',
+				wallet_dir = udir,
+				test_suite = True,
+				port_shift = shift,
+				datadir    = os.path.join('test','daemons'),
+				daemon_addr = f'127.0.0.1:{md.rpc_port}',
+				testnet = True
+			)
+			wd_rpc = MoneroWalletRPCClient(
+				host   = wd.host,
+				port   = wd.rpc_port,
+				user   = wd.user,
+				passwd = wd.passwd,
+				test_connection = False,
+			)
+			self.users[user] = ud(
+				sid           = sid,
+				mmwords       = f'test/ref/{sid}.mmwords',
+				udir          = udir,
+				datadir       = datadir,
+				kal_range     = kal_range,
+				kafile        = f'{udir}/{sid}-XMR-M[{kal_range}].testnet.akeys',
+				walletfile_fs = f'{udir}/{sid}-{{}}-MoneroWallet.testnet',
+				addrfile_fs   = f'{udir}/{sid}-{{}}-MoneroWallet.testnet.address.txt',
+				md            = md,
+				md_rpc        = md_rpc,
+				md_json_rpc   = md_json_rpc,
+				wd            = wd,
+				wd_rpc        = wd_rpc,
+			)
+
+	def init_daemon_args(self):
+		common_args = ['--p2p-bind-ip=127.0.0.1','--fixed-difficulty=1'] # ,'--rpc-ssl-allow-any-cert']
+		for u in self.users:
+			other_ports = [self.users[u2].md.p2p_port for u2 in self.users if u2 != u]
+			node_args = [f'--add-exclusive-node=127.0.0.1:{p}' for p in other_ports]
+			self.users[u].md.usr_coind_args = common_args + node_args
+
+	# cmd_group methods
+
+	def gen_kafiles(self):
+		for user,data in self.users.items():
+			if not data.kal_range:
+				continue
+			run(['mkdir','-p',data.udir])
+			run(f'rm -f {data.kafile}',shell=True)
+			t = self.spawn(
+				'mmgen-keygen', [
+					'--testnet=1','-q', '--accept-defaults', '--coin=xmr',
+					f'--outdir={data.udir}', data.mmwords, data.kal_range
+				],
+				extra_desc = f'({capfirst(user)})' )
+			t.read()
+			t.ok()
+		t.skip_ok = True
+		return t
+
+	def create_wallets(self):
+		for user,data in self.users.items():
+			if not data.kal_range:
+				continue
+			run('rm -f {}*'.format( data.walletfile_fs.format('*') ),shell=True)
+			dir_arg = [f'--outdir='+data.udir]
+			cmd_opts = ['wallets={}'.format(data.kal_range)]
+			t = self.spawn(
+				'mmgen-tool',
+				self.tool_args + dir_arg + [ 'xmrwallet', 'create', data.kafile ] + cmd_opts,
+				extra_desc = f'({capfirst(user)})' )
+			t.expect('Check key-to-address validity? (y/N): ','n')
+			for i in MMGenRange(data.kal_range).items:
+				t.expect('Address: ')
+			t.read()
+			t.ok()
+		t.skip_ok = True
+		return t
+
+	async def set_dest_miner(self):
+		self.do_msg()
+		self.set_dest('miner',1,0,lambda x: x > 20,'unlocked balance > 20')
+		await self.open_wallet_user('miner',1)
+		return 'ok'
+
+	async def fund_alice(self):
+		self.do_msg()
+		await self.transfer(
+			'miner',
+			1234567891234,
+			read_from_file(self.users['alice'].addrfile_fs.format(1)),
+		)
+		self.set_dest('alice',1,0,lambda x: x > 1,'unlocked balance > 1')
+		return 'ok'
+
+	def sync_wallets_selected(self):
+		return self.sync_wallets(wallets='1,3-4')
+
+	def sync_wallets(self,wallets=None):
+		data = self.users['alice']
+		dir_arg = [f'--outdir={data.udir}']
+		cmd_opts = list_gen(
+			[f'daemon=localhost:{data.md.rpc_port}'],
+			[f'wallets={wallets}', wallets],
+		)
+		t = self.spawn(
+			'mmgen-tool',
+			self.tool_args + dir_arg + [ 'xmrwallet', 'sync', data.kafile ] + cmd_opts )
+		t.expect('Check key-to-address validity? (y/N): ','n')
+		wlist = AddrIdxList(wallets) if wallets else MMGenRange(data.kal_range).items
+		for n,wnum in enumerate(wlist):
+			t.expect('Syncing wallet {}/{} ({})'.format(
+				n+1,
+				len(wlist),
+				os.path.basename(data.walletfile_fs.format(wnum)),
+			))
+			t.expect('Chain height: ')
+			t.expect('Wallet height: ')
+			t.expect('Balance: ')
+		t.read()
+		return t
+
+	def _sweep_user(self,user,spec,tx_relay_daemon=None):
+		data = self.users[user]
+		dir_arg = [f'--outdir='+data.udir]
+		cmd_opts = list_gen(
+			[f'daemon=localhost:{data.md.rpc_port}'],
+			[f'wallets={spec}'],
+			[f'tx_relay_daemon={tx_relay_daemon}', tx_relay_daemon]
+		)
+		t = self.spawn(
+			'mmgen-tool',
+			self.tool_args + dir_arg + [ 'xmrwallet', 'sweep', data.kafile ] + cmd_opts,
+			extra_desc = f'({capfirst(user)})' )
+		t.expect('Check key-to-address validity? (y/N): ','n')
+		t.expect(
+			'Create new {} .* \(y/N\): '.format('account' if ',' in spec else 'address'),
+			'y', regex=True )
+		t.expect('Relay sweep transaction? (y/N): ','y')
+		t.read()
+		return t
+
+	def sweep_to_address_proxy(self):
+		ret = self._sweep_user(
+			'alice',
+			'1:0',
+			tx_relay_daemon = 'localhost:{}:127.0.0.1:{}'.format( # proxy must be IP, not 'localhost'
+				self.users['bob'].md.rpc_port,
+				self.socks_port
+			) if self.use_proxy else None
+		)
+		self.set_dest('alice',1,0,lambda x: x > 1,'unlocked balance > 1')
+		return ret
+
+	def sweep_to_account(self):
+		ret = self._sweep_user('alice','1:0,2')
+		self.set_dest('alice',2,1,lambda x: x > 1,'unlocked balance > 1')
+		return ret
+
+	def sweep_to_address_noproxy(self):
+		ret = self._sweep_user(
+			'alice',
+			'2:1',
+			tx_relay_daemon = 'localhost:{}'.format(self.users['bob'].md.rpc_port)
+		)
+		self.set_dest('alice',2,1,lambda x: x > 1,'unlocked balance > 1')
+		return ret
+
+	# wallet methods
+
+	async def open_wallet_user(self,user,wnum):
+		data = self.users[user]
+		silence()
+		kal = KeyAddrList(self.proto,data.kafile,skip_key_address_validity_check=True)
+		end_silence()
+		return await data.wd_rpc.call(
+			'open_wallet',
+			filename = os.path.basename(data.walletfile_fs.format(wnum)),
+			password = kal.entry(wnum).wallet_passwd )
+
+	async def close_wallet_user(self,user):
+		ret = await self.users[user].wd_rpc.call('close_wallet')
+		return 'ok'
+
+	# mining methods
+
+	async def start_mining(self):
+		data = self.users['miner']
+		addr = read_from_file(data.addrfile_fs.format(1)) # mine to wallet #1, account 0
+
+		for i in range(20):
+			ret = await data.md_rpc.call(
+				'start_mining',
+				do_background_mining = False, # run mining in background or foreground
+				ignore_battery       = True,  # ignore battery state (on laptop)
+				miner_address        = addr,  # account address to mine to
+				threads_count        = 3 )    # number of mining threads to run
+			status = self.get_status(ret)
+			if status == 'OK':
+				return True
+			elif status == 'BUSY':
+				await asyncio.sleep(5)
+				omsg('Daemon busy.  Attempting to start mining...')
+			else:
+				die(2,f'Monerod returned status {status}')
+		else:
+			die(2,'Max retries exceeded')
+
+	async def stop_mining(self):
+		ret = await self.users['miner'].md_rpc.call('stop_mining')
+		return self.get_status(ret)
+
+	async def mine_blocks(self,random_txs=None):
+		"""
+		- open destination wallet
+		- optionally create and broadcast random TXs
+		- start mining
+		- mine until funds appear in wallet
+		- stop mining
+		- close wallet
+		"""
+
+		async def get_height():
+			u = self.users['miner']
+			for i in range(20):
+				try:
+					return (await u.md_json_rpc.call('get_last_block_header'))['block_header']['height']
+				except Exception as e:
+					if 'onnection refused' in str(e):
+						omsg(f'{e}\nMonerod appears to have crashed. Attempting to restart...')
+						await asyncio.sleep(5)
+						u.md.restart()
+						await asyncio.sleep(5)
+						await self.start_mining()
+					else:
+						raise
+			else:
+				die(2,'Restart attempt limit exceeded')
+
+		async def send_random_txs():
+			from mmgen.tool import tool_api
+			t = tool_api()
+			t.init_coin('XMR','testnet')
+			t.usr_randchars = 0
+			imsg_r(f'Sending random transactions: ')
+			for i in range(random_txs):
+				await self.transfer(
+					'miner',
+					123456789,
+					t.randpair()[1],
+				)
+				imsg_r(f'{i+1} ')
+				oqmsg_r('+')
+				await asyncio.sleep(0.5)
+			imsg('')
+
+		def print_balance(dest,ub):
+			imsg('Total balance in {}’s wallet #{}, account {}: {}'.format(
+				capfirst(dest.user),
+				dest.wnum,
+				dest.account,
+				ub.hl()
+			))
+
+		async def get_balance(dest):
+			data = self.users[dest.user]
+			await data.wd_rpc.call('refresh')
+			ret = await data.wd_rpc.call('get_accounts')
+			return XMRAmt(ret['subaddress_accounts'][dest.account]['unlocked_balance'],from_unit='atomic')
+
+		self.do_msg(extra_desc=f'+{random_txs} random TXs' if random_txs else None)
+
+		if self.dest.user != 'miner':
+			await self.open_wallet_user(self.dest.user,self.dest.wnum)
+
+		if random_txs:
+			await send_random_txs()
+
+		await self.start_mining()
+
+		h = await get_height()
+		imsg_r(f'Chain height: {h} ')
+
+		while True:
+			ub = await get_balance(self.dest)
+			if self.dest.test(ub):
+				imsg('')
+				oqmsg_r('+')
+				print_balance(self.dest,ub)
+				break
+#			else:
+#				imsg(f'Test {self.dest.test_desc!r} failed')
+			await asyncio.sleep(2)
+			h = await get_height()
+			imsg_r(f'{h} ')
+			oqmsg_r('+')
+
+		await self.stop_mining()
+
+		if self.dest.user != 'miner':
+			await self.close_wallet_user(self.dest.user)
+
+		return 'ok'
+
+	async def mine_blocks_tx(self):
+		return await self.mine_blocks(random_txs=self.dfl_random_txs)
+
+	# util methods
+
+	def get_status(self,ret):
+		if ret['status'] != 'OK':
+			imsg( 'RPC status: {}'.format(ret['status']) )
+		return ret['status']
+
+	def do_msg(self,extra_desc=None):
+		self.spawn(
+			'',
+			msg_only = True,
+			extra_desc = f'({extra_desc})' if extra_desc else None
+		)
+
+	def set_dest(self,user,wnum,account,test,test_desc):
+		self.dest = namedtuple(
+			'dest_info',['user','wnum','account','test','test_desc'])(user,wnum,account,test,test_desc)
+
+	async def transfer(self,user,amt,addr):
+		return await self.users[user].wd_rpc.call('transfer',destinations=[{'amount':amt,'address':addr}])
+
+	# daemon start/stop methods
+
+	def start_daemons(self):
+		self.stop_daemons()
+		for v in self.users.values():
+			run(['mkdir','-p',v.datadir])
+			v.md.start()
+
+	def stop_daemons(self):
+		for v in self.users.values():
+			if v.md.state != 'stopped':
+				v.md.stop()
+		run(['rm','-rf',self.datadir_base])
+
+	def start_wallet_daemons(self):
+		for v in self.users.values():
+			if v.kal_range:
+				v.wd.start()
+
+	def stop_wallet_daemons(self):
+		for v in self.users.values():
+			if v.kal_range and v.wd.state != 'stopped':
+				v.wd.stop()