Browse Source

cmdtest.py ethdev: new `CmdTestEthdevMethods` class

The MMGen Project 3 days ago
parent
commit
c08fbd2cc2
4 changed files with 317 additions and 203 deletions
  1. 5 0
      mmgen/proto/eth/misc.py
  2. 24 57
      test/cmdtest_d/automount_eth.py
  3. 275 146
      test/cmdtest_d/ethdev.py
  4. 13 0
      test/cmdtest_d/shared.py

+ 5 - 0
mmgen/proto/eth/misc.py

@@ -71,3 +71,8 @@ def ec_recover_pubkey(cfg, message, sig, msghash_type):
 		*ecdsa_raw_recover(
 			hash_message(cfg, message, msghash_type), tuple(int(hexstr, 16) for hexstr in (v, r, s)))
 	)
+
+def compute_contract_addr(cfg, deployer_addr, nonce):
+	from . import rlp
+	encoded = rlp.encode([bytes.fromhex(deployer_addr), nonce])
+	return get_keccak(cfg)(encoded).hexdigest()[-40:]

+ 24 - 57
test/cmdtest_d/automount_eth.py

@@ -14,11 +14,11 @@ test.cmdtest_d.automount_eth: Ethereum automount autosigning tests for the cmdte
 import os, re
 
 from .autosign import CmdTestAutosignThreaded
-from .ethdev import CmdTestEthdev, parity_devkey_fn
+from .ethdev import CmdTestEthdev, CmdTestEthdevMethods
 from .include.common import dfl_words_file
-from ..include.common import cfg
+from ..include.common import cfg, joinpath
 
-class CmdTestAutosignETH(CmdTestAutosignThreaded, CmdTestEthdev):
+class CmdTestAutosignETH(CmdTestAutosignThreaded, CmdTestEthdev, CmdTestEthdevMethods):
 	'automounted transacting operations for Ethereum via ethdev'
 
 	networks = ('eth', 'etc')
@@ -41,8 +41,8 @@ class CmdTestAutosignETH(CmdTestAutosignThreaded, CmdTestEthdev):
 		('token_deploy1c',         'deploying ERC20 token #1 (Token)'),
 		('tx_status2',             'getting the transaction status'),
 		('token_fund_user',        'transferring token funds from dev to user'),
-		('token_addrgen_addr1',    'generating token addresses'),
-		('token_addrimport_addr1', 'importing token addresses using token address (MM1)'),
+		('token_addrgen',          'generating token addresses'),
+		('token_addrimport',       'importing token addresses using token address (MM1)'),
 		('token_bal1',             f'the {cfg.coin} balance and token balance'),
 		('create_token_tx',        'creating a token transaction'),
 		('send_token_tx',          'sending a token transaction'),
@@ -62,33 +62,11 @@ class CmdTestAutosignETH(CmdTestAutosignThreaded, CmdTestEthdev):
 		self.txop_opts = ['--autosign', '--regtest=1', '--quiet']
 
 	def fund_mmgen_address(self):
-		keyfile = os.path.join(self.tmpdir, parity_devkey_fn)
-		t = self.spawn(
-			'mmgen-txdo',
-			self.eth_args
-			+ [f'--keys-from-file={keyfile}']
-			+ ['--fee=40G', '98831F3A:E:1,123.456', dfl_words_file],
-		)
-		t.expect('efresh balance:\b', 'q')
-		t.expect('from: ', '10')
-		t.expect('(Y/n): ', 'y')
-		t.expect('(Y/n): ', 'y')
-		t.expect('(y/N): ', 'n')
-		t.expect('view: ', 'n')
-		t.expect('confirm: ', 'YES')
-		return t
+		return self._fund_mmgen_address(arg='98831F3A:E:1,123.456')
 
 	def create_tx(self):
 		self.insert_device_online()
-		t = self.spawn('mmgen-txcreate', self.txop_opts + ['-B', '98831F3A:E:11,54.321'])
-		t = self.txcreate_ui_common(
-			t,
-			caller            = 'txcreate',
-			input_sels_prompt = 'to spend from',
-			inputs            = '1',
-			file_desc         = 'transaction',
-			interactive_fee   = '50G',
-			fee_desc          = 'transaction fee or gas price')
+		t = self._create_tx(fee='50G', args=['98831F3A:E:11,54.321'], add_opts=self.txop_opts)
 		t.read()
 		self.remove_device_online()
 		return t
