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
This commit is contained in:
The MMGen Project 2025-04-27 11:53:49 +00:00
commit ff28d39a3c
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
23 changed files with 371 additions and 35 deletions

View file

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

View file

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

View file

@ -73,4 +73,21 @@ EXAMPLES:
Check whether the funds have arrived in the BCH destination wallet: Check whether the funds have arrived in the BCH destination wallet:
$ mmgen-tool --coin=bch --bch-rpc-host=gemini twview minconf=0 $ 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
""" """

View file

@ -176,3 +176,23 @@ class ResolvedToken(Token, metaclass=AsyncInit):
if not self.decimals: if not self.decimals:
die('TokenNotInBlockchain', f'Token {addr!r} not in blockchain') die('TokenNotInBlockchain', f'Token {addr!r} not in blockchain')
self.base_unit = Decimal('10') ** -self.decimals 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'))

View file

@ -141,7 +141,7 @@ class EthereumTwCtl(TwCtl):
async def sym2addr(self, sym): async def sym2addr(self, sym):
for addr in self.data['tokens']: 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 return addr
def get_token_param(self, token, param): def get_token_param(self, token, param):

View file

@ -111,7 +111,7 @@ class Base(TxBase):
f'{d[5]}: invalid swap memo in serialized data') f'{d[5]}: invalid swap memo in serialized data')
class TokenBase(Base): class TokenBase(Base):
dfl_gas = 52000 dfl_gas = 75000
contract_desc = 'token contract' contract_desc = 'token contract'
def check_serialized_integrity(self): 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' assert d[4] == b'', f'{d[4]}: non-empty amount field in token transaction in serialized data'
data = d[5].hex() data = d[5].hex()
assert data[:8] == 'a9059cbb', ( assert data[:8] == ('095ea7b3' if self.is_swap else 'a9059cbb'), (
f'{data[:8]}: invalid MethodID for op ‘transfer’ in serialized data') f'{data[:8]}: invalid MethodID for op ‘{self.token_op}’ in serialized data')
assert data[32:72] == o['token_to'], ( assert data[32:72] == o['token_to'], (
f'{data[32:72]}: invalid ‘token_to‘ address in serialized data') f'{data[32:72]}: invalid ‘token_to‘ address in serialized data')
assert TokenAmt( assert TokenAmt(
@ -135,3 +135,20 @@ class TokenBase(Base):
decimals = o['decimals'], decimals = o['decimals'],
from_unit = 'atomic') == o['amt'], ( from_unit = 'atomic') == o['amt'], (
f'{data[72:]}: invalid amt in serialized data') 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')

View file

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

View file

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

View file

@ -13,7 +13,7 @@ proto.eth.tx.new_swap: Ethereum new swap transaction class
""" """
from ....tx.new_swap import NewSwap as TxNewSwap from ....tx.new_swap import NewSwap as TxNewSwap
from .new import New from .new import New, TokenNew
class NewSwap(New, TxNewSwap): class NewSwap(New, TxNewSwap):
desc = 'Ethereum swap transaction' desc = 'Ethereum swap transaction'
@ -34,3 +34,10 @@ class NewSwap(New, TxNewSwap):
@property @property
def vault_output(self): def vault_output(self):
return self.outputs[0] 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')

View file

@ -18,7 +18,7 @@ from ....tx import unsigned as TxBase
from ....util import msg, msg_r, die from ....util import msg, msg_r, die
from ....obj import CoinTxID, ETHNonce, Int, HexStr from ....obj import CoinTxID, ETHNonce, Int, HexStr
from ....addr import CoinAddr, ContractAddr from ....addr import CoinAddr, ContractAddr
from ..contract import Token from ..contract import Token, THORChainRouterContract
from .completed import Completed, TokenCompleted from .completed import Completed, TokenCompleted
class Unsigned(Completed, TxBase.Unsigned): class Unsigned(Completed, TxBase.Unsigned):
@ -110,14 +110,32 @@ class TokenUnsigned(TokenCompleted, Unsigned):
o['token_addr'] = ContractAddr(self.proto, d['token_addr']) o['token_addr'] = ContractAddr(self.proto, d['token_addr'])
o['decimals'] = Int(d['decimals']) o['decimals'] = Int(d['decimals'])
o['token_to'] = o['to'] o['token_to'] = o['to']
if self.is_swap:
o['expiry'] = Int(d['expiry'])
async def do_sign(self, o, wif): async def do_sign(self, o, wif):
t = Token(self.cfg, self.proto, o['token_addr'], decimals=o['decimals']) 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) 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']) res = await t.txsign(tx_in, wif, o['from'], chain_id=o['chainId'])
self.serialized = res.txhex self.serialized = res.txhex
self.coin_txid = res.txid 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): class AutomountUnsigned(TxBase.AutomountUnsigned, Unsigned):
pass pass

View file

