Browse Source

THORChain ERC20 token swap support

Examples:

    # List available assets:
    $ mmgen-swaptxcreate -S

    # Create a Tether-to-LTC swap transaction for autosigning, connecting to the
    # swap quote server via Tor:
    $ mmgen-swaptxcreate --autosign --proxy=localhost:9050 ETH.USDT 1000 LTC

    # After signing, send the transaction via public Etherscan proxy over Tor:
    $ mmgen-txsend --autosign --quiet --tx-proxy=etherscan --proxy=localhost:9050

    # After sending, check the transaction status and receipt:
    $ mmgen-txsend --autosign --verbose --status

    # Create a Tether-to-DAI swap transaction, with explicit destination account:
    $ mmgen-swaptxcreate ETH.USDT 1000 ETH.DAI E:01234ABC:3

Testing:

    $ test/cmdtest.py -e ethswap
The MMGen Project 7 months ago
parent
commit
ff28d39a3c

+ 1 - 0
MANIFEST.in

@@ -16,6 +16,7 @@ include test/*/*.py
 include test/*/*/*.py
 include test/ref/*
 include test/ref/*/*
+include test/ref/*/*/*
 include test/ref/*/*/*/*
 include test/overlay/fakemods/mmgen/*.py
 include test/overlay/fakemods/mmgen/*/*.py

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-15.1.dev30
+15.1.dev31

+ 17 - 0
mmgen/help/swaptxcreate_examples.py

@@ -73,4 +73,21 @@ EXAMPLES:
   Check whether the funds have arrived in the BCH destination wallet:
 
     $ mmgen-tool --coin=bch --bch-rpc-host=gemini twview minconf=0
+
+  Create a Tether-to-LTC swap transaction for autosigning, connecting to the
+  swap quote server via Tor:
+
+    $ {gc.prog_name} --autosign --proxy=localhost:9050 ETH.USDT 1000 LTC
+
+  After signing, send the transaction via public Etherscan proxy over Tor:
+
+    $ mmgen-txsend --autosign --quiet --tx-proxy=etherscan --proxy=localhost:9050
+
+  After sending, check the transaction status:
+
+    $ mmgen-txsend --autosign --verbose --status
+
+  Create a Tether-to-DAI swap transaction, with explicit destination account:
+
+    $ {gc.prog_name} ETH.USDT 1000 ETH.DAI E:01234ABC:3
 """

+ 20 - 0
mmgen/proto/eth/contract.py

@@ -176,3 +176,23 @@ class ResolvedToken(Token, metaclass=AsyncInit):
 		if not self.decimals:
 			die('TokenNotInBlockchain', f'Token {addr!r} not in blockchain')
 		self.base_unit = Decimal('10') ** -self.decimals
+
+# Tokens: First approve router to spend tokens from user: asset.approve(router,amount).
+# Then call router.depositWithExpiry(inbound_address, asset, amount, memo, expiry).
+# Asset is the token contract address. Amount should be in native asset decimals
+# (eg 1e18 for most tokens). Do not swap to smart contract addresses.
+class THORChainRouterContract(Token):
+
+	def create_deposit_with_expiry_data(self, inbound_addr, asset_addr, amt, memo, expiry):
+		assert isinstance(memo, bytes)
+		assert isinstance(expiry, int)
+		memo_chunks = len(memo) // 32 + bool(len(memo) % 32)
+		return ( # Method ID: 0x44bc937b
+			self.create_method_id('depositWithExpiry(address,address,uint256,string,uint256)')
+			+ inbound_addr.rjust(64, '0')                   # 32 bytes
+			+ asset_addr.rjust(64, '0')                     # 32 bytes
+			+ '{:064x}'.format(int(amt / self.base_unit))   # 32 bytes
+			+ '{:064x}'.format(32 * 5)                      # 32 bytes (memo offset)
+			+ '{:064x}'.format(expiry)                      # 32 bytes
+			+ '{:064x}'.format(len(memo))                   # dynamic arg
+			+ memo.hex().ljust(64 * memo_chunks, '0'))

+ 1 - 1
mmgen/proto/eth/tw/ctl.py

@@ -141,7 +141,7 @@ class EthereumTwCtl(TwCtl):
 
 	async def sym2addr(self, sym):
 		for addr in self.data['tokens']:
-			if self.data['tokens'][addr]['params']['symbol'] == sym.upper():
+			if self.data['tokens'][addr]['params']['symbol'].upper() == sym.upper():
 				return addr
 
 	def get_token_param(self, token, param):

+ 20 - 3
mmgen/proto/eth/tx/base.py

@@ -111,7 +111,7 @@ class Base(TxBase):
 						f'{d[5]}: invalid swap memo in serialized data')
 
 class TokenBase(Base):
-	dfl_gas = 52000
+	dfl_gas = 75000
 	contract_desc = 'token contract'
 
 	def check_serialized_integrity(self):
@@ -126,8 +126,8 @@ class TokenBase(Base):
 			assert d[4] == b'', f'{d[4]}: non-empty amount field in token transaction in serialized data'
 
 			data = d[5].hex()
-			assert data[:8] == 'a9059cbb', (
-				f'{data[:8]}: invalid MethodID for op ‘transfer’ in serialized data')
+			assert data[:8] == ('095ea7b3' if self.is_swap else 'a9059cbb'), (
+				f'{data[:8]}: invalid MethodID for op ‘{self.token_op}’ in serialized data')
 			assert data[32:72] == o['token_to'], (
 				f'{data[32:72]}: invalid ‘token_to‘ address in serialized data')
 			assert TokenAmt(
@@ -135,3 +135,20 @@ class TokenBase(Base):
 					decimals = o['decimals'],
 					from_unit = 'atomic') == o['amt'], (
 				f'{data[72:]}: invalid amt in serialized data')
+
+			if self.is_swap:
+				d = rlp.decode(bytes.fromhex(self.serialized2))
+				data = d[5].hex()
+				assert data[:8] == '44bc937b', (
+					f'{data[:8]}: invalid MethodID in router TX serialized data')
+				assert data[32:72] == self.token_vault_addr, (
+					f'{data[32:72]}: invalid vault address in router TX serialized data')
+
+				memo = bytes.fromhex(data[392:])[:len(self.swap_memo)]
+				assert memo == self.swap_memo.encode(), (
+					f'{memo}: invalid swap memo in router TX serialized data')
+				assert TokenAmt(
+						int(data[136:200], 16),
+						decimals = o['decimals'],
+						from_unit = 'atomic') == o['amt'], (
+					f'{data[136:200]}: invalid amt in router TX serialized data')

+ 6 - 3
mmgen/proto/eth/tx/info.py

@@ -14,7 +14,7 @@ proto.eth.tx.info: Ethereum transaction info class
 
 from ....tx.info import TxInfo
 from ....util import fmt, pp_fmt
-from ....color import pink, yellow, blue
+from ....color import pink, yellow, blue, cyan
 from ....addr import MMGenID
 
 class TxInfo(TxInfo):
@@ -34,7 +34,7 @@ class TxInfo(TxInfo):
 			return ' ' + (io.mmid.hl() if io.mmid else MMGenID.hlc(nonmm_str))
 		fs = """
 			From:      {f}{f_mmid}