@@ -96,49 +74,38 @@ class CmdTestAutosignETH(CmdTestAutosignThreaded, CmdTestEthdev):
 	def run_autosign_setup(self):
 		return self.run_setup(mn_type='bip39', mn_file='test/ref/98831F3A.bip39', use_dfl_wallet=None)
 
-	def send_tx(self, add_args=[]):
+	def send_tx(self):
 		self._wait_signed('transaction')
 		self.insert_device_online()
-		t = self.spawn('mmgen-txsend', self.txop_opts + add_args, no_passthru_opts=['coin'])
-		t.view_tx('t')
-		t.expect('(y/N): ', 'n')
-		self._do_confirm_send(t, quiet=True)
-		t.written_to_file('Sent automount transaction')
+		t = self._send_tx(desc='automount transaction', add_opts=self.txop_opts)
 		t.read()
 		self.remove_device_online()
 		return t
 
-	def token_fund_user(self):
-		return self.token_transfer_ops(op='do_transfer', num_tokens=1)
+	def token_addrgen(self):
+		return self._token_addrgen(mm_idxs=[11], naddrs=3)
+
+	def token_addrimport(self):
+		return self._token_addrimport('token_addr1', '11-13', expect='3/3')
 
-	def token_addrgen_addr1(self):
-		return self.token_addrgen(num_tokens=1)
+	def token_fund_user(self):
+		return self._token_transfer_ops(op='fund_user', mm_idxs=[11])
 
 	def token_bal1(self):
-		return self.token_bal(pat=r':E:11\s+1000\s+54\.321\s+')
+		return self._bal_check(pat=r':E:11\s+1000\s+54\.321\s+')
 
 	def token_bal2(self):
-		return self.token_bal(pat=r':E:11\s+998.76544\s+54.318\d+\s+.*:E:12\s+1\.23456\s+')
-
-	def token_bal(self, pat):
-		self.mining_delay()
-		t = self.spawn('mmgen-tool', ['--regtest=1', '--token=mm1', 'twview', 'wide=1'])
-		text = t.read(strip_color=True)
-		assert re.search(pat, text, re.DOTALL), f'output failed to match regex {pat}'
-		return t
+		return self._bal_check(pat=r':E:11\s+998.76544\s+54.318\d+\s+.*:E:12\s+1\.23456\s+')
 
 	def create_token_tx(self):
 		self.insert_device_online()
-		t = self.txcreate_ui_common(
-			self.spawn(
-				'mmgen-txcreate',
-				self.txop_opts + ['--token=MM1', '-B', '--fee=50G', '98831F3A:E:12,1.23456']),
-			inputs            = '1',
-			input_sels_prompt = 'to spend from',
-			file_desc         = 'Unsigned automount transaction')
+		t = self._create_token_tx(
+			cmd = 'txcreate',
+			fee = '50G',
+			args = ['98831F3A:E:12,1.23456'],
+			add_opts = self.txop_opts)
 		t.read()
 		self.remove_device_online()
 		return t
 
-	def send_token_tx(self):
-		return self.send_tx()
+	send_token_tx = send_tx

+ 275 - 146
test/cmdtest_d/ethdev.py

@@ -26,8 +26,9 @@ from collections import namedtuple
 from subprocess import run, PIPE, DEVNULL
 from pathlib import Path
 
-from mmgen.color import yellow, blue, cyan, set_vt100
+from mmgen.color import red, yellow, blue, cyan, set_vt100
 from mmgen.util import msg, rmsg, die
+from mmgen.proto.eth.misc import compute_contract_addr
 
 from ..include.common import (
 	cfg,
@@ -43,8 +44,8 @@ from ..include.common import (
 	silence,
 	end_silence,
 	gr_uc,
-	stop_test_daemons
-)
+	stop_test_daemons)
+
 from .include.common import (
 	ref_dir,
 	dfl_words_file,
@@ -54,8 +55,9 @@ from .include.common import (
 	tw_comment_lat_cyr_gr,
 	get_file_with_ext,
 	ok_msg,
-	Ctrl_U
-)
+	Ctrl_U,
+	cleanup_env)
+
 from .base import CmdTestBase
 from .shared import CmdTestShared
 from .httpd.etherscan import EtherscanServer