@ -24,9 +24,44 @@ class THORChainSwapAsset(SwapAsset):
'ETH': _ad('Ethereum', 'ETH', None, 'e', True), 'ETH': _ad('Ethereum', 'ETH', None, 'e', True),
'DOGE': _ad('Dogecoin', 'DOGE', None, 'd', False), 'DOGE': _ad('Dogecoin', 'DOGE', None, 'd', False),
'RUNE': _ad('Rune (THORChain)', 'RUNE', 'THOR.RUNE', 'r', 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') unsupported = ('DOGE', 'RUNE')

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,11 +12,17 @@
test.cmdtest_d.ethswap: Ethereum swap tests for the cmdtest.py test suite 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.cfg import Config
from mmgen.util import rmsg, die
from mmgen.protocol import init_proto 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.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 .httpd.thornode import ThornodeServer
from .regtest import CmdTestRegtest from .regtest import CmdTestRegtest
@ -42,6 +48,38 @@ class CmdTestEthSwapMethods:
async def token_deploy_c(self): async def token_deploy_c(self):
return await self._token_deploy_token(num=1) 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): def token_fund_user(self):
return self._token_transfer_ops( return self._token_transfer_ops(
op = 'fund_user', op = 'fund_user',
@ -54,9 +92,40 @@ class CmdTestEthSwapMethods:
def token_addrimport(self): def token_addrimport(self):
return self._token_addrimport('token_addr1', '1-5', expect='5/5') 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): def token_bal1(self):
return self._token_bal_check(pat=rf'{dfl_sid}:E:1\s+{self.token_fund_amt}\s') 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): class CmdTestEthSwap(CmdTestSwapMethods, CmdTestRegtest):
'Ethereum swap operations' 'Ethereum swap operations'
@ -117,9 +186,12 @@ class CmdTestEthSwap(CmdTestSwapMethods, CmdTestRegtest):
('eth_token_deploy_a', ''), ('eth_token_deploy_a', ''),
('eth_token_deploy_b', ''), ('eth_token_deploy_b', ''),
('eth_token_deploy_c', ''), ('eth_token_deploy_c', ''),
('eth_token_compile_router', ''),
('eth_token_deploy_router', ''),
('eth_token_fund_user', ''), ('eth_token_fund_user', ''),
('eth_token_addrgen', ''), ('eth_token_addrgen', ''),
('eth_token_addrimport', ''), ('eth_token_addrimport', ''),
('eth_token_addrimport_inbound', ''),
('eth_token_bal1', ''), ('eth_token_bal1', ''),
), ),
'token_swap': ( 'token_swap': (
@ -153,10 +225,28 @@ class CmdTestEthSwap(CmdTestSwapMethods, CmdTestRegtest):
('eth_bal2', ''), ('eth_bal2', ''),
), ),
'eth_token_swap': ( 'eth_token_swap': (
'swap operations (ETH <-> MM1)', 'swap operations (ETH -> ERC20, ERC20 -> BTC, ERC20 -> ETH)',
('eth_swaptxcreate3', ''), # ETH -> MM1
('eth_swaptxsign3', ''), ('eth_swaptxcreate3', ''),
('eth_swaptxsend3', ''), ('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_a', 'deploying ERC20 token MM1 (SafeMath)'),
('token_deploy_b', 'deploying ERC20 token MM1 (Owned)'), ('token_deploy_b', 'deploying ERC20 token MM1 (Owned)'),
('token_deploy_c', 'deploying ERC20 token MM1 (Token)'), ('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_fund_user', 'transferring token funds from dev to user'),
('token_addrgen', 'generating token addresses'), ('token_addrgen', 'generating token addresses'),
('token_addrimport', 'importing token addresses using token address (MM1)'), ('token_addrimport', 'importing token addresses using token address (MM1)'),
('token_addrimport_inbound', 'importing THORNode inbound token address'),
('token_bal1', 'the token balance'), ('token_bal1', 'the token balance'),
# eth_token_swap: # eth_token_swap:
@ -276,6 +369,25 @@ class CmdTestEthSwapEth(CmdTestEthSwapMethods, CmdTestSwapMethods, CmdTestEthdev
('swaptxcreate3', 'creating an ETH->MM1 swap transaction'), ('swaptxcreate3', 'creating an ETH->MM1 swap transaction'),
('swaptxsign3', 'signing the transaction'), ('swaptxsign3', 'signing the transaction'),
('swaptxsend3', 'sending 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): 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']) t = self._swaptxcreate(['ETH', '8.765', 'ETH.MM1', f'{dfl_sid}:E:5'])
return self._swaptxcreate_ui_common(t) 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): def swaptxsign1(self):
return self._swaptxsign() return self._swaptxsign()
@ -304,8 +424,28 @@ class CmdTestEthSwapEth(CmdTestEthSwapMethods, CmdTestSwapMethods, CmdTestEthdev
self.mining_delay() self.mining_delay()
return self._swaptxsend(add_opts=['--verbose', '--status'], status=True) return self._swaptxsend(add_opts=['--verbose', '--status'], status=True)
swaptxsign3 = swaptxsign1 def swaptxmemo4(self):
swaptxsend3 = swaptxsend1 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): def bal1(self):
return self.bal('swap1') return self.bal('swap1')

View file

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

View file

@ -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' Ctrl_U = '\x15'
eth_inbound_addr = (28 * '0') + 'feedbeefcafe'
thorchain_router_addr_file = 'test/data_dir/thorchain_router_addr'
def ok_msg(): def ok_msg():
if cfg.profile: if cfg.profile:
return return

View file

@ -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}' 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(): def init_coverage():
coverdir = os.path.join('test', 'trace') coverdir = os.path.join('test', 'trace')
acc_file = os.path.join('test', 'trace.acc') acc_file = os.path.join('test', 'trace.acc')

View file

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

View file

@ -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 one or more lines are too long