-			To:        {t}{t_mmid}
+			{toaddr}   {t}{t_mmid}{tvault}
 			Amount:    {a} {c}
 			Gas price: {g} Gwei
 			Start gas: {G} Kwei
@@ -44,10 +44,13 @@ class TxInfo(TxInfo):
 		t = tx.txobj
 		td = t['data']
 		to_addr = t[self.to_addr_key]
+		tokenswap = tx.is_swap and tx.is_token
 		return fs.format(
 			f      = t['from'].hl(0),
 			t      = to_addr.hl(0) if to_addr else blue('None'),
 			a      = t['amt'].hl(),
+			toaddr = ('Router:' if tokenswap else 'To:').ljust(8),
+			tvault = (f'\nVault:     {cyan(tx.token_vault_addr)}' if tokenswap else ''),
 			n      = t['nonce'].hl(),
 			d      = blue('None') if not td else '{}... ({} bytes)'.format(td[:40], len(td)//2),
 			m      = pink(tx.swap_memo) if tx.is_swap else None,
@@ -55,7 +58,7 @@ class TxInfo(TxInfo):
 			g      = yellow(tx.pretty_fmt_fee(t['gasPrice'].to_unit('Gwei'))),
 			G      = yellow(tx.pretty_fmt_fee(t['startGas'].to_unit('Kwei'))),
 			f_mmid = mmid_disp(tx.inputs[0]),
-			t_mmid = mmid_disp(tx.outputs[0]) if tx.outputs else '') + '\n\n'
+			t_mmid = mmid_disp(tx.outputs[0]) if tx.outputs and not tokenswap else '') + '\n\n'
 
 	def format_abs_fee(self, iwidth, /, *, color=None):
 		return self.tx.fee.fmt(iwidth, color=color) + (' (max)' if self.tx.txobj['data'] else '')

+ 2 - 0
mmgen/proto/eth/tx/new.py

@@ -216,6 +216,8 @@ class TokenNew(TokenBase, New):
 		o['token_addr'] = t.addr
 		o['decimals'] = t.decimals
 		o['token_to'] = o['to']