@@ -120,7 +122,227 @@ def set_vbals(daemon_id):
 
 coin = cfg.coin
 
-class CmdTestEthdev(CmdTestBase, CmdTestShared):
+class CmdTestEthdevMethods: # mixin class
+
+	def _addrgen(self, addrs='1-3,11-13,21-23', no_msg=False):
+		t = self.spawn(
+			'mmgen-addrgen',
+			[f'--coin={self.proto.coin}'] + self.eth_args + [dfl_words_file, addrs],
+			no_msg = no_msg,
+			no_passthru_opts = True)
+		t.written_to_file('Addresses')
+		return t
+
+	def _create_tx(self, *, fee, args, add_opts=[]):
+		return self.txcreate_ui_common(
+			self.spawn('mmgen-txcreate', add_opts + ['-B'] + args),
+			caller            = 'txcreate',
+			input_sels_prompt = 'to spend from',
+			inputs            = '1',
+			file_desc         = 'transaction',
+			interactive_fee   = fee,
+			fee_desc          = 'transaction fee or gas price')
+
+	def _send_tx(self, *, desc='transaction', add_opts=[]):
+		t = self.spawn('mmgen-txsend', add_opts, no_passthru_opts=['coin'])
+		t.view_tx('t')
+		t.expect('(y/N): ', 'n')
+		self._do_confirm_send(t, quiet=True)
+		t.written_to_file(f'Sent {desc}')
+		return t
+
+	def _txdo(self, *, args, acct, fee='50G'):
+		t = self.txcreate(
+			args,
+			acct            = acct,
+			caller          = 'txdo',
+			no_read         = True,
+			bad_input_sels  = False,
+			interactive_fee = fee,
+			print_listing   = False)
+		t.written_to_file('Signed transaction')
+		t.expect('confirm: ', 'YES\n')
+		return t
+
+	def _txbump(self, *, fee, ext, add_opts=[], add_args=[]):
+		ext = ext.format('-α' if self.cfg.debug_utf8 else '')
+		txfile = self.get_file_with_ext(ext, no_dot=True)
+		t = self.spawn('mmgen-txbump', self.eth_args + add_opts + ['--yes', txfile] + add_args)
+		t.expect('or gas price: ', fee+'\n')
+		return t
+
+	def _tx_receipt(self, ext='{}.regtest.sigtx'):
+		self.mining_delay()
+		return self.txsend(
+			ext = ext,
+			add_args = ['--receipt'],
+			return_early = True,
+			env = cleanup_env(cfg=self.cfg))
+
+	def _fund_mmgen_address(self, arg):
+		return self._txdo(
+			args = [f'--keys-from-file={joinpath(self.tmpdir, parity_devkey_fn)}', arg, dfl_words_file],
+			acct = '10')
+
+	def _token_addrgen(self, *, mm_idxs, naddrs):
+		self.spawn(msg_only=True)
+		for idx in mm_idxs:
+			t = self._addrgen(addrs=f'{idx}-{idx+naddrs-1}', no_msg=True)
+		return t
+
+	def _token_addrimport(self, addr_file, addr_range, expect, extra_args=[]):
+		token_addr = self.read_from_tmpfile(addr_file).strip()
+		return self.addrimport(
+			ext      = f'[{addr_range}]{{}}.regtest.addrs',
+			expect   = expect,
+			add_args = ['--token-addr='+token_addr]+extra_args)
+
+	def _get_contract_address(self, deployer_addr):
+		t = self.spawn(
+			'mmgen-cli',
+			['--regtest=1', 'eth_getTransactionCount', '0x'+deployer_addr, 'pending'],
+			env = cleanup_env(cfg=self.cfg),
+			silent = True,
+			no_msg = True)
+		nonce = t.read().strip()
+		imsg(f'Nonce: {red(nonce)}')
+		ret = compute_contract_addr(self.cfg, deployer_addr, int(nonce, 16))
+		imsg(f'Computed contract address: {blue(ret)}')
+		return ret
+
+	async def _token_deploy(self, num, key, gas, mmgen_cmd='txdo', gas_price='8G', get_receipt=True):
+		keyfile = joinpath(self.tmpdir, parity_devkey_fn)
+		fn = joinpath(self.tmpdir, 'mm'+str(num), key+'.bin')
+		args = [
+			'-B',
+			f'--fee={gas_price}',
+			f'--gas={gas}',
+			f'--contract-data={fn}',
+			f'--inputs={dfl_devaddr}',
+			'--yes',
+		]
+
+		contract_addr = self._get_contract_address(dfl_devaddr)
+		if key == 'Token':
+			self.write_to_tmpfile(f'token_addr{num}', contract_addr+'\n')
+
+		if mmgen_cmd == 'txdo':
+			args += ['-k', keyfile]
+		t = self.spawn('mmgen-'+mmgen_cmd, self.eth_args + args)
+		if mmgen_cmd == 'txcreate':
+			t.written_to_file('transaction')
+			ext = '[0,8000]{}.regtest.rawtx'.format('-α' if self.cfg.debug_utf8 else '')
+			txfile = self.get_file_with_ext(ext, no_dot=True)
+			t = self.spawn(
+				'mmgen-txsign',
+				self.eth_args + ['--yes', '-k', keyfile, txfile], no_msg=True, no_passthru_opts=['coin'])
+			self.txsign_ui_common(t, ni=True)
+
+			txfile = txfile.replace('.rawtx', '.sigtx')
+			t = self.spawn('mmgen-txsend',
+				self.eth_args + [txfile], no_msg=True, no_passthru_opts=['coin'])
+
+		txid = self.txsend_ui_common(t,
+			caller = mmgen_cmd,
+			quiet  = mmgen_cmd == 'txdo' or not self.cfg.debug,
+			bogus_send = False)
+
+		_ = strip_ansi_escapes(t.expect_getend('Contract address: '))
+		assert _ == contract_addr, f'Contract address mismatch: {_} != {contract_addr}'
+
+		if get_receipt:
+			if (await self.get_tx_receipt(txid)).status == 0:
+				die(2, f'Contract {num}:{key} failed to execute. Aborting')
+
+		if key == 'Token':
+			imsg(f'\nToken MM{num} deployed!')
+
+		return t
+
+	async def _token_deploy_math(self, *, num, get_receipt=True, mmgen_cmd='txdo'):
+		return await self._token_deploy(
+			num=num, key='SafeMath', gas=500_000, get_receipt=get_receipt, mmgen_cmd=mmgen_cmd)
+
+	async def _token_deploy_owned(self, *, num, get_receipt=True):
+		return await self._token_deploy(
+			num=num, key='Owned', gas=1_000_000, get_receipt=get_receipt)
+
+	async def _token_deploy_token(self, *, num, get_receipt=True):
+		return await self._token_deploy(
+			num=num, key='Token', gas=4_000_000, gas_price='7G', get_receipt=get_receipt)
+
+	def _bal_check(self, *, pat):
+		self.mining_delay()
+		t = self.spawn('mmgen-tool', ['--regtest=1', '--token=mm1', 'twview', 'wide=1'])
+		text = t.read(strip_color=True)
+		assert re.search(pat, text, re.DOTALL), f'output failed to match regex {pat}'
+		return t
+
+	def _create_token_tx(self, *, cmd, fee, args, add_opts=[]):
+		return self.txcreate_ui_common(
+			self.spawn(
+				f'mmgen-{cmd}',
+				['--token=MM1', '-B', f'--fee={fee}'] + add_opts + args),
+			inputs            = '1',
+			input_sels_prompt = 'to spend from',
+			caller            = cmd,
+			file_desc         = 'Unsigned automount transaction')
+
+	async def _token_transfer_ops(self, *, op, mm_idxs, amt=1000, get_receipt=True, sid=dfl_sid):
+		self.spawn(msg_only=True)
+		from mmgen.tool.wallet import tool_cmd
+		usr_mmaddrs = [f'{sid}:E:{i}' for i in mm_idxs]
+
+		from mmgen.proto.eth.contract import ResolvedToken
+		async def fund_user(rpc):
+			for i in range(len(usr_mmaddrs)):
+				tk = await ResolvedToken(
+					self.cfg,
+					self.proto,
+					rpc,
+					self.read_from_tmpfile(f'token_addr{i+1}').strip())
+				imsg_r('\n' + await tk.info())
+				imsg('dev token balance (pre-send): {}'.format(await tk.get_balance(dfl_devaddr)))
+				imsg(f'Sending {amt} {self.proto.dcoin} to address {usr_addrs[i]} ({usr_mmaddrs[i]})')
+				txid = await tk.transfer(
+					from_addr = dfl_devaddr,
+					to_addr   = usr_addrs[i],
+					amt       = amt,
+					key       = dfl_devkey,
+					gas       = self.proto.coin_amt(120000, from_unit='wei'),
+					gasPrice  = self.proto.coin_amt(8, from_unit='Gwei'))
+
+				if get_receipt and (await self.get_tx_receipt(txid)).status == 0:
+					die(2, 'Transfer of token funds failed. Aborting')
+
+		async def show_bals(rpc):
+			for i in range(len(usr_mmaddrs)):
+				tk = await ResolvedToken(
+					self.cfg,
+					self.proto,
+					rpc,
+					self.read_from_tmpfile(f'token_addr{i+1}').strip())
+				imsg('Token: {}'.format(await tk.get_symbol()))
+				imsg(f'dev token balance: {await tk.get_balance(dfl_devaddr)}')
+				imsg('usr token balance: {} ({} {})'.format(
+					await tk.get_balance(usr_addrs[i]),
+					usr_mmaddrs[i],
+					usr_addrs[i]))
+
+		def gen_addr(addr):
+			return tool_cmd(
+				self.cfg, cmdname='gen_addr', proto=self.proto).gen_addr(addr, wallet=dfl_words_file)
+
+		silence()
+		usr_addrs = list(map(gen_addr, usr_mmaddrs))
+		if op == 'show_bals':
+			await show_bals(await self.rpc)
+		elif op == 'fund_user':
+			await fund_user(await self.rpc)
+		end_silence()
+		return 'ok'
+
+class CmdTestEthdev(CmdTestBase, CmdTestShared, CmdTestEthdevMethods):
 	'Ethereum transacting, token deployment and tracking wallet operations'
 	networks = ('eth', 'etc')
 	passthru_opts = ('coin', 'daemon_id', 'eth_daemon_id', 'http_timeout', 'rpc_backend')
