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
   By default, the change and destination addresses are chosen automatically by
   finding the lowest-indexed unused addresses of the preferred address types in
   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
   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
   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
   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
   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
   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
   While discouraged, sending change or swapping to non-wallet addresses is also
   supported, in which case the signing script (‘mmgen-txsign’ or ‘mmgen-
   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-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
   By default, the change and destination addresses are chosen automatically by
   finding the lowest-indexed unused addresses of the preferred address types in
   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
   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
   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
   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
   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
   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
   While discouraged, sending change or swapping to non-wallet addresses is also
   supported, in which case the signing script (‘mmgen-txsign’ or ‘mmgen-
   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-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
 By default, the change and destination addresses are chosen automatically by
 finding the lowest-indexed unused addresses of the preferred address types in
 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
 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
 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
 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
 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
 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
 While discouraged, sending change or swapping to non-wallet addresses is also
 supported, in which case the signing script (‘mmgen-txsign’ or ‘mmgen-
 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
 	start_subsidy   = 50
 	max_int         = 0xffffffff
 	max_int         = 0xffffffff
 	max_op_return_data_len = 80
 	max_op_return_data_len = 80
+	address_reuse_ok = False
 
 
 	coin_cfg_opts = (
 	coin_cfg_opts = (
 		'daemon_id',
 		'daemon_id',

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

@@ -37,6 +37,8 @@ class mainnet(CoinProtocol.DummyWIF, CoinProtocol.Secp256k1):
 	base_coin     = 'ETH'
 	base_coin     = 'ETH'
 	avg_bdi       = 15
 	avg_bdi       = 15
 	decimal_prec  = 36
 	decimal_prec  = 36
+	address_reuse_ok = True
+	is_evm = True
 
 
 	# https://www.chainid.dev
 	# https://www.chainid.dev
 	chain_ids = {
 	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):
 	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
 		self.minconf = None
 		addrs = {}
 		addrs = {}
 
 
@@ -75,7 +74,8 @@ class EthereumTwAddresses(TwAddresses, EthereumTwView, EthereumTwRPC):
 			addrs[e.label.mmid] = {
 			addrs[e.label.mmid] = {
 				'addr':  e.coinaddr,
 				'addr':  e.coinaddr,
 				'amt':   bal,
 				'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,
 				'confs': 0,
 				'lbl':   e.label}
 				'lbl':   e.label}
 			self.total += bal
 			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):
 class Completed(Base, TxBase.Completed):
 	fn_fee_unit = 'Mwei'
 	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
 	@property
 	def send_amt(self):
 	def send_amt(self):
 		return self.outputs[0].amt if self.outputs else self.proto.coin_amt('0')
 		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'),
 			t      = to_addr.hl(0) if to_addr else blue('None'),
 			a      = t['amt'].hl(),
 			a      = t['amt'].hl(),
 			n      = t['nonce'].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 '',
 			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['gasPrice'].to_unit('Gwei'))),
 			G      = yellow(tx.pretty_fmt_fee(t['startGas'].to_unit('Kwei'))),
 			G      = yellow(tx.pretty_fmt_fee(t['startGas'].to_unit('Kwei'))),
@@ -65,7 +68,7 @@ class TxInfo(TxInfo):
 		)
 		)
 
 
 	def format_verbose_footer(self):
 	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
 			from ..contract import parse_abi
 			return '\nParsed contract data: ' + pp_fmt(parse_abi(self.tx.txobj['data']))
 			return '\nParsed contract data: ' + pp_fmt(parse_abi(self.tx.txobj['data']))
 		else:
 		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'
 	no_chg_msg = 'Warning: Transaction leaves account with zero balance'
 	usr_fee_prompt = 'Enter transaction fee or gas price: '
 	usr_fee_prompt = 'Enter transaction fee or gas price: '
 	msg_insufficient_funds = 'Account balance insufficient to fund this transaction ({} {} needed)'
 	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):
 	def __init__(self, *args, **kwargs):
 
 
@@ -66,7 +68,7 @@ class New(Base, TxBase.New):
 	async def create_serialized(self, *, locktime=None):
 	async def create_serialized(self, *, locktime=None):
 		assert len(self.inputs) == 1, 'Transaction has more than one input!'
 		assert len(self.inputs) == 1, 'Transaction has more than one input!'
 		o_num = len(self.outputs)
 		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})'
 		assert o_num == o_ok, f'Transaction has {o_num} output{suf(o_num)} (should have {o_ok})'
 		await self.make_txobj()
 		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'}
 		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')
 			'update_txid() must be called only when self.serialized is not hex data')
 		self.txid = MMGenTxID(make_chksum_6(self.serialized).upper())
 		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):
 	async def process_cmdline_args(self, cmd_args, ad_f, ad_w):
 
 
 		lc = len(cmd_args)
 		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:
 		if lc == 0 and self.usr_contract_data and 'Token' not in self.name:
 			return
 			return
 
 