+		if self.is_swap:
+			o['expiry'] = self.quote_data.data['expiry']
 
 	def update_change_output(self, funds_left):
 		if self.outputs[0].is_chg:

+ 8 - 1
mmgen/proto/eth/tx/new_swap.py

@@ -13,7 +13,7 @@ proto.eth.tx.new_swap: Ethereum new swap transaction class
 """
 
 from ....tx.new_swap import NewSwap as TxNewSwap
-from .new import New
+from .new import New, TokenNew
 
 class NewSwap(New, TxNewSwap):
 	desc = 'Ethereum swap transaction'
@@ -34,3 +34,10 @@ class NewSwap(New, TxNewSwap):
 	@property
 	def vault_output(self):
 		return self.outputs[0]
+
+class TokenNewSwap(TokenNew, NewSwap):
+	desc = 'Ethereum token swap transaction'
+
+	def update_vault_addr(self, c):
+		self.token_vault_addr = self.proto.coin_addr(c.inbound_address)
+		return super().update_vault_addr(c, addr='router')

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

@@ -18,7 +18,7 @@ from ....tx import unsigned as TxBase
 from ....util import msg, msg_r, die
 from ....obj import CoinTxID, ETHNonce, Int, HexStr
 from ....addr import CoinAddr, ContractAddr
-from ..contract import Token
+from ..contract import Token, THORChainRouterContract
 from .completed import Completed, TokenCompleted
 
 class Unsigned(Completed, TxBase.Unsigned):
@@ -110,14 +110,32 @@ class TokenUnsigned(TokenCompleted, Unsigned):
 		o['token_addr'] = ContractAddr(self.proto, d['token_addr'])
 		o['decimals'] = Int(d['decimals'])
 		o['token_to'] = o['to']
+		if self.is_swap:
+			o['expiry'] = Int(d['expiry'])
 
 	async def do_sign(self, o, wif):
 		t = Token(self.cfg, self.proto, o['token_addr'], decimals=o['decimals'])
-		tdata = t.create_transfer_data(o['to'], o['amt'], op='transfer')
+		tdata = t.create_transfer_data(o['to'], o['amt'], op=self.token_op)
 		tx_in = t.make_tx_in(gas=self.gas, gasPrice=o['gasPrice'], nonce=o['nonce'], data=tdata)
 		res = await t.txsign(tx_in, wif, o['from'], chain_id=o['chainId'])
 		self.serialized = res.txhex
 		self.coin_txid = res.txid
+		if self.is_swap:
+			c = THORChainRouterContract(self.cfg, self.proto, o['to'], decimals=o['decimals'])
+			cdata = c.create_deposit_with_expiry_data(
+				self.token_vault_addr,
+				o['token_addr'],
+				o['amt'],
+				self.swap_memo.encode(),
+				o['expiry'])
+			tx_in = c.make_tx_in(
+				gas = self.gas * (7.8 if self.cfg.test_suite else 2),
+				gasPrice = o['gasPrice'],
+				nonce = o['nonce'] + 1,
+				data = cdata)
+			res = await t.txsign(tx_in, wif, o['from'], chain_id=o['chainId'])
+			self.serialized2 = res.txhex
+			self.coin_txid2 = res.txid
 
 class AutomountUnsigned(TxBase.AutomountUnsigned, Unsigned):
 	pass

+ 36 - 1
mmgen/swap/proto/thorchain/asset.py

@@ -24,9 +24,44 @@ class THORChainSwapAsset(SwapAsset):
 		'ETH':       _ad('Ethereum',                'ETH',  None,        'e',  True),
 		'DOGE':      _ad('Dogecoin',                'DOGE', None,        'd',  False),
 		'RUNE':      _ad('Rune (THORChain)',        'RUNE', 'THOR.RUNE', 'r',  False),
+		'ETH.AAVE':  _ad('Aave (ETH)',              None,   'ETH.AAVE',  None, True),
+		'ETH.DAI':   _ad('Sky Dollar (USDS) (ETH)', None,   'ETH.DAI',   None, True),
+		'ETH.DPI':   _ad('DeFi Pulse Index (ETH)',  None,   'ETH.DPI',   None, True),
+		'ETH.FOX':   _ad('ShapeShift FOX (ETH)',    None,   'ETH.FOX',   None, True),
+		'ETH.GUSD':  _ad('Gemini Dollar (ETH)',     None,   'ETH.GUSD',  None, True),
+		'ETH.LINK':  _ad('Chainlink (ETH)',         None,   'ETH.LINK',  None, True),
+		'ETH.LUSD':  _ad('Liquity USD (ETH)',       None,   'ETH.LUSD',  None, True),
+		'ETH.SNX':   _ad('Synthetix (ETH)',         None,   'ETH.SNX',   None, True),
+		'ETH.TGT':   _ad('THORWallet (ETH)',        None,   'ETH.TGT',   None, True),
+		'ETH.THOR':  _ad('THORSwap (ETH)',          None,   'ETH.THOR',  None, True),
+		'ETH.USDC':  _ad('USDC (ETH)',              None,   'ETH.USDC',  None, True),
+		'ETH.USDP':  _ad('Pax Dollar (ETH)',        None,   'ETH.USDP',  None, True),
+		'ETH.USDT':  _ad('Tether (ETH)',            None,   'ETH.USDT',  None, True),
+		'ETH.vTHOR': _ad('THORSwap Staking (ETH)',  None,   'ETH.vTHOR', None, True),
+		'ETH.WBTC':  _ad('Wrapped BTC (ETH)',       None,   'ETH.WBTC',  None, True),
+		'ETH.XRUNE': _ad('Thorstarter (ETH)',       None,   'ETH.XRUNE', None, True),
+		'ETH.YFI':   _ad('yearn.finance (ETH)',     None,   'ETH.YFI',   None, True),
 	}
 
-	evm_contracts = {}
+	evm_contracts = {
+		'ETH.AAVE':  '7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9',
+		'ETH.DAI':   '6b175474e89094c44da98b954eedeac495271d0f',
+		'ETH.DPI':   '1494ca1f11d487c2bbe4543e90080aeba4ba3c2b',
+		'ETH.FOX':   'c770eefad204b5180df6a14ee197d99d808ee52d',
+		'ETH.GUSD':  '056fd409e1d7a124bd7017459dfea2f387b6d5cd',
+		'ETH.LINK':  '514910771af9ca656af840dff83e8264ecf986ca',
+		'ETH.LUSD':  '5f98805a4e8be255a32880fdec7f6728c6568ba0',
+		'ETH.SNX':   'c011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f',
+		'ETH.TGT':   '108a850856db3f85d0269a2693d896b394c80325',
+		'ETH.THOR':  'a5f2211b9b8170f694421f2046281775e8468044',
+		'ETH.USDC':  'a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+		'ETH.USDP':  '8e870d67f660d95d5be530380d0ec0bd388289e1',
+		'ETH.USDT':  'dac17f958d2ee523a2206206994597c13d831ec7',
+		'ETH.vTHOR': '815c23eca83261b6ec689b60cc4a58b54bc24d8d',
+		'ETH.WBTC':  '2260fac5e5542a773aa44fbcfedf7c193bc2c599',
+		'ETH.XRUNE': '69fa0fee221ad11012bab0fdb45d444d3d2ce71c',
+		'ETH.YFI':   '0bc529c00c6401aef6d220be8c6ea1667f6ad93e',
+	}
 
 	unsupported = ('DOGE', 'RUNE')
 

+ 4 - 0
mmgen/swap/proto/thorchain/thornode.py

@@ -172,6 +172,10 @@ class Thornode:
 		addr = self.data['inbound_address']
 		return addr.removeprefix('0x') if self.tx.proto.is_evm else addr
 
+	@property
+	def router(self):
+		return self.data['router'].lower().removeprefix('0x')
+
 	@property
 	def rel_fee_hint(self):
 		gas_unit = self.data['gas_rate_units']

+ 1 - 0
mmgen/tx/__init__.py

@@ -78,6 +78,7 @@ async def _get_obj_async(_clsname, _modname, **kwargs):
 	# signing.
 	if proto and proto.tokensym and clsname in (
 			'New',
+			'NewSwap',
 			'OnlineSigned',
 			'AutomountOnlineSigned',
 			'Sent',

+ 9 - 1
mmgen/tx/base.py

@@ -86,7 +86,10 @@ class Base(MMGenObject):
 		'swap_quote_expiry': None,
 		'swap_recv_addr_mmid': None,
 		'swap_recv_asset_spec': None,
-		'swap_memo': None}
+		'swap_memo': None,
+		'token_vault_addr': None,
+		'serialized2': None,
+		'coin_txid2': CoinTxID}
 	file_format  = 'json'
 	non_mmgen_inputs_msg = f"""
 		This transaction includes inputs with non-{gc.proj_name} addresses.  When