@@ -595,7 +817,7 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 
 		make_key()
 		signer_addr = self.keystore_data['address']
-		self.write_to_tmpfile( 'signer_addr', signer_addr + '\n')
+		self.write_to_tmpfile('signer_addr', signer_addr + '\n')
 
 		imsg(f'  Keystore:           {self.keystore_dir}')
 		imsg(f'  Signer key:         {self.keystore_data["key"]}')
@@ -647,13 +869,8 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 	async def wallet_upgrade2(self):
 		return await self._wallet_upgrade('tracking-wallet-v2.json', 'token params field', 'network field')
 
-	def addrgen(self, addrs='1-3,11-13,21-23'):
-		t = self.spawn(
-			'mmgen-addrgen',
-			[f'--coin={self.proto.coin}'] + self.eth_args + [dfl_words_file, addrs],
-			no_passthru_opts = True)
-		t.written_to_file('Addresses')
-		return t
+	def addrgen(self):
+		return self._addrgen()
 
 	def addrimport(
 			self,
@@ -716,7 +933,7 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 			t.read()
 		return t
 
-	def txsign(self, ni=False, ext='{}.regtest.rawtx', add_args=[], dev_send=False):
+	def txsign(self, ni=False, ext='{}.regtest.rawtx', add_args=[], dev_send=False, has_label=True):
 		ext = ext.format('-α' if self.cfg.debug_utf8 else '')
 		keyfile = joinpath(self.tmpdir, parity_devkey_fn)
 		txfile = self.get_file_with_ext(ext, no_dot=True)
@@ -729,18 +946,31 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 				+ add_args
 				+ [txfile, dfl_words_file],
 			no_passthru_opts = ['coin'])