@@ -91,9 +109,10 @@ class New(Base, TxBase.New):
 		a = self.parse_cmdline_arg(self.proto, cmd_args[0], ad_f, ad_w)
 		a = self.parse_cmdline_arg(self.proto, cmd_args[0], ad_f, ad_w)
 
 
 		self.add_output(
 		self.add_output(
-			coinaddr = a.addr,
+			coinaddr = None if a.is_vault else a.addr,
 			amt      = self.proto.coin_amt(a.amt or '0'),
 			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)
 		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
 				from ....color import cyan
 				msg('{}\n{}'.format(cyan('TRANSACTION RECEIPT'), pp_fmt(ret.rx)))
 				msg('{}\n{}'.format(cyan('TRANSACTION RECEIPT'), pp_fmt(ret.rx)))
 			if ret:
 			if ret:
-				if tx.txobj['data']:
+				if tx.txobj['data'] and not tx.is_swap:
 					cd = capfirst(tx.contract_desc)
 					cd = capfirst(tx.contract_desc)
 					if ret.exec_status == 0:
 					if ret.exec_status == 0:
 						msg(f'{cd} failed to execute!')
 						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['data']: # contract-creating transaction
 			if o['to']:
 			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:
 			else:
 				self.txobj['token_addr'] = TokenAddr(self.proto, etx.creates.hex())
 				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
 		base_coin  = None
 		is_fork_of = None
 		is_fork_of = None
 		chain_names = None
 		chain_names = None
+		is_evm = False
 		networks   = ('mainnet', 'testnet', 'regtest')
 		networks   = ('mainnet', 'testnet', 'regtest')
 		decimal_prec = 28
 		decimal_prec = 28
 		_set_ok = ('tokensym',)
 		_set_ok = ('tokensym',)

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

@@ -23,11 +23,13 @@ class params:
 			'BTC': 'Bitcoin',
 			'BTC': 'Bitcoin',
 			'LTC': 'Litecoin',
 			'LTC': 'Litecoin',
 			'BCH': 'Bitcoin Cash',
 			'BCH': 'Bitcoin Cash',
+			'ETH': 'Ethereum',
 		},
 		},
 		'receive': {
 		'receive': {
 			'BTC': 'Bitcoin',
 			'BTC': 'Bitcoin',
 			'LTC': 'Litecoin',
 			'LTC': 'Litecoin',
 			'BCH': 'Bitcoin Cash',
 			'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
 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 ....amt import UniAmt
 
 
 from . import name as proto_name
 from . import name as proto_name
@@ -42,6 +42,8 @@ class Memo:
 		'THOR.RUNE': 'r',
 		'THOR.RUNE': 'r',
 	}
 	}
 
 
+	evm_chains = ('ETH', 'AVAX', 'BSC', 'BASE')
+
 	function_abbrevs = {
 	function_abbrevs = {
 		'SWAP': '=',
 		'SWAP': '=',
 	}
 	}
@@ -95,6 +97,11 @@ class Memo:
 
 
 		address = get_item('address')
 		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'
 		desc = 'trade_limit/stream_interval/stream_quantity'
 		lsq = get_item(desc)
 		lsq = get_item(desc)
 
 
@@ -135,6 +142,11 @@ class Memo:
 		self.addr = addr.views[addr.view_pref]
 		self.addr = addr.views[addr.view_pref]
 		assert not ':' in self.addr # colon is record separator, so address mustn’t contain one
 		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):
 	def __str__(self):
 		from . import ExpInt4
 		from . import ExpInt4
 		try:
 		try:

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

@@ -98,6 +98,8 @@ class Thornode:
   {lblcolor('Trade limit:')}                   {tl_rounded.hl()} {out_coin} """ + mcolor(
   {lblcolor('Trade limit:')}                   {tl_rounded.hl()} {out_coin} """ + mcolor(
 				f'({abs(1 - ratio) * 100:0.2f}% {direction} expected amount)')
 				f'({abs(1 - ratio) * 100:0.2f}% {direction} expected amount)')
 			tx_size_adj = len(e.enc) - 1
 			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:
 		else:
 			trade_limit_disp = ''
 			trade_limit_disp = ''
 			tx_size_adj = 0
 			tx_size_adj = 0
