Browse Source

Ethereum THORChain swaps

Only native ETH supported for now.  Work on ERC20 token swaps is underway.

Sample create-sign-send workflow for a BTC->ETH swap (assumes offline
autosigning is set up):

    $ mmgen-swaptxcreate --autosign BTC 0.12345 ETH

    remove device - insert - wait for signing - remove - insert

    $ mmgen-txsend --autosign

Create step for ETH->BTC swap:

    $ mmgen-swaptxcreate --autosign ETH 5.4321 BTC

For more information, see:

    $ mmgen-swaptxcreate --help

Testing:

    $ test/cmdtest.py ethswap
The MMGen Project 1 week ago
parent
commit
f0563031de

+ 8 - 4
doc/wiki/commands/command-help-swaptxcreate.md

@@ -64,8 +64,11 @@
 
   By default, the change and destination addresses are chosen automatically by
   finding the lowest-indexed unused addresses of the preferred address types in
-  the send and receive tracking wallets.  Types ‘B’, ‘S’ and ‘C’ (see ADDRESS
-  TYPES below) are searched in that order for unused addresses.
+  the send and receive tracking wallets.  For Bitcoin and forks, types ‘B’,
+  ‘S’ and ‘C’ (see ADDRESS TYPES below) are searched in that order for unused
+  addresses.  Note that sending to an unused address may be undesirable for
+  Ethereum, where address (i.e. account) reuse is the norm.  In that case, the
+  user should specify a destination address on the command line.
 
   If the wallet contains eligible unused addresses with multiple Seed IDs, the
   user will be presented with a list of the lowest-indexed addresses of