@@ -243,3 +246,8 @@ class Base(MMGenObject):
 			from ..swap.asset import SwapAsset
 			x = '[unknown]'
 			return SwapAsset._ad(x, x, x, x, x)
+
+	# token methods:
+	@property
+	def token_op(self):
+		return 'approve' if self.is_swap else 'transfer'

+ 4 - 3
mmgen/tx/new_swap.py

@@ -173,11 +173,11 @@ class NewSwap(New):
 		else:
 			self.usr_trade_limit = None
 
-	def update_vault_addr(self, addr):
+	def update_vault_addr(self, c, *, addr='inbound_address'):
 		vault_idx = self.vault_idx
 		assert vault_idx == 0, f'{vault_idx}: vault index is not zero!'
 		o = self.outputs[vault_idx]._asdict()
-		o['addr'] = addr
+		o['addr'] = getattr(c, addr)
 		self.outputs[vault_idx] = self.Output(self.proto, **o)
 
 	async def update_vault_output(self, amt, *, deduct_est_fee=False):
@@ -206,6 +206,7 @@ class NewSwap(New):
 				break
 
 		self.swap_quote_expiry = c.data['expiry']
-		self.update_vault_addr(c.inbound_address)
+		self.update_vault_addr(c)
 		self.update_data_output(trade_limit)