@@ -105,7 +107,7 @@ class Thornode:
 		def get_estimated_fee():
 		def get_estimated_fee():
 			return tx.feespec2abs(
 			return tx.feespec2abs(
 				fee_arg = d['recommended_gas_rate'] + gas_unit_data[gas_unit].code,
 				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:'
 		_amount_in_label = 'Amount in:'
 		if deduct_est_fee:
 		if deduct_est_fee:
@@ -145,7 +147,8 @@ class Thornode:
 
 
 	@property
 	@property
 	def inbound_address(self):
 	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
 	@property
 	def rel_fee_hint(self):
 	def rel_fee_hint(self):

+ 3 - 1
mmgen/tx/info.py

@@ -74,7 +74,9 @@ class TxInfo:
 
 
 			if tx.is_swap:
 			if tx.is_swap:
 				from ..swap.proto.thorchain.memo import Memo, proto_name
 				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):
 				if Memo.is_partial_memo(data):
 					p = Memo.parse(data.decode('ascii'))
 					p = Memo.parse(data.decode('ascii'))
 					yield '  {} {}\n'.format(magenta('DEX Protocol:'), blue(proto_name))
 					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')
 			die(1, 'Exiting at user request')
 
 
 	async def warn_addr_used(self, proto, chg, desc):
 	async def warn_addr_used(self, proto, chg, desc):
+		if proto.address_reuse_ok:
+			return
 		from ..tw.addresses import TwAddresses
 		from ..tw.addresses import TwAddresses
 		if (await TwAddresses(self.cfg, proto, get_data=True)).is_used(chg.addr):
 		if (await TwAddresses(self.cfg, proto, get_data=True)).is_used(chg.addr):
 			from ..ui import keypress_confirm
 			from ..ui import keypress_confirm

+ 3 - 2
mmgen/tx/new_swap.py

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

+ 4 - 0
test/cmdtest_d/cfg.py

@@ -39,6 +39,7 @@ cmd_groups_dfl = {
 	'autosign_eth':       ('CmdTestAutosignETH',       {'modname': 'automount_eth'}),
 	'autosign_eth':       ('CmdTestAutosignETH',       {'modname': 'automount_eth'}),
 	'regtest':            ('CmdTestRegtest',           {}),
 	'regtest':            ('CmdTestRegtest',           {}),
 	'swap':               ('CmdTestSwap',              {}),
 	'swap':               ('CmdTestSwap',              {}),
+	'ethswap':            ('CmdTestEthSwap',           {}),
 	# 'chainsplit':         ('CmdTestChainsplit',      {}),
 	# 'chainsplit':         ('CmdTestChainsplit',      {}),
 	'ethdev':             ('CmdTestEthdev',            {}),
 	'ethdev':             ('CmdTestEthdev',            {}),
 	'xmrwallet':          ('CmdTestXMRWallet',         {}),
 	'xmrwallet':          ('CmdTestXMRWallet',         {}),
@@ -46,6 +47,7 @@ cmd_groups_dfl = {
 }
 }
 
 
 cmd_groups_extra = {
 cmd_groups_extra = {
+	'ethswap_eth':            ('CmdTestEthSwapEth',           {'modname': 'ethswap'}),
 	'dev':                    ('CmdTestDev',                  {'modname': 'misc'}),
 	'dev':                    ('CmdTestDev',                  {'modname': 'misc'}),
 	'regtest_legacy':         ('CmdTestRegtestBDBWallet',     {'modname': 'regtest'}),
 	'regtest_legacy':         ('CmdTestRegtestBDBWallet',     {'modname': 'regtest'}),
 	'autosign_btc':           ('CmdTestAutosignBTC',          {'modname': 'autosign'}),
 	'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
 	'39': {}, # xmr_autosign
 	'40': {}, # cfgfile
 	'40': {}, # cfgfile
 	'41': {}, # opts
 	'41': {}, # opts
+	'47': {}, # ethswap
+	'48': {}, # ethswap_eth
 	'49': {}, # autosign_automount
 	'49': {}, # autosign_automount
 	'59': {}, # autosign_eth
 	'59': {}, # autosign_eth
 	'99': {}, # dummy
 	'99': {}, # dummy

+ 1 - 1
test/cmdtest_d/ct_ethdev.py

@@ -123,7 +123,7 @@ coin = cfg.coin
 class CmdTestEthdev(CmdTestBase, CmdTestShared):
 class CmdTestEthdev(CmdTestBase, CmdTestShared):
 	'Ethereum transacting, token deployment and tracking wallet operations'
 	'Ethereum transacting, token deployment and tracking wallet operations'
 	networks = ('eth', 'etc')
 	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]
 	tmpdir_nums = [22]
 	color = True
 	color = True
 	menu_prompt = 'efresh balance:\b'
 	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)
 			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')
 		fn = self.get_file_with_ext('sigtx')
 		t = self.spawn(
 		t = self.spawn(
 			'mmgen-txsend',
 			'mmgen-txsend',
@@ -169,6 +169,8 @@ class CmdTestSwapMethods:
 		if spawn_only:
 		if spawn_only:
 			return t
 			return t
 		t.expect('view: ', 'v')
 		t.expect('view: ', 'v')
+		if status:
+			return t
 		t.expect('(y/N): ', 'n')
 		t.expect('(y/N): ', 'n')
 		t.expect('to confirm: ', 'YES\n')
 		t.expect('to confirm: ', 'YES\n')
 		return t
 		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
 # 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'
 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+)'
 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 = {
 data_template = {
 	'inbound_address': None,
 	'inbound_address': None,
@@ -59,11 +59,12 @@ data_template = {
 def make_inbound_addr(proto, mmtype):
 def make_inbound_addr(proto, mmtype):
 	from mmgen.tool.coin import tool_cmd
 	from mmgen.tool.coin import tool_cmd
 	n = int(time.time()) // (60 * 60 * 24) # increments once every 24 hrs
 	n = int(time.time()) // (60 * 60 * 24) # increments once every 24 hrs
-	return tool_cmd(
+	ret = tool_cmd(
 		cfg     = cfg,
 		cfg     = cfg,
 		cmdname = 'pubhash2addr',
 		cmdname = 'pubhash2addr',
 		proto   = proto,
 		proto   = proto,
 		mmtype  = mmtype).pubhash2addr(f'{n:040x}')
 		mmtype  = mmtype).pubhash2addr(f'{n:040x}')
+	return '0x' + ret if proto.is_evm else ret
 
 
 class ThornodeServer(HTTPD):
 class ThornodeServer(HTTPD):
 	name = 'thornode server'
 	name = 'thornode server'

+ 8 - 1
test/cmdtest_d/runner.py

@@ -124,6 +124,7 @@ class CmdTestRunner:
 			extra_desc      = '',
 			extra_desc      = '',
 			no_output       = False,
 			no_output       = False,
 			msg_only        = False,
 			msg_only        = False,
+			log_only        = False,
 			no_msg          = False,
 			no_msg          = False,
 			cmd_dir         = 'cmds',
 			cmd_dir         = 'cmds',
 			no_exec_wrapper = False,
 			no_exec_wrapper = False,
@@ -167,6 +168,9 @@ class CmdTestRunner:
 				self.tg.test_name,
 				self.tg.test_name,
 				cmd_disp))
 				cmd_disp))
 
 
+		if log_only:
+			return
+
 		for i in args: # die only after writing log entry
 		for i in args: # die only after writing log entry
 			if not isinstance(i, str):
 			if not isinstance(i, str):
 				die(2, 'Error: missing input files in cmd line?:\nName: {}\nCmdline: {!r}'.format(
 				die(2, 'Error: missing input files in cmd line?:\nName: {}\nCmdline: {!r}'.format(
@@ -426,7 +430,7 @@ class CmdTestRunner:
 
 
 		return rerun
 		return rerun
 
 
-	def run_test(self, cmd):
+	def run_test(self, cmd, sub=False):
 
 
 		if self.deps_only and cmd == self.deps_only:
 		if self.deps_only and cmd == self.deps_only:
 			sys.exit(0)
 			sys.exit(0)
@@ -463,6 +467,9 @@ class CmdTestRunner:
 					setattr(self.tg, k, test_cfg[k])
 					setattr(self.tg, k, test_cfg[k])
 
 
 		ret = getattr(self.tg, cmd)(*arg_list) # run the test
 		ret = getattr(self.tg, cmd)(*arg_list) # run the test
+		if sub:
+			return ret
+
 		if type(ret).__name__ == 'coroutine':
 		if type(ret).__name__ == 'coroutine':
 			ret = asyncio.run(ret)
 			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 import CompletedTX, UnsignedTX
 from mmgen.tx.file import MMGenTxFile
 from mmgen.tx.file import MMGenTxFile
 from mmgen.cfg import Config
 from mmgen.cfg import Config
+from mmgen.color import cyan
 
 
 from ..include.common import cfg, qmsg, vmsg, gr_uc, make_burn_addr
 from ..include.common import cfg, qmsg, vmsg, gr_uc, make_burn_addr
 
 
@@ -175,10 +176,13 @@ class unit_tests:
 		for coin, addrtype in (
 		for coin, addrtype in (
 			('ltc', 'bech32'),
 			('ltc', 'bech32'),
 			('bch', 'compressed'),
 			('bch', 'compressed'),
+			('eth', None),
 		):
 		):
 			proto = init_proto(cfg, coin, need_amt=True)
 			proto = init_proto(cfg, coin, need_amt=True)
 			addr = make_burn_addr(proto, addrtype)
 			addr = make_burn_addr(proto, addrtype)
 
 
+			vmsg(f'\nTesting coin {cyan(coin.upper())}:')
+
 			for limit, limit_chk in (
 			for limit, limit_chk in (
 				('123.4567',   12340000000),
 				('123.4567',   12340000000),
 				('1.234567',   123400000),
 				('1.234567',   123400000),