-		return self.txsign_ui_common(t, ni=ni, has_label=True)
+		return self.txsign_ui_common(t, ni=ni, has_label=has_label)
 
-	def txsend(self, ext='{}.regtest.sigtx', add_args=[], test=False):
+	def txsend(
+			self,
+			ext          = '{}.regtest.sigtx',
+			add_args     = [],
+			test         = False,
+			return_early = False,
+			has_label    = True,
+			env          = {}):
 		ext = ext.format('-α' if self.cfg.debug_utf8 else '')
 		txfile = self.get_file_with_ext(ext, no_dot=True)
-		t = self.spawn('mmgen-txsend', self.eth_args + add_args + [txfile], no_passthru_opts=['coin'])
+		t = self.spawn(
+			'mmgen-txsend',
+			self.eth_args + add_args + [txfile],
+			no_passthru_opts = ['coin'],
+			env = env)
+		if return_early:
+			return t
 		self.txsend_ui_common(
 			t,
 			quiet      = not self.cfg.debug,
 			bogus_send = False,
 			test       = test,
-			has_label  = True)
+			has_label  = has_label)
 		return t
 
 	def txview(self, ext_fs):
@@ -931,12 +1161,8 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 			interactive_fee  = '40G',
 			fee_info_data    = ('0.00084', '40'))
 