@@ -74,7 +77,8 @@
   Change and destination addresses may also be specified manually with the
   CHG_ADDR and ADDR arguments.  These may be given as full MMGen IDs or in the
   form ADDRTYPE_CODE or SEED_ID:ADDRTYPE_CODE (see EXAMPLES below and the
-  ‘mmgen-txcreate’ help screen for details).
+  ‘mmgen-txcreate’ help screen for details).  For Ethereum, the CHG_ADDR
+  argument is not supported.
 
   While discouraged, sending change or swapping to non-wallet addresses is also
   supported, in which case the signing script (‘mmgen-txsign’ or ‘mmgen-
@@ -196,5 +200,5 @@
 
       $ mmgen-tool --coin=bch --bch-rpc-host=gemini twview minconf=0
 
-  MMGEN v15.1.dev18              March 2025               MMGEN-SWAPTXCREATE(1)
+  MMGEN v15.1.dev23              March 2025               MMGEN-SWAPTXCREATE(1)
 ```

+ 8 - 4
doc/wiki/commands/command-help-swaptxdo.md

@@ -85,8 +85,11 @@
 
   By default, the change and destination addresses are chosen automatically by
   finding the lowest-indexed unused addresses of the preferred address types in
-  the send and receive tracking wallets.  Types ‘B’, ‘S’ and ‘C’ (see ADDRESS
-  TYPES below) are searched in that order for unused addresses.
+  the send and receive tracking wallets.  For Bitcoin and forks, types ‘B’,
+  ‘S’ and ‘C’ (see ADDRESS TYPES below) are searched in that order for unused
+  addresses.  Note that sending to an unused address may be undesirable for
+  Ethereum, where address (i.e. account) reuse is the norm.  In that case, the
+  user should specify a destination address on the command line.
 
   If the wallet contains eligible unused addresses with multiple Seed IDs, the
   user will be presented with a list of the lowest-indexed addresses of
@@ -95,7 +98,8 @@
   Change and destination addresses may also be specified manually with the
   CHG_ADDR and ADDR arguments.  These may be given as full MMGen IDs or in the
   form ADDRTYPE_CODE or SEED_ID:ADDRTYPE_CODE (see EXAMPLES below and the
-  ‘mmgen-txcreate’ help screen for details).
+  ‘mmgen-txcreate’ help screen for details).  For Ethereum, the CHG_ADDR
+  argument is not supported.
 
   While discouraged, sending change or swapping to non-wallet addresses is also
   supported, in which case the signing script (‘mmgen-txsign’ or ‘mmgen-
@@ -260,5 +264,5 @@
 
       $ mmgen-tool --coin=bch --bch-rpc-host=gemini twview minconf=0
 
-  MMGEN v15.1.dev18              March 2025                  MMGEN-SWAPTXDO(1)
+  MMGEN v15.1.dev23              March 2025                  MMGEN-SWAPTXDO(1)
 ```

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-15.1.dev22
+15.1.dev23

+ 7 - 3
mmgen/help/swaptxcreate.py

@@ -41,8 +41,11 @@ inputs will be swapped.
 
 By default, the change and destination addresses are chosen automatically by
 finding the lowest-indexed unused addresses of the preferred address types in
-the send and receive tracking wallets.  Types ‘B’, ‘S’ and ‘C’ (see ADDRESS
-TYPES below) are searched in that order for unused addresses.
+the send and receive tracking wallets.  For Bitcoin and forks, types ‘B’,
+‘S’ and ‘C’ (see ADDRESS TYPES below) are searched in that order for unused
+addresses.  Note that sending to an unused address may be undesirable for
+Ethereum, where address (i.e. account) reuse is the norm.  In that case, the
+user should specify a destination address on the command line.
 
 If the wallet contains eligible unused addresses with multiple Seed IDs, the
 user will be presented with a list of the lowest-indexed addresses of
@@ -51,7 +54,8 @@ preferred type for each Seed ID and prompted to choose from among them.
 Change and destination addresses may also be specified manually with the
 CHG_ADDR and ADDR arguments.  These may be given as full MMGen IDs or in the
 form ADDRTYPE_CODE or SEED_ID:ADDRTYPE_CODE (see EXAMPLES below and the
-‘mmgen-txcreate’ help screen for details).
+‘mmgen-txcreate’ help screen for details).  For Ethereum, the CHG_ADDR
+argument is not supported.
 
 While discouraged, sending change or swapping to non-wallet addresses is also
 supported, in which case the signing script (‘mmgen-txsign’ or ‘mmgen-

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

@@ -53,6 +53,7 @@ class mainnet(CoinProtocol.Secp256k1): # chainparams.cpp
 	start_subsidy   = 50
 	max_int         = 0xffffffff
 	max_op_return_data_len = 80
+	address_reuse_ok = False
 
 	coin_cfg_opts = (
 		'daemon_id',

+ 2 - 0
mmgen/proto/eth/params.py

@@ -37,6 +37,8 @@ class mainnet(CoinProtocol.DummyWIF, CoinProtocol.Secp256k1):
 	base_coin     = 'ETH'
 	avg_bdi       = 15
 	decimal_prec  = 36
+	address_reuse_ok = True
+	is_evm = True
 
 	# https://www.chainid.dev
 	chain_ids = {

+ 3 - 3
mmgen/proto/eth/tw/addresses.py

@@ -65,8 +65,7 @@ class EthereumTwAddresses(TwAddresses, EthereumTwView, EthereumTwRPC):
 
 	async def get_rpc_data(self):
 
-		amt0 = self.proto.coin_amt('0')
-		self.total = amt0
+		self.total = self.proto.coin_amt('0')
 		self.minconf = None
 		addrs = {}
 
@@ -75,7 +74,8 @@ class EthereumTwAddresses(TwAddresses, EthereumTwView, EthereumTwRPC):
 			addrs[e.label.mmid] = {
 				'addr':  e.coinaddr,
 				'amt':   bal,
-				'recvd': amt0,
+				'recvd': bal, # since it’s nearly impossible to empty an Ethereum account,
+							  # we consider a used account to be any account with a balance
 				'confs': 0,
 				'lbl':   e.label}
 			self.total += bal

+ 5 - 0
mmgen/proto/eth/tx/completed.py

@@ -18,6 +18,11 @@ from .base import Base, TokenBase
 class Completed(Base, TxBase.Completed):
 	fn_fee_unit = 'Mwei'
 
+	def get_tx_usr_data(self):
+		o = self.txobj
+		if o['to'] and o['data']:
+			return bytes.fromhex(o['data'])
+
 	@property
 	def send_amt(self):
 		return self.outputs[0].amt if self.outputs else self.proto.coin_amt('0')

+ 5 - 2
mmgen/proto/eth/tx/info.py

@@ -49,7 +49,10 @@ class TxInfo(TxInfo):
 			t      = to_addr.hl(0) if to_addr else blue('None'),
 			a      = t['amt'].hl(),
 			n      = t['nonce'].hl(),
-			d      = '{}... ({} bytes)'.format(td[:40], len(td)//2) if len(td) else blue('None'),
+			d      = (
+				blue('None') if not td
+				else pink(bytes.fromhex(td).decode()) if tx.is_swap
+				else '{}... ({} bytes)'.format(td[:40], len(td)//2)),
 			c      = tx.proto.dcoin if len(tx.outputs) else '',
 			g      = yellow(tx.pretty_fmt_fee(t['gasPrice'].to_unit('Gwei'))),
 			G      = yellow(tx.pretty_fmt_fee(t['startGas'].to_unit('Kwei'))),
@@ -65,7 +68,7 @@ class TxInfo(TxInfo):
 		)
 
 	def format_verbose_footer(self):
-		if self.tx.txobj['data']:
+		if self.tx.txobj['data'] and not self.tx.is_swap:
 			from ..contract import parse_abi
 			return '\nParsed contract data: ' + pp_fmt(parse_abi(self.tx.txobj['data']))
 		else:

+ 22 - 3
mmgen/proto/eth/tx/new.py

@@ -28,6 +28,8 @@ class New(Base, TxBase.New):
 	no_chg_msg = 'Warning: Transaction leaves account with zero balance'
 	usr_fee_prompt = 'Enter transaction fee or gas price: '
 	msg_insufficient_funds = 'Account balance insufficient to fund this transaction ({} {} needed)'
+	byte_cost = 68 # https://ethereum.stackexchange.com/questions/39401/
+	               # how-do-you-calculate-gas-limit-for-transaction-with-data-in-ethereum
 
 	def __init__(self, *args, **kwargs):
 
@@ -66,7 +68,7 @@ class New(Base, TxBase.New):
 	async def create_serialized(self, *, locktime=None):
 		assert len(self.inputs) == 1, 'Transaction has more than one input!'
 		o_num = len(self.outputs)
-		o_ok = 0 if self.usr_contract_data else 1
+		o_ok = 0 if self.usr_contract_data and not self.is_swap else 1
 		assert o_num == o_ok, f'Transaction has {o_num} output{suf(o_num)} (should have {o_ok})'
 		await self.make_txobj()
 		odict = {k:v if v is None else str(v) for k, v in self.txobj.items() if k != 'token_to'}
@@ -78,10 +80,26 @@ class New(Base, TxBase.New):
 			'update_txid() must be called only when self.serialized is not hex data')
 		self.txid = MMGenTxID(make_chksum_6(self.serialized).upper())
 
+	def set_gas_with_data(self, data):
+		self.gas = self.proto.coin_amt(self.dfl_gas + self.byte_cost * len(data), from_unit='wei')
+
+	# one-shot method
+	def adj_gas_with_extra_data_len(self, extra_data_len):
+		if not hasattr(self, '_gas_adjusted'):
+			self.gas += self.proto.coin_amt(self.byte_cost * extra_data_len, from_unit='wei')
+			self._gas_adjusted = True
+
 	async def process_cmdline_args(self, cmd_args, ad_f, ad_w):
 
 		lc = len(cmd_args)
 
+		if lc == 2 and self.is_swap:
+			data_arg = cmd_args.pop()
+			lc = 1
+			assert data_arg.startswith('data:'), f'{data_arg}: invalid data arg (must start with "data:")'
+			self.usr_contract_data = data_arg.removeprefix('data:').encode()
+			self.set_gas_with_data(self.usr_contract_data)
+
 		if lc == 0 and self.usr_contract_data and 'Token' not in self.name:
 			return
 
@@ -91,9 +109,10 @@ class New(Base, TxBase.New):
 		a = self.parse_cmdline_arg(self.proto, cmd_args[0], ad_f, ad_w)
 
 		self.add_output(
-			coinaddr = a.addr,
+			coinaddr = None if a.is_vault else a.addr,
 			amt      = self.proto.coin_amt(a.amt or '0'),
-			is_chg   = not a.amt)
+			is_chg   = not a.amt,
+			is_vault = a.is_vault)
 
 		self.add_mmaddrs_to_outputs(ad_f, ad_w)
 

+ 38 - 0
mmgen/proto/eth/tx/new_swap.py

@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 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.eth.tx.new_swap: Ethereum new swap transaction class
+"""
+
+from ....tx.new_swap import NewSwap as TxNewSwap
+from ....tx.new_swap import get_swap_proto_mod
+from .new import New
+
+class NewSwap(New, TxNewSwap):
+	desc = 'Ethereum swap transaction'
+
+	def update_data_output(self, trade_limit):
+		sp = get_swap_proto_mod(self.swap_proto)
+		parsed_memo = sp.data.parse(self.usr_contract_data.decode())
+		memo = sp.data(
+			self.recv_proto,
+			self.recv_proto.coin_addr(parsed_memo.address),
+			trade_limit = trade_limit)
+		self.usr_contract_data = str(memo).encode()
+		self.set_gas_with_data(self.usr_contract_data)
+
+	@property
+	def vault_idx(self):
+		return 0
+
+	@property
+	def vault_output(self):
+		return self.outputs[0]

+ 1 - 1
mmgen/proto/eth/tx/status.py

@@ -60,7 +60,7 @@ class Status(TxBase.Status):
 				from ....color import cyan
 				msg('{}\n{}'.format(cyan('TRANSACTION RECEIPT'), pp_fmt(ret.rx)))
 			if ret:
-				if tx.txobj['data']:
+				if tx.txobj['data'] and not tx.is_swap:
 					cd = capfirst(tx.contract_desc)
 					if ret.exec_status == 0:
 						msg(f'{cd} failed to execute!')

+ 2 - 1
mmgen/proto/eth/tx/unsigned.py

@@ -60,7 +60,8 @@ class Unsigned(Completed, TxBase.Unsigned):
 
 		if o['data']: # contract-creating transaction
 			if o['to']:
-				raise ValueError('contract-creating transaction cannot have to-address')
+				if not self.is_swap:
+					raise ValueError('contract-creating transaction cannot have to-address')
 			else:
 				self.txobj['token_addr'] = TokenAddr(self.proto, etx.creates.hex())
 

+ 1 - 0
mmgen/protocol.py

@@ -55,6 +55,7 @@ class CoinProtocol(MMGenObject):
 		base_coin  = None
 		is_fork_of = None
 		chain_names = None
+		is_evm = False
 		networks   = ('mainnet', 'testnet', 'regtest')
 		decimal_prec = 28
 		_set_ok = ('tokensym',)

+ 2 - 0
mmgen/swap/proto/thorchain/__init__.py

@@ -23,11 +23,13 @@ class params:
 			'BTC': 'Bitcoin',
 			'LTC': 'Litecoin',
 			'BCH': 'Bitcoin Cash',
+			'ETH': 'Ethereum',
 		},
 		'receive': {
 			'BTC': 'Bitcoin',
 			'LTC': 'Litecoin',
 			'BCH': 'Bitcoin Cash',
+			'ETH': 'Ethereum',
 		}
 	}
 

+ 13 - 1
mmgen/swap/proto/thorchain/memo.py

@@ -12,7 +12,7 @@
 swap.proto.thorchain.memo: THORChain swap protocol memo class
 """
 
-from ....util import die
+from ....util import die, is_hex_str
 from ....amt import UniAmt
 
 from . import name as proto_name
@@ -42,6 +42,8 @@ class Memo:
 		'THOR.RUNE': 'r',
 	}
 
+	evm_chains = ('ETH', 'AVAX', 'BSC', 'BASE')
+
 	function_abbrevs = {
 		'SWAP': '=',
 	}
@@ -95,6 +97,11 @@ class Memo:
 
 		address = get_item('address')
 
+		if chain in cls.evm_chains:
+			assert address.startswith('0x'), f'{address}: address does not start with ‘0x’'
+			assert len(address) == 42, f'{address}: address has incorrect length ({len(address)} != 42)'
+			address = address.removeprefix('0x')
+
 		desc = 'trade_limit/stream_interval/stream_quantity'
 		lsq = get_item(desc)
 
@@ -135,6 +142,11 @@ class Memo:
 		self.addr = addr.views[addr.view_pref]
 		assert not ':' in self.addr # colon is record separator, so address mustn’t contain one
 
+		if self.chain in self.evm_chains:
+			assert len(self.addr) == 40, f'{self.addr}: address has incorrect length ({len(self.addr)} != 40)'
+			assert is_hex_str(self.addr), f'{self.addr}: address is not a hexadecimal string'
+			self.addr = '0x' + self.addr
+
 	def __str__(self):
 		from . import ExpInt4
 		try:

+ 5 - 2
mmgen/swap/proto/thorchain/thornode.py

@@ -98,6 +98,8 @@ class Thornode:
   {lblcolor('Trade limit:')}                   {tl_rounded.hl()} {out_coin} """ + mcolor(
 				f'({abs(1 - ratio) * 100:0.2f}% {direction} expected amount)')
 			tx_size_adj = len(e.enc) - 1
+			if tx.proto.is_evm:
+				tx.adj_gas_with_extra_data_len(len(e.enc) - 1) # one-shot method, no-op if repeated
 		else:
 			trade_limit_disp = ''
 			tx_size_adj = 0
@@ -105,7 +107,7 @@ class Thornode:
 		def get_estimated_fee():
 			return tx.feespec2abs(
 				fee_arg = d['recommended_gas_rate'] + gas_unit_data[gas_unit].code,
-				tx_size = tx.estimate_size() + tx_size_adj)
+				tx_size = None if tx.proto.is_evm else tx.estimate_size() + tx_size_adj)
 
 		_amount_in_label = 'Amount in:'
 		if deduct_est_fee:
@@ -145,7 +147,8 @@ class Thornode:
 
 	@property
 	def inbound_address(self):
-		return self.data['inbound_address']
+		addr = self.data['inbound_address']
+		return addr.removeprefix('0x') if self.tx.proto.is_evm else addr
 
 	@property
 	def rel_fee_hint(self):

+ 3 - 1
mmgen/tx/info.py

@@ -74,7 +74,9 @@ class TxInfo:
 
 			if tx.is_swap:
 				from ..swap.proto.thorchain.memo import Memo, proto_name
-				data = tx.data_output.data
+				data = (
+					(tx.usr_contract_data or bytes.fromhex(tx.txobj['data'])) if tx.proto.is_evm
+					else tx.data_output.data)
 				if Memo.is_partial_memo(data):
 					p = Memo.parse(data.decode('ascii'))
 					yield '  {} {}\n'.format(magenta('DEX Protocol:'), blue(proto_name))

+ 2 - 0
mmgen/tx/new.py

@@ -306,6 +306,8 @@ class New(Base):
 			die(1, 'Exiting at user request')
 
 	async def warn_addr_used(self, proto, chg, desc):
+		if proto.address_reuse_ok:
+			return
 		from ..tw.addresses import TwAddresses
 		if (await TwAddresses(self.cfg, proto, get_data=True)).is_used(chg.addr):
 			from ..ui import keypress_confirm

+ 3 - 2
mmgen/tx/new_swap.py

@@ -97,7 +97,7 @@ class NewSwap(New):
 				arg = get_arg()
 
 			# arg 3: chg_spec (change address spec)
-			if args.send_amt:
+			if args.send_amt and not self.proto.is_evm:
 				if not arg in sp.params.coins['receive']: # is change arg
 					args.chg_spec = arg
 					arg = get_arg()
@@ -119,7 +119,7 @@ class NewSwap(New):
 
 		chg_output = (
 			await self.get_swap_output(self.proto, args.chg_spec, addrfiles, 'change address')
-			if args.send_amt else None)
+			if args.send_amt and not self.proto.is_evm else None)
 
 		if chg_output:
 			self.check_addr_is_wallet_addr(
@@ -145,6 +145,7 @@ class NewSwap(New):
 		self.swap_recv_addr_mmid = recv_output.mmid
 
 		return (
+			[f'vault,{args.send_amt}', f'data:{memo}'] if args.send_amt and self.proto.is_evm else
 			[f'vault,{args.send_amt}', chg_output.mmid, f'data:{memo}'] if args.send_amt else
 			['vault', f'data:{memo}'])
 

+ 4 - 0
test/cmdtest_d/cfg.py

@@ -39,6 +39,7 @@ cmd_groups_dfl = {
 	'autosign_eth':       ('CmdTestAutosignETH',       {'modname': 'automount_eth'}),
 	'regtest':            ('CmdTestRegtest',           {}),
 	'swap':               ('CmdTestSwap',              {}),
+	'ethswap':            ('CmdTestEthSwap',           {}),
 	# 'chainsplit':         ('CmdTestChainsplit',      {}),
 	'ethdev':             ('CmdTestEthdev',            {}),
 	'xmrwallet':          ('CmdTestXMRWallet',         {}),
@@ -46,6 +47,7 @@ cmd_groups_dfl = {
 }
 
 cmd_groups_extra = {
+	'ethswap_eth':            ('CmdTestEthSwapEth',           {'modname': 'ethswap'}),
 	'dev':                    ('CmdTestDev',                  {'modname': 'misc'}),
 	'regtest_legacy':         ('CmdTestRegtestBDBWallet',     {'modname': 'regtest'}),
 	'autosign_btc':           ('CmdTestAutosignBTC',          {'modname': 'autosign'}),
@@ -241,6 +243,8 @@ cfgs = { # addr_idx_lists (except 31, 32, 33, 34) must contain exactly 8 address
 	'39': {}, # xmr_autosign
 	'40': {}, # cfgfile
 	'41': {}, # opts
+	'47': {}, # ethswap
+	'48': {}, # ethswap_eth
 	'49': {}, # autosign_automount
 	'59': {}, # autosign_eth
 	'99': {}, # dummy

+ 1 - 1
test/cmdtest_d/ct_ethdev.py

@@ -123,7 +123,7 @@ coin = cfg.coin
 class CmdTestEthdev(CmdTestBase, CmdTestShared):
 	'Ethereum transacting, token deployment and tracking wallet operations'
 	networks = ('eth', 'etc')
-	passthru_opts = ('coin', 'daemon_id', 'http_timeout', 'rpc_backend')
+	passthru_opts = ('coin', 'daemon_id', 'eth_daemon_id', 'http_timeout', 'rpc_backend')
 	tmpdir_nums = [22]
 	color = True
 	menu_prompt = 'efresh balance:\b'

+ 219 - 0
test/cmdtest_d/ct_ethswap.py

@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 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_ethswap: Ethereum swap tests for the cmdtest.py test suite
+"""
+
+from mmgen.wallet.mmgen import wallet as MMGenWallet
+from mmgen.cfg import Config
+from mmgen.protocol import init_proto
+
+from .runner import CmdTestRunner
+
+from .common import dfl_words_file, dfl_seed_id, rt_pw
+
+from .httpd.thornode import ThornodeServer
+from .ct_regtest import CmdTestRegtest
+from .ct_swap import CmdTestSwapMethods
+from .ct_ethdev import CmdTestEthdev
+
+thornode_server = ThornodeServer()
+
+method_template = """
+def {name}(self):
+	self.spawn(log_only=True)
+	return ethswap_eth.run_test("{eth_name}", sub=True)
+"""
+
+class CmdTestEthSwap(CmdTestRegtest, CmdTestSwapMethods):
+	'Ethereum swap operations'
+
+	bdb_wallet = True
+	tmpdir_nums = [47]
+	networks = ('btc',)
+	passthru_opts = ('coin', 'rpc_backend', 'eth_daemon_id')
+
+	cmd_group_in = (
+		('setup',                'regtest (Bob and Alice) mode setup'),
+		('eth_setup',            'Ethereum devnet setup'),
+		('subgroup.init',        []),
+		('subgroup.fund',        ['init']),
+		('subgroup.eth_init',    []),
+		('subgroup.eth_fund',    ['eth_init']),
+		('subgroup.swap',        ['fund', 'eth_fund']),
+		('subgroup.eth_swap',    ['fund', 'eth_fund']),
+		('stop',                 'stopping regtest daemon'),
+		('eth_stop',             'stopping Ethereum daemon'),
+		('thornode_server_stop', 'stopping the Thornode server'),
+	)
+	cmd_subgroups = {
+	'init': (
+		'creating Bob’s MMGen wallet and tracking wallet',
+		('walletconv_bob', 'wallet creation (Bob)'),
+		('addrgen_bob',    'address generation (Bob)'),
+		('addrimport_bob', 'importing Bob’s addresses'),
+	),
+	'fund': (
+		'funding Bob’s wallet',
+		('bob_import_miner_addr', 'importing miner’s coinbase addr into Bob’s wallet'),
+		('fund_bob',              'funding Bob’s wallet'),
+		('generate',              'mining a block'),
+		('bob_bal1',              'Bob’s balance'),
+	),
+	'eth_init': (
+		'initializing the ETH tracking wallet',
+		('eth_addrgen',             ''),
+		('eth_addrimport',          ''),
+		('eth_addrimport_dev_addr', ''),
+		('eth_fund_dev_address',    ''),
+	),
+	'eth_fund': (
+		'funding the ETH tracking wallet',
+		('eth_txcreate1', ''),
+		('eth_txsign1',   ''),
+		('eth_txsend1',   ''),
+		('eth_bal1',      ''),
+	),
+	'swap': (
+		'swap operations (BTC -> ETH)',
+		('swaptxcreate1', 'creating a BTC->ETH swap transaction'),
+		('swaptxcreate2', 'creating a BTC->ETH swap transaction (used account)'),
+		('swaptxsign1',   'signing the swap transaction'),
+		('swaptxsend1',   'sending the swap transaction'),
+		('generate',      'generating a block'),
+		('bob_bal2',      'Bob’s balance'),
+	),
+	'eth_swap': (
+		'swap operations (ETH -> BTC)',
+		('eth_swaptxcreate1', ''),
+		('eth_swaptxcreate2', ''),
+		('eth_swaptxsign1',   ''),
+		('eth_swaptxsend1',   ''),
+		('eth_swaptxstatus1', ''),
+		('eth_bal2',          ''),
+	),
+	}
+
+	eth_tests = [c[0] for v in tuple(cmd_subgroups.values()) + (cmd_group_in,)
+		for c in v if isinstance(c, tuple) and c[0].startswith('eth_')]
+
+	exec(''.join(method_template.format(name=k, eth_name=k.removeprefix('eth_')) for k in eth_tests))
+
+	def __init__(self, cfg, trunner, cfgs, spawn):
+
+		super().__init__(cfg, trunner, cfgs, spawn)
+
+		if not trunner:
+			return
+
+		global ethswap_eth
+		cfg = Config({
+			'_clone': trunner.cfg,
+			'proto': init_proto(cfg, network_id='eth'),
+			'resume': None,
+			'resume_after': None,
+			'exit_after': None,
+			'eth_daemon_id': trunner.cfg.eth_daemon_id,
+			'log': None,
+			'coin': 'eth'})
+		t = trunner
+		ethswap_eth = CmdTestRunner(cfg, t.repo_root, t.data_dir, t.trash_dir, t.trash_dir2)
+		ethswap_eth.init_group('ethswap_eth')
+
+		thornode_server.start()
+
+	def walletconv_bob(self):
+		t = self.spawn(
+			'mmgen-walletconv',
+			['--bob', '--quiet', '-r0', f'-d{self.cfg.data_dir}/regtest/bob', dfl_words_file],
+			no_passthru_opts = ['coin', 'eth_daemon_id'])
+		t.hash_preset(MMGenWallet.desc, '1')
+		t.passphrase_new('new '+MMGenWallet.desc, rt_pw)
+		t.label()
+		return t
+
+	def swaptxcreate1(self):
+		self.get_file_with_ext('rawtx', delete_all=True)
+		t = self._swaptxcreate(['BTC', '8.765', 'ETH'])
+		t.expect('OK? (Y/n): ', 'y')
+		t.expect(':E:2')
+		t.expect('OK? (Y/n): ', 'y')
+		return self._swaptxcreate_ui_common(t)
+
+	def swaptxcreate2(self):
+		self.get_file_with_ext('rawtx', delete_all=True)
+		t = self._swaptxcreate(['BTC', '8.765', 'ETH', f'{dfl_seed_id}:E:1'])
+		t.expect('OK? (Y/n): ', 'y')
+		return self._swaptxcreate_ui_common(t)
+
+	def swaptxsign1(self):
+		return self._swaptxsign()
+
+	def swaptxsend1(self):
+		return self._swaptxsend()
+
+	def bob_bal2(self):
+		return self._user_bal_cli('bob', chk='491.23498314')
+
+	def thornode_server_stop(self):
+		self.spawn(msg_only=True)
+		thornode_server.stop()
+		return 'ok'
+
+class CmdTestEthSwapEth(CmdTestEthdev, CmdTestSwapMethods):
+	'Ethereum swap operations - Ethereum wallet'
+
+	networks = ('eth',)
+	tmpdir_nums = [48]
+
+	bals = lambda self, k: {
+		'swap1': [('98831F3A:E:1', '123.456')],
+		'swap2': [('98831F3A:E:1', '114.690978056')],
+	}[k]
+
+	cmd_group_in = CmdTestEthdev.cmd_group_in + (
+		('swaptxcreate1', 'creating an ETH->BTC swap transaction'),
+		('swaptxcreate2', 'creating an ETH->BTC swap transaction (specific address, trade limit)'),
+		('swaptxsign1',   'signing the transaction'),
+		('swaptxsend1',   'sending the transaction'),
+		('swaptxstatus1', 'getting the transaction status (with --verbose)'),
+		('bal1',          'the ETH balance'),
+		('bal2',          'the ETH balance'),
+	)
+
+	def swaptxcreate1(self):
+		self.get_file_with_ext('rawtx', delete_all=True)
+		t = self._swaptxcreate(['ETH', '8.765', 'BTC'])
+		t.expect('OK? (Y/n): ', 'y')
+		return self._swaptxcreate_ui_common(t)
+
+	def swaptxcreate2(self):
+		self.get_file_with_ext('rawtx', delete_all=True)
+		return self._swaptxcreate_ui_common(
+			self._swaptxcreate(
+				['ETH', '8.765', 'BTC', f'{dfl_seed_id}:B:3'],
+				add_opts = ['--trade-limit=3%']),
+			expect = ':2019e4/1/0')
+
+	def swaptxsign1(self):
+		return self._swaptxsign()
+
+	def swaptxsend1(self):
+		return self._swaptxsend()
+
+	def swaptxstatus1(self):
+		return self._swaptxsend(add_opts=['--verbose', '--status'], status=True)
+
+	def bal1(self):
+		return self.bal('swap1')
+
+	def bal2(self):
+		return self.bal('swap2')

+ 3 - 1
test/cmdtest_d/ct_swap.py

@@ -160,7 +160,7 @@ class CmdTestSwapMethods:
 			],
 			spawn_only = spawn_only)
 