+		self.quote_data = c
 		return c.rel_fee_hint

+ 13 - 3
test/cmdtest_d/ethdev.py

@@ -57,7 +57,8 @@ from .include.common import (
 	get_file_with_ext,
 	ok_msg,
 	Ctrl_U,
-	cleanup_env)
+	cleanup_env,
+	thorchain_router_addr_file)
 
 from .base import CmdTestBase
 from .shared import CmdTestShared
@@ -240,10 +241,11 @@ class CmdTestEthdevMethods:
 			gas,
 			mmgen_cmd = 'txdo',
 			gas_price = '8G',
+			fn = None,
 			num = None):
 
 		keyfile = joinpath(self.tmpdir, dfl_devkey_fn)
-		fn = joinpath(self.tmpdir, 'mm'+str(num), key+'.bin')
+		fn = fn or joinpath(self.tmpdir, 'mm'+str(num), key+'.bin')
 		args = [
 			'-B',
 			f'--fee={gas_price}',
@@ -256,6 +258,14 @@ class CmdTestEthdevMethods:
 		contract_addr = self._get_contract_address(dfl_devaddr)
 		if key == 'Token':
 			self.write_to_tmpfile(f'token_addr{num}', contract_addr+'\n')
+		elif key == 'thorchain_router':
+			from mmgen.fileutil import write_data_to_file
+			write_data_to_file(
+				self.cfg,
+				thorchain_router_addr_file,
+				contract_addr + '\n',
+				ask_overwrite = False,
+				quiet = True)
 
 		if mmgen_cmd == 'txdo':
 			args += ['-k', keyfile]
@@ -1587,7 +1597,7 @@ class CmdTestEthdev(CmdTestEthdevMethods, CmdTestBase, CmdTestShared):
 	def token_txdo_cached_balances(self):
 		return self.txdo_cached_balances(
 			acct          = '1',
-			fee_info_data = ('0.0026', '50'),
+			fee_info_data = ('0.00375', '50'),
 			add_args      = ['--token=mm1', '98831F3A:E:12,43.21'])
 
 	def token_txcreate_refresh_balances(self):

+ 147 - 7
test/cmdtest_d/ethswap.py

@@ -12,11 +12,17 @@
 test.cmdtest_d.ethswap: Ethereum swap tests for the cmdtest.py test suite
 """
 
+from subprocess import run, PIPE, DEVNULL
+
 from mmgen.cfg import Config
+from mmgen.util import rmsg, die
 from mmgen.protocol import init_proto
+from mmgen.fileutil import get_data_from_file
+
+from ..include.common import imsg, chk_equal
 
 from .include.runner import CmdTestRunner
-from .include.common import dfl_sid
+from .include.common import dfl_sid, eth_inbound_addr, thorchain_router_addr_file
 from .httpd.thornode import ThornodeServer
 
 from .regtest import CmdTestRegtest
@@ -42,6 +48,38 @@ class CmdTestEthSwapMethods:
 	async def token_deploy_c(self):
 		return await self._token_deploy_token(num=1)
 
+	def token_compile_router(self):
+
+		if not self.using_solc:
+			bin_fn = 'test/ref/ethereum/bin/THORChain_Router.bin'
+			imsg(f'Using precompiled contract data ‘{bin_fn}’')
+			import shutil
+			shutil.copy(bin_fn, self.tmpdir)
+			return 'skip'
+
+		imsg("Compiling THORChain router contract")
+		self.spawn(msg_only=True)
+		cmd = [
+			'solc',
+			'--evm-version=constantinople',
+			'--overwrite',
+			f'--output-dir={self.tmpdir}',
+			'--bin',
+			'test/ref/ethereum/THORChain_Router.sol']
+		imsg('Executing: {}'.format(' '.join(cmd)))
+		cp = run(cmd, stdout=DEVNULL, stderr=PIPE)
+		if cp.returncode != 0:
+			rmsg('solc failed with the following output:')
+			die(2, cp.stderr.decode())
+		imsg('THORChain router contract compiled')
+		return 'ok'
+
+	async def token_deploy_router(self):
+		return await self._token_deploy(
+			key = 'thorchain_router',
+			gas = 1_000_000,
+			fn  = f'{self.tmpdir}/THORChain_Router.bin')
+
 	def token_fund_user(self):
 		return self._token_transfer_ops(
 			op          = 'fund_user',
@@ -54,9 +92,40 @@ class CmdTestEthSwapMethods:
 	def token_addrimport(self):
 		return self._token_addrimport('token_addr1', '1-5', expect='5/5')
 
+	def token_addrimport_inbound(self):
+		token_addr = self.read_from_tmpfile('token_addr1').strip()
+		return self.spawn(
+			'mmgen-addrimport',
+			['--quiet', '--regtest=1', f'--token-addr={token_addr}', f'--address={eth_inbound_addr}'])
+
 	def token_bal1(self):
 		return self._token_bal_check(pat=rf'{dfl_sid}:E:1\s+{self.token_fund_amt}\s')
 
+	def token_bal2(self):
+		return self._token_bal_check(pat=rf'{eth_inbound_addr}\s+\S+\s+87.654321\s')
+
+	async def _swaptxmemo(self, chk):
+		from mmgen.proto.eth.contract import Contract
+		self.spawn(msg_only=True)
+		addr = get_data_from_file(self.cfg, thorchain_router_addr_file, quiet=True).strip()
+		c = Contract(self.cfg, self.proto, addr, rpc=await self.rpc)
+		res = (await c.do_call('saved_memo()'))[2:]
+		memo_len = int(res[64:128], 16)
+		chk_equal(bytes.fromhex(res[128:128+(2*memo_len)]).decode(), chk)
+		imsg(f'saved_memo: {chk}')
+		return 'ok'
+
+	def _swaptxsend_eth_proxy(self, *, add_opts=[], test=False):
+		t = self._swaptxsend(
+			add_opts = ['--tx-proxy=eth'] + (['--test'] if test else []) + add_opts,
+			spawn_only = True)
+		t.expect('view: ', 'y')
+		t.expect('continue: ', '\n') # exit swap quote
+		t.expect('(y/N): ', '\n')    # add comment
+		if not test:
+			t.expect('to confirm: ', 'YES\n')
+		return t
+
 class CmdTestEthSwap(CmdTestSwapMethods, CmdTestRegtest):
 	'Ethereum swap operations'
 
@@ -117,9 +186,12 @@ class CmdTestEthSwap(CmdTestSwapMethods, CmdTestRegtest):
 		('eth_token_deploy_a',           ''),
 		('eth_token_deploy_b',           ''),
 		('eth_token_deploy_c',           ''),
+		('eth_token_compile_router',     ''),
+		('eth_token_deploy_router',      ''),
 		('eth_token_fund_user',          ''),
 		('eth_token_addrgen',            ''),
 		('eth_token_addrimport',         ''),
+		('eth_token_addrimport_inbound', ''),
 		('eth_token_bal1',               ''),
 	),
 	'token_swap': (
@@ -153,10 +225,28 @@ class CmdTestEthSwap(CmdTestSwapMethods, CmdTestRegtest):
 		('eth_bal2',          ''),
 	),
 	'eth_token_swap': (
-		'swap operations (ETH <-> MM1)',
-		('eth_swaptxcreate3',  ''),
-		('eth_swaptxsign3',    ''),
-		('eth_swaptxsend3',    ''),
+		'swap operations (ETH -> ERC20, ERC20 -> BTC, ERC20 -> ETH)',
+		# ETH -> MM1
+		('eth_swaptxcreate3',          ''),
+		('eth_swaptxsign3',            ''),
+		('eth_swaptxsend3',            ''),
+		# MM1 -> BTC
+		('eth_swaptxcreate4',          ''),
+		('eth_swaptxsign4',            ''),
+		('eth_swaptxsend4',            ''),
+		('eth_swaptxmemo4',            ''),
+		('eth_swaptxstatus4',          ''),
+		('eth_swaptxreceipt4',         ''),
+		('eth_token_bal2',             ''),
+		# MM1 -> ETH
+		('eth_swaptxcreate5',          ''),
+		('eth_swaptxsign5',            ''),
+		('eth_etherscan_server_start', ''),
+		('eth_swaptxsend5_test',       ''),
+		('eth_swaptxsend5a',           ''),
+		('eth_swaptxsend5b',           ''),
+		('eth_swaptxsend5',            ''),
+		('eth_etherscan_server_stop',  ''),
 	),
 	}
 
@@ -266,9 +356,12 @@ class CmdTestEthSwapEth(CmdTestEthSwapMethods, CmdTestSwapMethods, CmdTestEthdev
 		('token_deploy_a',           'deploying ERC20 token MM1 (SafeMath)'),
 		('token_deploy_b',           'deploying ERC20 token MM1 (Owned)'),
 		('token_deploy_c',           'deploying ERC20 token MM1 (Token)'),
+		('token_compile_router',     'compiling THORChain router contract'),
+		('token_deploy_router',      'deploying THORChain router contract'),
 		('token_fund_user',          'transferring token funds from dev to user'),
 		('token_addrgen',            'generating token addresses'),
 		('token_addrimport',         'importing token addresses using token address (MM1)'),
+		('token_addrimport_inbound', 'importing THORNode inbound token address'),
 		('token_bal1',               'the token balance'),
 
 		# eth_token_swap:
@@ -276,6 +369,25 @@ class CmdTestEthSwapEth(CmdTestEthSwapMethods, CmdTestSwapMethods, CmdTestEthdev
 		('swaptxcreate3',          'creating an ETH->MM1 swap transaction'),
 		('swaptxsign3',            'signing the transaction'),
 		('swaptxsend3',            'sending the transaction'),
+
+		# MM1 -> BTC
+		('swaptxcreate4',          'creating an MM1->BTC swap transaction'),
+		('swaptxsign4',            'signing the transaction'),
+		('swaptxsend4',            'sending the transaction'),
+		('swaptxmemo4',            'checking the memo'),
+		('swaptxstatus4',          'getting the transaction status'),
+		('swaptxreceipt4',         'getting the transaction receipt'),
+		('token_bal2',             'the token balance'),
+
+		# MM1 -> ETH
+		('swaptxcreate5',          'creating an MM1->ETH swap transaction'),
+		('swaptxsign5',            'signing the transaction'),
+		('etherscan_server_start', 'starting the Etherscan server'),
+		('swaptxsend5_test',       'testing the transaction via Etherscan'),
+		('swaptxsend5a',           'sending the transaction via Etherscan (p1)'),
+		('swaptxsend5b',           'sending the transaction via Etherscan (p2)'),
+		('swaptxsend5',            'sending the transaction via Etherscan (complete)'),
+		('etherscan_server_stop',  'stopping the Etherscan server'),
 	)
 
 	def swaptxcreate1(self):
@@ -294,6 +406,14 @@ class CmdTestEthSwapEth(CmdTestEthSwapMethods, CmdTestSwapMethods, CmdTestEthdev
 		t = self._swaptxcreate(['ETH', '8.765', 'ETH.MM1', f'{dfl_sid}:E:5'])
 		return self._swaptxcreate_ui_common(t)
 
+	def swaptxcreate4(self):
+		t = self._swaptxcreate(['ETH.MM1', '87.654321', 'BTC', f'{dfl_sid}:C:2'])
+		return self._swaptxcreate_ui_common(t)
+
+	def swaptxcreate5(self):
+		t = self._swaptxcreate(['ETH.MM1', '98.7654321', 'ETH', f'{dfl_sid}:E:12'])
+		return self._swaptxcreate_ui_common(t)
+
 	def swaptxsign1(self):
 		return self._swaptxsign()
 
@@ -304,8 +424,28 @@ class CmdTestEthSwapEth(CmdTestEthSwapMethods, CmdTestSwapMethods, CmdTestEthdev
 		self.mining_delay()
 		return self._swaptxsend(add_opts=['--verbose', '--status'], status=True)
 
-	swaptxsign3 = swaptxsign1
-	swaptxsend3 = swaptxsend1
+	def swaptxmemo4(self):
+		return self._swaptxmemo('=:b:mkQsXA7mqDtnUpkaXMbDtAL1KMeof4GPw3:0/1/0')
+
+	def swaptxreceipt4(self):
+		self.mining_delay()
+		return self._swaptxsend(add_opts=['--receipt'], spawn_only=True)
+
+	def swaptxsend5_test(self):
+		return self._swaptxsend_eth_proxy(test=True)
+
+	def swaptxsend5a(self):
+		return self._swaptxsend_eth_proxy(add_opts=['--txhex-idx=1'])
+
+	def swaptxsend5b(self):
+		return self._swaptxsend_eth_proxy(add_opts=['--txhex-idx=2'])
+
+	def swaptxsend5(self):
+		return self._swaptxsend_eth_proxy()
+
+	swaptxsign5 = swaptxsign4 = swaptxsign3 = swaptxsign1
+	swaptxsend4 = swaptxsend3 = swaptxsend1
+	swaptxstatus4 = swaptxstatus1
 
 	def bal1(self):
 		return self.bal('swap1')

+ 18 - 8
test/cmdtest_d/httpd/thornode.py

@@ -18,6 +18,8 @@ from mmgen.cfg import Config
 from mmgen.amt import UniAmt
 from mmgen.protocol import init_proto
 
+from ..include.common import eth_inbound_addr, thorchain_router_addr_file
+
 from . import HTTPD
 
 cfg = Config()
@@ -117,14 +119,16 @@ data_template_eth = {
 }
 
 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
-	ret = tool_cmd(
-		cfg     = cfg,
-		cmdname = 'pubhash2addr',
-		proto   = proto,
-		mmtype  = mmtype).pubhash2addr(f'{n:040x}')
-	return '0x' + ret if proto.is_evm else ret
+	if proto.is_evm:
+		return '0x' + eth_inbound_addr # non-checksummed as per ninerealms thornode
+	else:
+		from mmgen.tool.coin import tool_cmd
+		n = int(time.time()) // (60 * 60 * 24) # increments once every 24 hrs
+		return tool_cmd(
+			cfg     = cfg,
+			cmdname = 'pubhash2addr',
+			proto   = proto,
+			mmtype  = mmtype).pubhash2addr(f'{n:040x}')
 
 class ThornodeServer(HTTPD):
 	name = 'thornode server'
@@ -160,4 +164,10 @@ class ThornodeServer(HTTPD):
 				'recommended_gas_rate': recommended_gas_rate[send_proto.base_proto_coin]
 			})
 
+		if send_asset == 'MM1':
+			eth_proto = init_proto(cfg, 'eth', network='regtest')
+			with open(thorchain_router_addr_file) as fh:
+				raw_addr = fh.read().strip()
+			data['router'] = '0x' + eth_proto.checksummed_addr(raw_addr)
+
 		return json.dumps(data).encode()

+ 4 - 0
test/cmdtest_d/include/common.py

@@ -71,6 +71,10 @@ chksum_pat = r'\b[A-F0-9]{4} [A-F0-9]{4} [A-F0-9]{4} [A-F0-9]{4}\b'
 
 Ctrl_U = '\x15'
 
+eth_inbound_addr = (28 * '0') + 'feedbeefcafe'
+
+thorchain_router_addr_file = 'test/data_dir/thorchain_router_addr'
+
 def ok_msg():
 	if cfg.profile:
 		return

+ 3 - 0
test/include/common.py

@@ -236,6 +236,9 @@ def cmp_or_die(s, t, desc=None):
 			f'ERROR: recoded data:\n{t!r}\ndiffers from original data:\n{s!r}'
 		)
 
+def chk_equal(a, b):
+	assert a == b, f'equality test failed: {a} != {b}'
+
 def init_coverage():
 	coverdir = os.path.join('test', 'trace')
 	acc_file = os.path.join('test', 'trace.acc')

+ 0 - 1
test/overlay/fakemods/mmgen/swap/proto/thorchain/asset.py

@@ -3,7 +3,6 @@ from .asset_orig import *
 class overlay_fake_THORChainSwapAsset:
 
 	assets_data = {
-		'ETH.USDT': THORChainSwapAsset._ad('Tether (ETH)',     None, 'ETH.USDT', None, True),
 		'ETH.MM1':  THORChainSwapAsset._ad('MM1 Token (ETH)',  None, 'ETH.MM1',  None, True),
 		'ETH.JUNK': THORChainSwapAsset._ad('Junk Token (ETH)', None, 'ETH.JUNK', None, True),
 		'ETH.NONE': THORChainSwapAsset._ad('Unavailable Token (ETH)', None, 'ETH.NONE', None, True)

+ 35 - 0
test/ref/ethereum/THORChain_Router.sol

@@ -0,0 +1,35 @@
+// 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
+//
+// Minimal THORChain router for testing
+//
+// SPDX-License-Identifier: GPL-3.0
+
+pragma solidity >=0.8.25;
+
+interface iERC20 {
+	function transferFrom(
+		address from,
+		address to,
+		uint tokens) external payable returns (bool success);
+}
+
+contract THORChain_Router {
+	string public saved_memo;
+	function depositWithExpiry(
+		address payable vault,
+		address asset,
+		uint amount,
+		string memory memo,
+		uint expiration
+	) external payable returns (bool success) {
+		require(block.timestamp < expiration, "THORChain_Router: expired");
+		saved_memo = memo;
+		return iERC20(asset).transferFrom(msg.sender, vault, amount);
+	}
+}

File diff suppressed because it is too large
+ 0 - 0
test/ref/ethereum/bin/THORChain_Router.bin


Some files were not shown because too many files changed in this diff