-	def txbump(self, ext=',40000]{}.regtest.rawtx', fee='50G', add_args=[]):
-		ext = ext.format('-α' if self.cfg.debug_utf8 else '')
-		txfile = self.get_file_with_ext(ext, no_dot=True)
-		t = self.spawn('mmgen-txbump', self.eth_args + add_args + ['--yes', txfile])
-		t.expect('or gas price: ', fee+'\n')
-		return t
+	def txbump(self):
+		return self._txbump(fee='50G', ext=',40000]{}.regtest.rawtx')
 
 	def txsign4(self):
 		return self.txsign(ext='.45495,50000]{}.regtest.rawtx', add_args=['--no-quiet', '--no-yes'])
@@ -1053,6 +1279,8 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 		tx = await NewTX(cfg=self.cfg, proto=self.proto, target='tx')
 		tx.rpc = await self.rpc
 		res = await tx.get_receipt(txid)
+		if not res:
+			die(1, f'Error getting receipt for transaction {txid}')
 		imsg(f'Gas sent:  {res.gas_sent.hl():<9} {(res.gas_sent*res.gas_price).hl2(encl="()")}')
 		imsg(f'Gas used:  {res.gas_used.hl():<9} {(res.gas_used*res.gas_price).hl2(encl="()")}')
 		imsg(f'Gas price: {res.gas_price.hl()}')
@@ -1060,50 +1288,23 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 			omsg(yellow('Warning: all gas was used!'))
 		return res
 
-	async def token_deploy(self, num, key, gas, mmgen_cmd='txdo', gas_price='8G'):
-		keyfile = joinpath(self.tmpdir, parity_devkey_fn)
-		fn = joinpath(self.tmpdir, 'mm'+str(num), key+'.bin')
-		args = [
-			'-B',
-			f'--fee={gas_price}',
-			f'--gas={gas}',
-			f'--contract-data={fn}',
-			f'--inputs={dfl_devaddr}',
-			'--yes',
-		]
-		if mmgen_cmd == 'txdo':
-			args += ['-k', keyfile]
-		t = self.spawn('mmgen-'+mmgen_cmd, self.eth_args + args)
-		if mmgen_cmd == 'txcreate':
-			t.written_to_file('transaction')
-			ext = '[0,8000]{}.regtest.rawtx'.format('-α' if self.cfg.debug_utf8 else '')
-			txfile = self.get_file_with_ext(ext, no_dot=True)
-			t = self.spawn(
-				'mmgen-txsign',
-				self.eth_args + ['--yes', '-k', keyfile, txfile], no_msg=True, no_passthru_opts=['coin'])
-			self.txsign_ui_common(t, ni=True)
-			txfile = txfile.replace('.rawtx', '.sigtx')
-			t = self.spawn('mmgen-txsend',
-				self.eth_args + [txfile], no_msg=True, no_passthru_opts=['coin'])
-
-		txid = self.txsend_ui_common(t,
-			caller = mmgen_cmd,
-			quiet  = mmgen_cmd == 'txdo' or not self.cfg.debug,
-			bogus_send = False)
-		addr = strip_ansi_escapes(t.expect_getend('Contract address: '))
-		if (await self.get_tx_receipt(txid)).status == 0:
-			die(2, f'Contract {num}:{key} failed to execute. Aborting')
-		if key == 'Token':
-			self.write_to_tmpfile(f'token_addr{num}', addr+'\n')
-			imsg(f'\nToken MM{num} deployed!')
-		return t
-
 	async def token_deploy1a(self):