-	def _swaptxsend(self, *, add_opts=[], spawn_only=False):
+	def _swaptxsend(self, *, add_opts=[], spawn_only=False, status=False):
 		fn = self.get_file_with_ext('sigtx')
 		t = self.spawn(
 			'mmgen-txsend',
@@ -169,6 +169,8 @@ class CmdTestSwapMethods:
 		if spawn_only:
 			return t
 		t.expect('view: ', 'v')
+		if status:
+			return t
 		t.expect('(y/N): ', 'n')
 		t.expect('to confirm: ', 'YES\n')
 		return t

+ 5 - 4
test/cmdtest_d/httpd/thornode.py

@@ -24,9 +24,9 @@ cfg = Config()
 # https://thornode.ninerealms.com/thorchain/quote/swap?from_asset=BCH.BCH&to_asset=LTC.LTC&amount=1000000
 sample_request = 'GET /thorchain/quote/swap?from_asset=BCH.BCH&to_asset=LTC.LTC&amount=1000000000'
 request_pat = r'/thorchain/quote/swap\?from_asset=(\S+)\.(\S+)&to_asset=(\S+)\.(\S+)&amount=(\d+)'
-prices = {'BTC': 97000, 'LTC': 115, 'BCH': 330}
-gas_rate_units = {'BTC': 'satsperbyte'}
-recommended_gas_rate = {'BTC': '6'}
+prices = {'BTC': 97000, 'LTC': 115, 'BCH': 330, 'ETH': 2304}
+gas_rate_units = {'ETH': 'gwei', 'BTC': 'satsperbyte'}
+recommended_gas_rate = {'ETH': '1', 'BTC': '6'}
 
 data_template = {
 	'inbound_address': None,
@@ -59,11 +59,12 @@ data_template = {
 def make_inbound_addr(proto, mmtype):
 	from mmgen.tool.coin import tool_cmd
 	n = int(time.time()) // (60 * 60 * 24) # increments once every 24 hrs
-	return tool_cmd(
+	ret = tool_cmd(
 		cfg     = cfg,
 		cmdname = 'pubhash2addr',
 		proto   = proto,
 		mmtype  = mmtype).pubhash2addr(f'{n:040x}')
+	return '0x' + ret if proto.is_evm else ret
 
 class ThornodeServer(HTTPD):
 	name = 'thornode server'

+ 8 - 1
test/cmdtest_d/runner.py

@@ -124,6 +124,7 @@ class CmdTestRunner:
 			extra_desc      = '',
 			no_output       = False,
 			msg_only        = False,
+			log_only        = False,
 			no_msg          = False,
 			cmd_dir         = 'cmds',
 			no_exec_wrapper = False,
@@ -167,6 +168,9 @@ class CmdTestRunner:
 				self.tg.test_name,
 				cmd_disp))
 
+		if log_only:
+			return
+
 		for i in args: # die only after writing log entry
 			if not isinstance(i, str):
 				die(2, 'Error: missing input files in cmd line?:\nName: {}\nCmdline: {!r}'.format(
@@ -426,7 +430,7 @@ class CmdTestRunner:
 
 		return rerun
 
-	def run_test(self, cmd):
+	def run_test(self, cmd, sub=False):
 
 		if self.deps_only and cmd == self.deps_only:
 			sys.exit(0)
@@ -463,6 +467,9 @@ class CmdTestRunner:
 					setattr(self.tg, k, test_cfg[k])
 
 		ret = getattr(self.tg, cmd)(*arg_list) # run the test
+		if sub:
+			return ret
+
 		if type(ret).__name__ == 'coroutine':
 			ret = asyncio.run(ret)
 

+ 4 - 0
test/modtest_d/tx.py

@@ -9,6 +9,7 @@ import os
 from mmgen.tx import CompletedTX, UnsignedTX
 from mmgen.tx.file import MMGenTxFile
 from mmgen.cfg import Config
+from mmgen.color import cyan
 
 from ..include.common import cfg, qmsg, vmsg, gr_uc, make_burn_addr
 
@@ -175,10 +176,13 @@ class unit_tests:
 		for coin, addrtype in (
 			('ltc', 'bech32'),
 			('bch', 'compressed'),
+			('eth', None),
 		):
 			proto = init_proto(cfg, coin, need_amt=True)
 			addr = make_burn_addr(proto, addrtype)
 
+			vmsg(f'\nTesting coin {cyan(coin.upper())}:')
+
 			for limit, limit_chk in (
 				('123.4567',   12340000000),
 				('1.234567',   123400000),