From ff28d39a3c9a47a90ee0899606c96d4b9e6d845b Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Sun, 27 Apr 2025 11:53:49 +0000 Subject: [PATCH] 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 --- MANIFEST.in | 1 + mmgen/data/version | 2 +- mmgen/help/swaptxcreate_examples.py | 17 ++ mmgen/proto/eth/contract.py | 20 +++ mmgen/proto/eth/tw/ctl.py | 2 +- mmgen/proto/eth/tx/base.py | 23 ++- mmgen/proto/eth/tx/info.py | 9 +- mmgen/proto/eth/tx/new.py | 2 + mmgen/proto/eth/tx/new_swap.py | 9 +- mmgen/proto/eth/tx/unsigned.py | 22 ++- mmgen/swap/proto/thorchain/asset.py | 37 ++++- mmgen/swap/proto/thorchain/thornode.py | 4 + mmgen/tx/__init__.py | 1 + mmgen/tx/base.py | 10 +- mmgen/tx/new_swap.py | 7 +- test/cmdtest_d/ethdev.py | 16 +- test/cmdtest_d/ethswap.py | 154 +++++++++++++++++- test/cmdtest_d/httpd/thornode.py | 26 ++- test/cmdtest_d/include/common.py | 4 + test/include/common.py | 3 + .../mmgen/swap/proto/thorchain/asset.py | 1 - test/ref/ethereum/THORChain_Router.sol | 35 ++++ test/ref/ethereum/bin/THORChain_Router.bin | 1 + 23 files changed, 371 insertions(+), 35 deletions(-) create mode 100644 test/ref/ethereum/THORChain_Router.sol create mode 100644 test/ref/ethereum/bin/THORChain_Router.bin diff --git a/MANIFEST.in b/MANIFEST.in index 4d76e11f..5d490c27 100644 --- a/MANIFEST.in +++ b/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 diff --git a/mmgen/data/version b/mmgen/data/version index 095135a0..faf388d3 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -15.1.dev30 +15.1.dev31 diff --git a/mmgen/help/swaptxcreate_examples.py b/mmgen/help/swaptxcreate_examples.py index e37f192f..48cd5d5f 100755 --- a/mmgen/help/swaptxcreate_examples.py +++ b/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 """ diff --git a/mmgen/proto/eth/contract.py b/mmgen/proto/eth/contract.py index ab13541a..9d35c03d 100755 --- a/mmgen/proto/eth/contract.py +++ b/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')) diff --git a/mmgen/proto/eth/tw/ctl.py b/mmgen/proto/eth/tw/ctl.py index 2753c88b..75606405 100755 --- a/mmgen/proto/eth/tw/ctl.py +++ b/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): diff --git a/mmgen/proto/eth/tx/base.py b/mmgen/proto/eth/tx/base.py index 508cee7a..2387a478 100755 --- a/mmgen/proto/eth/tx/base.py +++ b/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') diff --git a/mmgen/proto/eth/tx/info.py b/mmgen/proto/eth/tx/info.py index 18b58ba8..b4d8e567 100755 --- a/mmgen/proto/eth/tx/info.py +++ b/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 '') diff --git a/mmgen/proto/eth/tx/new.py b/mmgen/proto/eth/tx/new.py index bd7c9962..71f17f91 100755 --- a/mmgen/proto/eth/tx/new.py +++ b/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: diff --git a/mmgen/proto/eth/tx/new_swap.py b/mmgen/proto/eth/tx/new_swap.py index 1e77cf05..93cab4a6 100755 --- a/mmgen/proto/eth/tx/new_swap.py +++ b/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') diff --git a/mmgen/proto/eth/tx/unsigned.py b/mmgen/proto/eth/tx/unsigned.py index 191687fc..2d38f743 100755 --- a/mmgen/proto/eth/tx/unsigned.py +++ b/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 diff --git a/mmgen/swap/proto/thorchain/asset.py b/mmgen/swap/proto/thorchain/asset.py index 3339707f..b0f2c88f 100644 --- a/mmgen/swap/proto/thorchain/asset.py +++ b/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') diff --git a/mmgen/swap/proto/thorchain/thornode.py b/mmgen/swap/proto/thorchain/thornode.py index 68a007f5..86db8453 100755 --- a/mmgen/swap/proto/thorchain/thornode.py +++ b/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'] diff --git a/mmgen/tx/__init__.py b/mmgen/tx/__init__.py index 97e1dbc0..4098723a 100755 --- a/mmgen/tx/__init__.py +++ b/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', diff --git a/mmgen/tx/base.py b/mmgen/tx/base.py index 982970bf..eabfb383 100755 --- a/mmgen/tx/base.py +++ b/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' diff --git a/mmgen/tx/new_swap.py b/mmgen/tx/new_swap.py index cea99ce7..25f30375 100755 --- a/mmgen/tx/new_swap.py +++ b/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 diff --git a/test/cmdtest_d/ethdev.py b/test/cmdtest_d/ethdev.py index d83ec155..0ec75284 100755 --- a/test/cmdtest_d/ethdev.py +++ b/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): diff --git a/test/cmdtest_d/ethswap.py b/test/cmdtest_d/ethswap.py index 88d972ec..b4ce5acd 100755 --- a/test/cmdtest_d/ethswap.py +++ b/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') diff --git a/test/cmdtest_d/httpd/thornode.py b/test/cmdtest_d/httpd/thornode.py index f7e016b3..8e13fb69 100755 --- a/test/cmdtest_d/httpd/thornode.py +++ b/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() diff --git a/test/cmdtest_d/include/common.py b/test/cmdtest_d/include/common.py index 3a2531bc..b05004bb 100755 --- a/test/cmdtest_d/include/common.py +++ b/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 diff --git a/test/include/common.py b/test/include/common.py index f04657fd..a47d39a6 100755 --- a/test/include/common.py +++ b/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') diff --git a/test/overlay/fakemods/mmgen/swap/proto/thorchain/asset.py b/test/overlay/fakemods/mmgen/swap/proto/thorchain/asset.py index 39f55a54..703155de 100755 --- a/test/overlay/fakemods/mmgen/swap/proto/thorchain/asset.py +++ b/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) diff --git a/test/ref/ethereum/THORChain_Router.sol b/test/ref/ethereum/THORChain_Router.sol new file mode 100644 index 00000000..ac77d033 --- /dev/null +++ b/test/ref/ethereum/THORChain_Router.sol @@ -0,0 +1,35 @@ +// MMGen Wallet, a terminal-based cryptocurrency wallet +// Copyright (C)2013-2025 The MMGen Project +// 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); + } +} diff --git a/test/ref/ethereum/bin/THORChain_Router.bin b/test/ref/ethereum/bin/THORChain_Router.bin new file mode 100644 index 00000000..d3d7c420 --- /dev/null +++ b/test/ref/ethereum/bin/THORChain_Router.bin @@ -0,0 +1 @@ +6080604052348015600f57600080fd5b50610a138061001f6000396000f3fe6080604052600436106100295760003560e01c806344bc937b1461002e578063f9fa28ac1461005e575b600080fd5b61004860048036038101906100439190610422565b610089565b60405161005591906104d4565b60405180910390f35b34801561006a57600080fd5b50610073610168565b604051610080919061056e565b60405180910390f35b60008142106100cd576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016100c4906105dc565b60405180910390fd5b82600090816100dc9190610808565b508473ffffffffffffffffffffffffffffffffffffffff166323b872dd3388876040518463ffffffff1660e01b815260040161011a9392919061094d565b6020604051808303816000875af1158015610139573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061015d91906109b0565b905095945050505050565b600080546101759061062b565b80601f01602080910402602001604051908101604052809291908181526020018280546101a19061062b565b80156101ee5780601f106101c3576101008083540402835291602001916101ee565b820191906000526020600020905b8154815290600101906020018083116101d157829003601f168201915b505050505081565b6000604051905090565b600080fd5b600080fd5b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b60006102358261020a565b9050919050565b6102458161022a565b811461025057600080fd5b50565b6000813590506102628161023c565b92915050565b60006102738261020a565b9050919050565b61028381610268565b811461028e57600080fd5b50565b6000813590506102a08161027a565b92915050565b6000819050919050565b6102b9816102a6565b81146102c457600080fd5b50565b6000813590506102d6816102b0565b92915050565b600080fd5b600080fd5b6000601f19601f8301169050919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b61032f826102e6565b810181811067ffffffffffffffff8211171561034e5761034d6102f7565b5b80604052505050565b60006103616101f6565b905061036d8282610326565b919050565b600067ffffffffffffffff82111561038d5761038c6102f7565b5b610396826102e6565b9050602081019050919050565b82818337600083830152505050565b60006103c56103c084610372565b610357565b9050828152602081018484840111156103e1576103e06102e1565b5b6103ec8482856103a3565b509392505050565b600082601f830112610409576104086102dc565b5b81356104198482602086016103b2565b91505092915050565b600080600080600060a0868803121561043e5761043d610200565b5b600061044c88828901610253565b955050602061045d88828901610291565b945050604061046e888289016102c7565b935050606086013567ffffffffffffffff81111561048f5761048e610205565b5b61049b888289016103f4565b92505060806104ac888289016102c7565b9150509295509295909350565b60008115159050919050565b6104ce816104b9565b82525050565b60006020820190506104e960008301846104c5565b92915050565b600081519050919050565b600082825260208201905092915050565b60005b8381101561052957808201518184015260208101905061050e565b60008484015250505050565b6000610540826104ef565b61054a81856104fa565b935061055a81856020860161050b565b610563816102e6565b840191505092915050565b600060208201905081810360008301526105888184610535565b905092915050565b7f54484f52436861696e5f526f757465723a206578706972656400000000000000600082015250565b60006105c66019836104fa565b91506105d182610590565b602082019050919050565b600060208201905081810360008301526105f5816105b9565b9050919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052602260045260246000fd5b6000600282049050600182168061064357607f821691505b602082108103610656576106556105fc565b5b50919050565b60008190508160005260206000209050919050565b60006020601f8301049050919050565b600082821b905092915050565b6000600883026106be7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82610681565b6106c88683610681565b95508019841693508086168417925050509392505050565b6000819050919050565b60006107056107006106fb846102a6565b6106e0565b6102a6565b9050919050565b6000819050919050565b61071f836106ea565b61073361072b8261070c565b84845461068e565b825550505050565b600090565b61074861073b565b610753818484610716565b505050565b5b818110156107775761076c600082610740565b600181019050610759565b5050565b601f8211156107bc5761078d8161065c565b61079684610671565b810160208510156107a5578190505b6107b96107b185610671565b830182610758565b50505b505050565b600082821c905092915050565b60006107df600019846008026107c1565b1980831691505092915050565b60006107f883836107ce565b9150826002028217905092915050565b610811826104ef565b67ffffffffffffffff81111561082a576108296102f7565b5b610834825461062b565b61083f82828561077b565b600060209050601f8311600181146108725760008415610860578287015190505b61086a85826107ec565b8655506108d2565b601f1984166108808661065c565b60005b828110156108a857848901518255600182019150602085019450602081019050610883565b868310156108c557848901516108c1601f8916826107ce565b8355505b6001600288020188555050505b505050505050565b6108e381610268565b82525050565b60006109046108ff6108fa8461020a565b6106e0565b61020a565b9050919050565b6000610916826108e9565b9050919050565b60006109288261090b565b9050919050565b6109388161091d565b82525050565b610947816102a6565b82525050565b600060608201905061096260008301866108da565b61096f602083018561092f565b61097c604083018461093e565b949350505050565b61098d816104b9565b811461099857600080fd5b50565b6000815190506109aa81610984565b92915050565b6000602082840312156109c6576109c5610200565b5b60006109d48482850161099b565b9150509291505056fea26469706673582212207996c4888816f1f7aff6ea7100c9097607ccccd6014d16c6c24daa855287dbfc64736f6c634300081a0033 \ No newline at end of file