-		return await self.token_deploy(num=1, key='SafeMath', gas=500_000)
+		return await self._token_deploy_math(num=1)
+
 	async def token_deploy1b(self):
-		return await self.token_deploy(num=1, key='Owned',    gas=1_000_000)
+		return await self._token_deploy_owned(num=1)
+
 	async def token_deploy1c(self):
-		return await self.token_deploy(num=1, key='Token',    gas=4_000_000, gas_price='7G')
+		return await self._token_deploy_token(num=1)
+
+	async def token_deploy2a(self): # test create, sign, send:
+		return await self._token_deploy_math(num=2, mmgen_cmd='txcreate')
+
+	async def token_deploy2b(self):
+		return await self._token_deploy_owned(num=2)
+
+	async def token_deploy2c(self):
+		return await self._token_deploy_token(num=2)
 
 	def tx_status2(self):
 		return self.tx_status(
@@ -1113,79 +1314,14 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 	def bal6(self):
 		return self.bal5()
 
-	async def token_deploy2a(self): # test create, sign, send:
-		return await self.token_deploy(num=2, key='SafeMath', gas=500_000, mmgen_cmd='txcreate')
-	async def token_deploy2b(self):
-		return await self.token_deploy(num=2, key='Owned',   gas=1_000_000)
-	async def token_deploy2c(self):
-		return await self.token_deploy(num=2, key='Token',   gas=4_000_000)
-
-	async def token_transfer_ops(self, op, amt=1000, num_tokens=2):
-		self.spawn(msg_only=True)
-		sid = dfl_sid
-		from mmgen.tool.wallet import tool_cmd
-		usr_mmaddrs = [f'{sid}:E:{i}' for i in (11, 21)][:num_tokens]
-
-		from mmgen.proto.eth.contract import ResolvedToken
-		async def do_transfer(rpc):
-			for i in range(num_tokens):
-				tk = await ResolvedToken(
-					self.cfg,
-					self.proto,
-					rpc,
-					self.read_from_tmpfile(f'token_addr{i+1}').strip())
-				imsg_r('\n' + await tk.info())
-				imsg('dev token balance (pre-send): {}'.format(await tk.get_balance(dfl_devaddr)))
-				imsg(f'Sending {amt} {self.proto.dcoin} to address {usr_addrs[i]} ({usr_mmaddrs[i]})')
-				txid = await tk.transfer(
-					from_addr = dfl_devaddr,
-					to_addr   = usr_addrs[i],
-					amt       = amt,
-					key       = dfl_devkey,
-					gas       = self.proto.coin_amt(120000, from_unit='wei'),
-					gasPrice  = self.proto.coin_amt(8, from_unit='Gwei'))
-				if (await self.get_tx_receipt(txid)).status == 0:
-					die(2, 'Transfer of token funds failed. Aborting')
-
-		async def show_bals(rpc):
-			for i in range(num_tokens):
-				tk = await ResolvedToken(
-					self.cfg,
-					self.proto,
-					rpc,
-					self.read_from_tmpfile(f'token_addr{i+1}').strip())
-				imsg('Token: {}'.format(await tk.get_symbol()))
-				imsg(f'dev token balance: {await tk.get_balance(dfl_devaddr)}')
-				imsg('usr token balance: {} ({} {})'.format(
-					await tk.get_balance(usr_addrs[i]),
-					usr_mmaddrs[i],
-					usr_addrs[i]))
-
-		def gen_addr(addr):
-			return tool_cmd(
-				self.cfg, cmdname='gen_addr', proto=self.proto).gen_addr(addr, wallet=dfl_words_file)
-
-		silence()
-		usr_addrs = list(map(gen_addr, usr_mmaddrs))
-		if op == 'show_bals':
-			await show_bals(await self.rpc)
-		elif op == 'do_transfer':
-			await do_transfer(await self.rpc)
-		end_silence()
-		return 'ok'
-
 	def token_fund_users(self):
-		return self.token_transfer_ops(op='do_transfer')
+		return self._token_transfer_ops(op='fund_user', mm_idxs=[11, 21])
 
 	def token_user_bals(self):
-		return self.token_transfer_ops(op='show_bals')
+		return self._token_transfer_ops(op='show_bals', mm_idxs=[11, 21])
 
-	def token_addrgen(self, num_tokens=2):
-		t = self.addrgen(addrs='11-13')
-		if num_tokens == 1:
-			return t
-		ok_msg()
-		return self.addrgen(addrs='21-23')
+	def token_addrgen(self):
+		return self._token_addrgen(mm_idxs=[11, 21], naddrs=3)
 
 	def token_addrimport_badaddr1(self):
 		t = self.addrimport(
@@ -1205,21 +1341,14 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 		t.expect('could not be resolved')
 		return t
 
-	def token_addrimport(self, addr_file, addr_range, expect, extra_args=[]):
-		token_addr = self.read_from_tmpfile(addr_file).strip()
-		return self.addrimport(
-			ext      = f'[{addr_range}]{{}}.regtest.addrs',
-			expect   = expect,
-			add_args = ['--token-addr='+token_addr]+extra_args)
-
 	def token_addrimport_addr1(self):
-		return self.token_addrimport('token_addr1', '11-13', expect='3/3')
+		return self._token_addrimport('token_addr1', '11-13', expect='3/3')
 
 	def token_addrimport_addr2(self):
-		return self.token_addrimport('token_addr2', '21-23', expect='3/3')
+		return self._token_addrimport('token_addr2', '21-23', expect='3/3')
 
 	def token_addrimport_batch(self):
-		return self.token_addrimport('token_addr1', '11-13', expect='3 addresses', extra_args=['--batch'])
+		return self._token_addrimport('token_addr1', '11-13', expect='3 addresses', extra_args=['--batch'])
 
 	def token_addrimport_sym(self):
 		return self.addrimport(
@@ -1285,7 +1414,7 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 	def token_txcreate2(self):
 		return self.token_txcreate(args=[burn_addr+', '+amt2], token='mm1')
 	def token_txbump(self):
-		return self.txbump(ext=amt2+',50000]{}.regtest.rawtx', fee='56G', add_args=['--token=mm1'])
+		return self._txbump(ext=amt2+',50000]{}.regtest.rawtx', fee='56G', add_opts=['--token=MM1'])
 	def token_txsign2(self):
 		return self.token_txsign(ext=amt2+',50000]{}.regtest.rawtx', token='mm1')
 	def token_txsend2(self):

+ 13 - 0
test/cmdtest_d/shared.py

@@ -118,6 +118,7 @@ class CmdTestShared:
 			return t
 
 		t.view_tx(view)
+
 		if not txdo:
 			t.expect('(y/N): ', ('n', 'y')[save])
 			t.written_to_file(file_desc)
@@ -192,6 +193,18 @@ class CmdTestShared:
 
 		return txid
 
+	def txbump_ui_common(self, t, *, fee, fee_desc='transaction fee', bad_fee=None):
+		t.expect('(Y/n): ', 'n') # network-estimated fee OK?
+		if bad_fee:
+			t.expect(f'{fee_desc}: ', f'{bad_fee}\n')
+		t.expect(f'{fee_desc}: ', f'{fee}\n')
+		t.expect('(Y/n): ', 'y') # fee OK?
+		t.expect('(Y/n): ', 'y') # signoff
+		t.expect('(y/N): ', 'n') # edit comment
+		t.expect('(y/N): ', 'y') # save TX?
+		t.written_to_file('Fee-bumped transaction')
+		return t
+
 	def txsign_end(self, t, tnum=None, has_label=False):
 		t.expect('Signing transaction')
 		t.do_comment(False, has_label=has_label)