From f0563031de5a726eba5e9a66a597a234774f3393 Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Sun, 23 Mar 2025 09:26:45 +0300 Subject: [PATCH] Ethereum THORChain swaps Only native ETH supported for now. Work on ERC20 token swaps is underway. Sample create-sign-send workflow for a BTC->ETH swap (assumes offline autosigning is set up): $ mmgen-swaptxcreate --autosign BTC 0.12345 ETH remove device - insert - wait for signing - remove - insert $ mmgen-txsend --autosign Create step for ETH->BTC swap: $ mmgen-swaptxcreate --autosign ETH 5.4321 BTC For more information, see: $ mmgen-swaptxcreate --help Testing: $ test/cmdtest.py ethswap --- .../commands/command-help-swaptxcreate.md | 12 +- doc/wiki/commands/command-help-swaptxdo.md | 12 +- mmgen/data/version | 2 +- mmgen/help/swaptxcreate.py | 10 +- mmgen/proto/btc/params.py | 1 + mmgen/proto/eth/params.py | 2 + mmgen/proto/eth/tw/addresses.py | 6 +- mmgen/proto/eth/tx/completed.py | 5 + mmgen/proto/eth/tx/info.py | 7 +- mmgen/proto/eth/tx/new.py | 25 +- mmgen/proto/eth/tx/new_swap.py | 38 +++ mmgen/proto/eth/tx/status.py | 2 +- mmgen/proto/eth/tx/unsigned.py | 3 +- mmgen/protocol.py | 1 + mmgen/swap/proto/thorchain/__init__.py | 2 + mmgen/swap/proto/thorchain/memo.py | 14 +- mmgen/swap/proto/thorchain/thornode.py | 7 +- mmgen/tx/info.py | 4 +- mmgen/tx/new.py | 2 + mmgen/tx/new_swap.py | 5 +- test/cmdtest_d/cfg.py | 4 + test/cmdtest_d/ct_ethdev.py | 2 +- test/cmdtest_d/ct_ethswap.py | 219 ++++++++++++++++++ test/cmdtest_d/ct_swap.py | 4 +- test/cmdtest_d/httpd/thornode.py | 9 +- test/cmdtest_d/runner.py | 9 +- test/modtest_d/tx.py | 4 + 27 files changed, 376 insertions(+), 35 deletions(-) create mode 100755 mmgen/proto/eth/tx/new_swap.py create mode 100755 test/cmdtest_d/ct_ethswap.py diff --git a/doc/wiki/commands/command-help-swaptxcreate.md b/doc/wiki/commands/command-help-swaptxcreate.md index fac0e94e..c3ce9803 100644 --- a/doc/wiki/commands/command-help-swaptxcreate.md +++ b/doc/wiki/commands/command-help-swaptxcreate.md @@ -64,8 +64,11 @@ By default, the change and destination addresses are chosen automatically by finding the lowest-indexed unused addresses of the preferred address types in - the send and receive tracking wallets. Types ‘B’, ‘S’ and ‘C’ (see ADDRESS - TYPES below) are searched in that order for unused addresses. + the send and receive tracking wallets. For Bitcoin and forks, types ‘B’, + ‘S’ and ‘C’ (see ADDRESS TYPES below) are searched in that order for unused + addresses. Note that sending to an unused address may be undesirable for + Ethereum, where address (i.e. account) reuse is the norm. In that case, the + user should specify a destination address on the command line. If the wallet contains eligible unused addresses with multiple Seed IDs, the user will be presented with a list of the lowest-indexed addresses of @@ -74,7 +77,8 @@ Change and destination addresses may also be specified manually with the CHG_ADDR and ADDR arguments. These may be given as full MMGen IDs or in the form ADDRTYPE_CODE or SEED_ID:ADDRTYPE_CODE (see EXAMPLES below and the - ‘mmgen-txcreate’ help screen for details). + ‘mmgen-txcreate’ help screen for details). For Ethereum, the CHG_ADDR + argument is not supported. While discouraged, sending change or swapping to non-wallet addresses is also supported, in which case the signing script (‘mmgen-txsign’ or ‘mmgen- @@ -196,5 +200,5 @@ $ mmgen-tool --coin=bch --bch-rpc-host=gemini twview minconf=0 - MMGEN v15.1.dev18 March 2025 MMGEN-SWAPTXCREATE(1) + MMGEN v15.1.dev23 March 2025 MMGEN-SWAPTXCREATE(1) ``` diff --git a/doc/wiki/commands/command-help-swaptxdo.md b/doc/wiki/commands/command-help-swaptxdo.md index f86d0838..a1f533bd 100644 --- a/doc/wiki/commands/command-help-swaptxdo.md +++ b/doc/wiki/commands/command-help-swaptxdo.md @@ -85,8 +85,11 @@ By default, the change and destination addresses are chosen automatically by finding the lowest-indexed unused addresses of the preferred address types in - the send and receive tracking wallets. Types ‘B’, ‘S’ and ‘C’ (see ADDRESS - TYPES below) are searched in that order for unused addresses. + the send and receive tracking wallets. For Bitcoin and forks, types ‘B’, + ‘S’ and ‘C’ (see ADDRESS TYPES below) are searched in that order for unused + addresses. Note that sending to an unused address may be undesirable for + Ethereum, where address (i.e. account) reuse is the norm. In that case, the + user should specify a destination address on the command line. If the wallet contains eligible unused addresses with multiple Seed IDs, the user will be presented with a list of the lowest-indexed addresses of @@ -95,7 +98,8 @@ Change and destination addresses may also be specified manually with the CHG_ADDR and ADDR arguments. These may be given as full MMGen IDs or in the form ADDRTYPE_CODE or SEED_ID:ADDRTYPE_CODE (see EXAMPLES below and the - ‘mmgen-txcreate’ help screen for details). + ‘mmgen-txcreate’ help screen for details). For Ethereum, the CHG_ADDR + argument is not supported. While discouraged, sending change or swapping to non-wallet addresses is also supported, in which case the signing script (‘mmgen-txsign’ or ‘mmgen- @@ -260,5 +264,5 @@ $ mmgen-tool --coin=bch --bch-rpc-host=gemini twview minconf=0 - MMGEN v15.1.dev18 March 2025 MMGEN-SWAPTXDO(1) + MMGEN v15.1.dev23 March 2025 MMGEN-SWAPTXDO(1) ``` diff --git a/mmgen/data/version b/mmgen/data/version index 623c0fa5..ee47b5d4 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -15.1.dev22 +15.1.dev23 diff --git a/mmgen/help/swaptxcreate.py b/mmgen/help/swaptxcreate.py index 86efe3c3..bf1f0a25 100755 --- a/mmgen/help/swaptxcreate.py +++ b/mmgen/help/swaptxcreate.py @@ -41,8 +41,11 @@ inputs will be swapped. By default, the change and destination addresses are chosen automatically by finding the lowest-indexed unused addresses of the preferred address types in -the send and receive tracking wallets. Types ‘B’, ‘S’ and ‘C’ (see ADDRESS -TYPES below) are searched in that order for unused addresses. +the send and receive tracking wallets. For Bitcoin and forks, types ‘B’, +‘S’ and ‘C’ (see ADDRESS TYPES below) are searched in that order for unused +addresses. Note that sending to an unused address may be undesirable for +Ethereum, where address (i.e. account) reuse is the norm. In that case, the +user should specify a destination address on the command line. If the wallet contains eligible unused addresses with multiple Seed IDs, the user will be presented with a list of the lowest-indexed addresses of @@ -51,7 +54,8 @@ preferred type for each Seed ID and prompted to choose from among them. Change and destination addresses may also be specified manually with the CHG_ADDR and ADDR arguments. These may be given as full MMGen IDs or in the form ADDRTYPE_CODE or SEED_ID:ADDRTYPE_CODE (see EXAMPLES below and the -‘mmgen-txcreate’ help screen for details). +‘mmgen-txcreate’ help screen for details). For Ethereum, the CHG_ADDR +argument is not supported. While discouraged, sending change or swapping to non-wallet addresses is also supported, in which case the signing script (‘mmgen-txsign’ or ‘mmgen- diff --git a/mmgen/proto/btc/params.py b/mmgen/proto/btc/params.py index 02e0dbf0..036d5588 100755 --- a/mmgen/proto/btc/params.py +++ b/mmgen/proto/btc/params.py @@ -53,6 +53,7 @@ class mainnet(CoinProtocol.Secp256k1): # chainparams.cpp start_subsidy = 50 max_int = 0xffffffff max_op_return_data_len = 80 + address_reuse_ok = False coin_cfg_opts = ( 'daemon_id', diff --git a/mmgen/proto/eth/params.py b/mmgen/proto/eth/params.py index 59c20b18..1219b2a6 100755 --- a/mmgen/proto/eth/params.py +++ b/mmgen/proto/eth/params.py @@ -37,6 +37,8 @@ class mainnet(CoinProtocol.DummyWIF, CoinProtocol.Secp256k1): base_coin = 'ETH' avg_bdi = 15 decimal_prec = 36 + address_reuse_ok = True + is_evm = True # https://www.chainid.dev chain_ids = { diff --git a/mmgen/proto/eth/tw/addresses.py b/mmgen/proto/eth/tw/addresses.py index 18a272be..e1cc7546 100755 --- a/mmgen/proto/eth/tw/addresses.py +++ b/mmgen/proto/eth/tw/addresses.py @@ -65,8 +65,7 @@ class EthereumTwAddresses(TwAddresses, EthereumTwView, EthereumTwRPC): async def get_rpc_data(self): - amt0 = self.proto.coin_amt('0') - self.total = amt0 + self.total = self.proto.coin_amt('0') self.minconf = None addrs = {} @@ -75,7 +74,8 @@ class EthereumTwAddresses(TwAddresses, EthereumTwView, EthereumTwRPC): addrs[e.label.mmid] = { 'addr': e.coinaddr, 'amt': bal, - 'recvd': amt0, + 'recvd': bal, # since it’s nearly impossible to empty an Ethereum account, + # we consider a used account to be any account with a balance 'confs': 0, 'lbl': e.label} self.total += bal diff --git a/mmgen/proto/eth/tx/completed.py b/mmgen/proto/eth/tx/completed.py index a66080db..df88998d 100755 --- a/mmgen/proto/eth/tx/completed.py +++ b/mmgen/proto/eth/tx/completed.py @@ -18,6 +18,11 @@ from .base import Base, TokenBase class Completed(Base, TxBase.Completed): fn_fee_unit = 'Mwei' + def get_tx_usr_data(self): + o = self.txobj + if o['to'] and o['data']: + return bytes.fromhex(o['data']) + @property def send_amt(self): return self.outputs[0].amt if self.outputs else self.proto.coin_amt('0') diff --git a/mmgen/proto/eth/tx/info.py b/mmgen/proto/eth/tx/info.py index 2a5e726c..c455ae86 100755 --- a/mmgen/proto/eth/tx/info.py +++ b/mmgen/proto/eth/tx/info.py @@ -49,7 +49,10 @@ class TxInfo(TxInfo): t = to_addr.hl(0) if to_addr else blue('None'), a = t['amt'].hl(), n = t['nonce'].hl(), - d = '{}... ({} bytes)'.format(td[:40], len(td)//2) if len(td) else blue('None'), + d = ( + blue('None') if not td + else pink(bytes.fromhex(td).decode()) if tx.is_swap + else '{}... ({} bytes)'.format(td[:40], len(td)//2)), c = tx.proto.dcoin if len(tx.outputs) else '', g = yellow(tx.pretty_fmt_fee(t['gasPrice'].to_unit('Gwei'))), G = yellow(tx.pretty_fmt_fee(t['startGas'].to_unit('Kwei'))), @@ -65,7 +68,7 @@ class TxInfo(TxInfo): ) def format_verbose_footer(self): - if self.tx.txobj['data']: + if self.tx.txobj['data'] and not self.tx.is_swap: from ..contract import parse_abi return '\nParsed contract data: ' + pp_fmt(parse_abi(self.tx.txobj['data'])) else: diff --git a/mmgen/proto/eth/tx/new.py b/mmgen/proto/eth/tx/new.py index a18d7c6f..3c7f2671 100755 --- a/mmgen/proto/eth/tx/new.py +++ b/mmgen/proto/eth/tx/new.py @@ -28,6 +28,8 @@ class New(Base, TxBase.New): no_chg_msg = 'Warning: Transaction leaves account with zero balance' usr_fee_prompt = 'Enter transaction fee or gas price: ' msg_insufficient_funds = 'Account balance insufficient to fund this transaction ({} {} needed)' + byte_cost = 68 # https://ethereum.stackexchange.com/questions/39401/ + # how-do-you-calculate-gas-limit-for-transaction-with-data-in-ethereum def __init__(self, *args, **kwargs): @@ -66,7 +68,7 @@ class New(Base, TxBase.New): async def create_serialized(self, *, locktime=None): assert len(self.inputs) == 1, 'Transaction has more than one input!' o_num = len(self.outputs) - o_ok = 0 if self.usr_contract_data else 1 + o_ok = 0 if self.usr_contract_data and not self.is_swap else 1 assert o_num == o_ok, f'Transaction has {o_num} output{suf(o_num)} (should have {o_ok})' await self.make_txobj() odict = {k:v if v is None else str(v) for k, v in self.txobj.items() if k != 'token_to'} @@ -78,10 +80,26 @@ class New(Base, TxBase.New): 'update_txid() must be called only when self.serialized is not hex data') self.txid = MMGenTxID(make_chksum_6(self.serialized).upper()) + def set_gas_with_data(self, data): + self.gas = self.proto.coin_amt(self.dfl_gas + self.byte_cost * len(data), from_unit='wei') + + # one-shot method + def adj_gas_with_extra_data_len(self, extra_data_len): + if not hasattr(self, '_gas_adjusted'): + self.gas += self.proto.coin_amt(self.byte_cost * extra_data_len, from_unit='wei') + self._gas_adjusted = True + async def process_cmdline_args(self, cmd_args, ad_f, ad_w): lc = len(cmd_args) + if lc == 2 and self.is_swap: + data_arg = cmd_args.pop() + lc = 1 + assert data_arg.startswith('data:'), f'{data_arg}: invalid data arg (must start with "data:")' + self.usr_contract_data = data_arg.removeprefix('data:').encode() + self.set_gas_with_data(self.usr_contract_data) + if lc == 0 and self.usr_contract_data and 'Token' not in self.name: return @@ -91,9 +109,10 @@ class New(Base, TxBase.New): a = self.parse_cmdline_arg(self.proto, cmd_args[0], ad_f, ad_w) self.add_output( - coinaddr = a.addr, + coinaddr = None if a.is_vault else a.addr, amt = self.proto.coin_amt(a.amt or '0'), - is_chg = not a.amt) + is_chg = not a.amt, + is_vault = a.is_vault) self.add_mmaddrs_to_outputs(ad_f, ad_w) diff --git a/mmgen/proto/eth/tx/new_swap.py b/mmgen/proto/eth/tx/new_swap.py new file mode 100755 index 00000000..448cc034 --- /dev/null +++ b/mmgen/proto/eth/tx/new_swap.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# +# MMGen Wallet, a terminal-based cryptocurrency wallet +# Copyright (C)2013-2025 The MMGen Project +# Licensed under the GNU General Public License, Version 3: +# https://www.gnu.org/licenses +# Public project repositories: +# https://github.com/mmgen/mmgen-wallet +# https://gitlab.com/mmgen/mmgen-wallet + +""" +proto.eth.tx.new_swap: Ethereum new swap transaction class +""" + +from ....tx.new_swap import NewSwap as TxNewSwap +from ....tx.new_swap import get_swap_proto_mod +from .new import New + +class NewSwap(New, TxNewSwap): + desc = 'Ethereum swap transaction' + + def update_data_output(self, trade_limit): + sp = get_swap_proto_mod(self.swap_proto) + parsed_memo = sp.data.parse(self.usr_contract_data.decode()) + memo = sp.data( + self.recv_proto, + self.recv_proto.coin_addr(parsed_memo.address), + trade_limit = trade_limit) + self.usr_contract_data = str(memo).encode() + self.set_gas_with_data(self.usr_contract_data) + + @property + def vault_idx(self): + return 0 + + @property + def vault_output(self): + return self.outputs[0] diff --git a/mmgen/proto/eth/tx/status.py b/mmgen/proto/eth/tx/status.py index 4a554d7d..2ccbc27b 100755 --- a/mmgen/proto/eth/tx/status.py +++ b/mmgen/proto/eth/tx/status.py @@ -60,7 +60,7 @@ class Status(TxBase.Status): from ....color import cyan msg('{}\n{}'.format(cyan('TRANSACTION RECEIPT'), pp_fmt(ret.rx))) if ret: - if tx.txobj['data']: + if tx.txobj['data'] and not tx.is_swap: cd = capfirst(tx.contract_desc) if ret.exec_status == 0: msg(f'{cd} failed to execute!') diff --git a/mmgen/proto/eth/tx/unsigned.py b/mmgen/proto/eth/tx/unsigned.py index 790ee86e..60483b2a 100755 --- a/mmgen/proto/eth/tx/unsigned.py +++ b/mmgen/proto/eth/tx/unsigned.py @@ -60,7 +60,8 @@ class Unsigned(Completed, TxBase.Unsigned): if o['data']: # contract-creating transaction if o['to']: - raise ValueError('contract-creating transaction cannot have to-address') + if not self.is_swap: + raise ValueError('contract-creating transaction cannot have to-address') else: self.txobj['token_addr'] = TokenAddr(self.proto, etx.creates.hex()) diff --git a/mmgen/protocol.py b/mmgen/protocol.py index 4b5a4e38..402b6ebd 100755 --- a/mmgen/protocol.py +++ b/mmgen/protocol.py @@ -55,6 +55,7 @@ class CoinProtocol(MMGenObject): base_coin = None is_fork_of = None chain_names = None + is_evm = False networks = ('mainnet', 'testnet', 'regtest') decimal_prec = 28 _set_ok = ('tokensym',) diff --git a/mmgen/swap/proto/thorchain/__init__.py b/mmgen/swap/proto/thorchain/__init__.py index 9c98712b..ddd6aa16 100755 --- a/mmgen/swap/proto/thorchain/__init__.py +++ b/mmgen/swap/proto/thorchain/__init__.py @@ -23,11 +23,13 @@ class params: 'BTC': 'Bitcoin', 'LTC': 'Litecoin', 'BCH': 'Bitcoin Cash', + 'ETH': 'Ethereum', }, 'receive': { 'BTC': 'Bitcoin', 'LTC': 'Litecoin', 'BCH': 'Bitcoin Cash', + 'ETH': 'Ethereum', } } diff --git a/mmgen/swap/proto/thorchain/memo.py b/mmgen/swap/proto/thorchain/memo.py index 0dbb3499..e34317fe 100755 --- a/mmgen/swap/proto/thorchain/memo.py +++ b/mmgen/swap/proto/thorchain/memo.py @@ -12,7 +12,7 @@ swap.proto.thorchain.memo: THORChain swap protocol memo class """ -from ....util import die +from ....util import die, is_hex_str from ....amt import UniAmt from . import name as proto_name @@ -42,6 +42,8 @@ class Memo: 'THOR.RUNE': 'r', } + evm_chains = ('ETH', 'AVAX', 'BSC', 'BASE') + function_abbrevs = { 'SWAP': '=', } @@ -95,6 +97,11 @@ class Memo: address = get_item('address') + if chain in cls.evm_chains: + assert address.startswith('0x'), f'{address}: address does not start with ‘0x’' + assert len(address) == 42, f'{address}: address has incorrect length ({len(address)} != 42)' + address = address.removeprefix('0x') + desc = 'trade_limit/stream_interval/stream_quantity' lsq = get_item(desc) @@ -135,6 +142,11 @@ class Memo: self.addr = addr.views[addr.view_pref] assert not ':' in self.addr # colon is record separator, so address mustn’t contain one + if self.chain in self.evm_chains: + assert len(self.addr) == 40, f'{self.addr}: address has incorrect length ({len(self.addr)} != 40)' + assert is_hex_str(self.addr), f'{self.addr}: address is not a hexadecimal string' + self.addr = '0x' + self.addr + def __str__(self): from . import ExpInt4 try: diff --git a/mmgen/swap/proto/thorchain/thornode.py b/mmgen/swap/proto/thorchain/thornode.py index d67d8735..2414f994 100755 --- a/mmgen/swap/proto/thorchain/thornode.py +++ b/mmgen/swap/proto/thorchain/thornode.py @@ -98,6 +98,8 @@ class Thornode: {lblcolor('Trade limit:')} {tl_rounded.hl()} {out_coin} """ + mcolor( f'({abs(1 - ratio) * 100:0.2f}% {direction} expected amount)') tx_size_adj = len(e.enc) - 1 + if tx.proto.is_evm: + tx.adj_gas_with_extra_data_len(len(e.enc) - 1) # one-shot method, no-op if repeated else: trade_limit_disp = '' tx_size_adj = 0 @@ -105,7 +107,7 @@ class Thornode: def get_estimated_fee(): return tx.feespec2abs( fee_arg = d['recommended_gas_rate'] + gas_unit_data[gas_unit].code, - tx_size = tx.estimate_size() + tx_size_adj) + tx_size = None if tx.proto.is_evm else tx.estimate_size() + tx_size_adj) _amount_in_label = 'Amount in:' if deduct_est_fee: @@ -145,7 +147,8 @@ class Thornode: @property def inbound_address(self): - return self.data['inbound_address'] + addr = self.data['inbound_address'] + return addr.removeprefix('0x') if self.tx.proto.is_evm else addr @property def rel_fee_hint(self): diff --git a/mmgen/tx/info.py b/mmgen/tx/info.py index 8afdc493..632cc49d 100755 --- a/mmgen/tx/info.py +++ b/mmgen/tx/info.py @@ -74,7 +74,9 @@ class TxInfo: if tx.is_swap: from ..swap.proto.thorchain.memo import Memo, proto_name - data = tx.data_output.data + data = ( + (tx.usr_contract_data or bytes.fromhex(tx.txobj['data'])) if tx.proto.is_evm + else tx.data_output.data) if Memo.is_partial_memo(data): p = Memo.parse(data.decode('ascii')) yield ' {} {}\n'.format(magenta('DEX Protocol:'), blue(proto_name)) diff --git a/mmgen/tx/new.py b/mmgen/tx/new.py index bc999373..608da1fb 100755 --- a/mmgen/tx/new.py +++ b/mmgen/tx/new.py @@ -306,6 +306,8 @@ class New(Base): die(1, 'Exiting at user request') async def warn_addr_used(self, proto, chg, desc): + if proto.address_reuse_ok: + return from ..tw.addresses import TwAddresses if (await TwAddresses(self.cfg, proto, get_data=True)).is_used(chg.addr): from ..ui import keypress_confirm diff --git a/mmgen/tx/new_swap.py b/mmgen/tx/new_swap.py index dcf2104d..e10e6d18 100755 --- a/mmgen/tx/new_swap.py +++ b/mmgen/tx/new_swap.py @@ -97,7 +97,7 @@ class NewSwap(New): arg = get_arg() # arg 3: chg_spec (change address spec) - if args.send_amt: + if args.send_amt and not self.proto.is_evm: if not arg in sp.params.coins['receive']: # is change arg args.chg_spec = arg arg = get_arg() @@ -119,7 +119,7 @@ class NewSwap(New): chg_output = ( await self.get_swap_output(self.proto, args.chg_spec, addrfiles, 'change address') - if args.send_amt else None) + if args.send_amt and not self.proto.is_evm else None) if chg_output: self.check_addr_is_wallet_addr( @@ -145,6 +145,7 @@ class NewSwap(New): self.swap_recv_addr_mmid = recv_output.mmid return ( + [f'vault,{args.send_amt}', f'data:{memo}'] if args.send_amt and self.proto.is_evm else [f'vault,{args.send_amt}', chg_output.mmid, f'data:{memo}'] if args.send_amt else ['vault', f'data:{memo}']) diff --git a/test/cmdtest_d/cfg.py b/test/cmdtest_d/cfg.py index d0503e25..d7beb601 100755 --- a/test/cmdtest_d/cfg.py +++ b/test/cmdtest_d/cfg.py @@ -39,6 +39,7 @@ cmd_groups_dfl = { 'autosign_eth': ('CmdTestAutosignETH', {'modname': 'automount_eth'}), 'regtest': ('CmdTestRegtest', {}), 'swap': ('CmdTestSwap', {}), + 'ethswap': ('CmdTestEthSwap', {}), # 'chainsplit': ('CmdTestChainsplit', {}), 'ethdev': ('CmdTestEthdev', {}), 'xmrwallet': ('CmdTestXMRWallet', {}), @@ -46,6 +47,7 @@ cmd_groups_dfl = { } cmd_groups_extra = { + 'ethswap_eth': ('CmdTestEthSwapEth', {'modname': 'ethswap'}), 'dev': ('CmdTestDev', {'modname': 'misc'}), 'regtest_legacy': ('CmdTestRegtestBDBWallet', {'modname': 'regtest'}), 'autosign_btc': ('CmdTestAutosignBTC', {'modname': 'autosign'}), @@ -241,6 +243,8 @@ cfgs = { # addr_idx_lists (except 31, 32, 33, 34) must contain exactly 8 address '39': {}, # xmr_autosign '40': {}, # cfgfile '41': {}, # opts + '47': {}, # ethswap + '48': {}, # ethswap_eth '49': {}, # autosign_automount '59': {}, # autosign_eth '99': {}, # dummy diff --git a/test/cmdtest_d/ct_ethdev.py b/test/cmdtest_d/ct_ethdev.py index 084d1fc5..4dc4e350 100755 --- a/test/cmdtest_d/ct_ethdev.py +++ b/test/cmdtest_d/ct_ethdev.py @@ -123,7 +123,7 @@ coin = cfg.coin class CmdTestEthdev(CmdTestBase, CmdTestShared): 'Ethereum transacting, token deployment and tracking wallet operations' networks = ('eth', 'etc') - passthru_opts = ('coin', 'daemon_id', 'http_timeout', 'rpc_backend') + passthru_opts = ('coin', 'daemon_id', 'eth_daemon_id', 'http_timeout', 'rpc_backend') tmpdir_nums = [22] color = True menu_prompt = 'efresh balance:\b' diff --git a/test/cmdtest_d/ct_ethswap.py b/test/cmdtest_d/ct_ethswap.py new file mode 100755 index 00000000..e209a398 --- /dev/null +++ b/test/cmdtest_d/ct_ethswap.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +# +# MMGen Wallet, a terminal-based cryptocurrency wallet +# Copyright (C)2013-2025 The MMGen Project +# Licensed under the GNU General Public License, Version 3: +# https://www.gnu.org/licenses +# Public project repositories: +# https://github.com/mmgen/mmgen-wallet +# https://gitlab.com/mmgen/mmgen-wallet + +""" +test.cmdtest_d.ct_ethswap: Ethereum swap tests for the cmdtest.py test suite +""" + +from mmgen.wallet.mmgen import wallet as MMGenWallet +from mmgen.cfg import Config +from mmgen.protocol import init_proto + +from .runner import CmdTestRunner + +from .common import dfl_words_file, dfl_seed_id, rt_pw + +from .httpd.thornode import ThornodeServer +from .ct_regtest import CmdTestRegtest +from .ct_swap import CmdTestSwapMethods +from .ct_ethdev import CmdTestEthdev + +thornode_server = ThornodeServer() + +method_template = """ +def {name}(self): + self.spawn(log_only=True) + return ethswap_eth.run_test("{eth_name}", sub=True) +""" + +class CmdTestEthSwap(CmdTestRegtest, CmdTestSwapMethods): + 'Ethereum swap operations' + + bdb_wallet = True + tmpdir_nums = [47] + networks = ('btc',) + passthru_opts = ('coin', 'rpc_backend', 'eth_daemon_id') + + cmd_group_in = ( + ('setup', 'regtest (Bob and Alice) mode setup'), + ('eth_setup', 'Ethereum devnet setup'), + ('subgroup.init', []), + ('subgroup.fund', ['init']), + ('subgroup.eth_init', []), + ('subgroup.eth_fund', ['eth_init']), + ('subgroup.swap', ['fund', 'eth_fund']), + ('subgroup.eth_swap', ['fund', 'eth_fund']), + ('stop', 'stopping regtest daemon'), + ('eth_stop', 'stopping Ethereum daemon'), + ('thornode_server_stop', 'stopping the Thornode server'), + ) + cmd_subgroups = { + 'init': ( + 'creating Bob’s MMGen wallet and tracking wallet', + ('walletconv_bob', 'wallet creation (Bob)'), + ('addrgen_bob', 'address generation (Bob)'), + ('addrimport_bob', 'importing Bob’s addresses'), + ), + 'fund': ( + 'funding Bob’s wallet', + ('bob_import_miner_addr', 'importing miner’s coinbase addr into Bob’s wallet'), + ('fund_bob', 'funding Bob’s wallet'), + ('generate', 'mining a block'), + ('bob_bal1', 'Bob’s balance'), + ), + 'eth_init': ( + 'initializing the ETH tracking wallet', + ('eth_addrgen', ''), + ('eth_addrimport', ''), + ('eth_addrimport_dev_addr', ''), + ('eth_fund_dev_address', ''), + ), + 'eth_fund': ( + 'funding the ETH tracking wallet', + ('eth_txcreate1', ''), + ('eth_txsign1', ''), + ('eth_txsend1', ''), + ('eth_bal1', ''), + ), + 'swap': ( + 'swap operations (BTC -> ETH)', + ('swaptxcreate1', 'creating a BTC->ETH swap transaction'), + ('swaptxcreate2', 'creating a BTC->ETH swap transaction (used account)'), + ('swaptxsign1', 'signing the swap transaction'), + ('swaptxsend1', 'sending the swap transaction'), + ('generate', 'generating a block'), + ('bob_bal2', 'Bob’s balance'), + ), + 'eth_swap': ( + 'swap operations (ETH -> BTC)', + ('eth_swaptxcreate1', ''), + ('eth_swaptxcreate2', ''), + ('eth_swaptxsign1', ''), + ('eth_swaptxsend1', ''), + ('eth_swaptxstatus1', ''), + ('eth_bal2', ''), + ), + } + + eth_tests = [c[0] for v in tuple(cmd_subgroups.values()) + (cmd_group_in,) + for c in v if isinstance(c, tuple) and c[0].startswith('eth_')] + + exec(''.join(method_template.format(name=k, eth_name=k.removeprefix('eth_')) for k in eth_tests)) + + def __init__(self, cfg, trunner, cfgs, spawn): + + super().__init__(cfg, trunner, cfgs, spawn) + + if not trunner: + return + + global ethswap_eth + cfg = Config({ + '_clone': trunner.cfg, + 'proto': init_proto(cfg, network_id='eth'), + 'resume': None, + 'resume_after': None, + 'exit_after': None, + 'eth_daemon_id': trunner.cfg.eth_daemon_id, + 'log': None, + 'coin': 'eth'}) + t = trunner + ethswap_eth = CmdTestRunner(cfg, t.repo_root, t.data_dir, t.trash_dir, t.trash_dir2) + ethswap_eth.init_group('ethswap_eth') + + thornode_server.start() + + def walletconv_bob(self): + t = self.spawn( + 'mmgen-walletconv', + ['--bob', '--quiet', '-r0', f'-d{self.cfg.data_dir}/regtest/bob', dfl_words_file], + no_passthru_opts = ['coin', 'eth_daemon_id']) + t.hash_preset(MMGenWallet.desc, '1') + t.passphrase_new('new '+MMGenWallet.desc, rt_pw) + t.label() + return t + + def swaptxcreate1(self): + self.get_file_with_ext('rawtx', delete_all=True) + t = self._swaptxcreate(['BTC', '8.765', 'ETH']) + t.expect('OK? (Y/n): ', 'y') + t.expect(':E:2') + t.expect('OK? (Y/n): ', 'y') + return self._swaptxcreate_ui_common(t) + + def swaptxcreate2(self): + self.get_file_with_ext('rawtx', delete_all=True) + t = self._swaptxcreate(['BTC', '8.765', 'ETH', f'{dfl_seed_id}:E:1']) + t.expect('OK? (Y/n): ', 'y') + return self._swaptxcreate_ui_common(t) + + def swaptxsign1(self): + return self._swaptxsign() + + def swaptxsend1(self): + return self._swaptxsend() + + def bob_bal2(self): + return self._user_bal_cli('bob', chk='491.23498314') + + def thornode_server_stop(self): + self.spawn(msg_only=True) + thornode_server.stop() + return 'ok' + +class CmdTestEthSwapEth(CmdTestEthdev, CmdTestSwapMethods): + 'Ethereum swap operations - Ethereum wallet' + + networks = ('eth',) + tmpdir_nums = [48] + + bals = lambda self, k: { + 'swap1': [('98831F3A:E:1', '123.456')], + 'swap2': [('98831F3A:E:1', '114.690978056')], + }[k] + + cmd_group_in = CmdTestEthdev.cmd_group_in + ( + ('swaptxcreate1', 'creating an ETH->BTC swap transaction'), + ('swaptxcreate2', 'creating an ETH->BTC swap transaction (specific address, trade limit)'), + ('swaptxsign1', 'signing the transaction'), + ('swaptxsend1', 'sending the transaction'), + ('swaptxstatus1', 'getting the transaction status (with --verbose)'), + ('bal1', 'the ETH balance'), + ('bal2', 'the ETH balance'), + ) + + def swaptxcreate1(self): + self.get_file_with_ext('rawtx', delete_all=True) + t = self._swaptxcreate(['ETH', '8.765', 'BTC']) + t.expect('OK? (Y/n): ', 'y') + return self._swaptxcreate_ui_common(t) + + def swaptxcreate2(self): + self.get_file_with_ext('rawtx', delete_all=True) + return self._swaptxcreate_ui_common( + self._swaptxcreate( + ['ETH', '8.765', 'BTC', f'{dfl_seed_id}:B:3'], + add_opts = ['--trade-limit=3%']), + expect = ':2019e4/1/0') + + def swaptxsign1(self): + return self._swaptxsign() + + def swaptxsend1(self): + return self._swaptxsend() + + def swaptxstatus1(self): + return self._swaptxsend(add_opts=['--verbose', '--status'], status=True) + + def bal1(self): + return self.bal('swap1') + + def bal2(self): + return self.bal('swap2') diff --git a/test/cmdtest_d/ct_swap.py b/test/cmdtest_d/ct_swap.py index c52ecd8d..884dfde5 100755 --- a/test/cmdtest_d/ct_swap.py +++ b/test/cmdtest_d/ct_swap.py @@ -160,7 +160,7 @@ class CmdTestSwapMethods: ], spawn_only = spawn_only) - def _swaptxsend(self, *, add_opts=[], spawn_only=False): + def _swaptxsend(self, *, add_opts=[], spawn_only=False, status=False): fn = self.get_file_with_ext('sigtx') t = self.spawn( 'mmgen-txsend', @@ -169,6 +169,8 @@ class CmdTestSwapMethods: if spawn_only: return t t.expect('view: ', 'v') + if status: + return t t.expect('(y/N): ', 'n') t.expect('to confirm: ', 'YES\n') return t diff --git a/test/cmdtest_d/httpd/thornode.py b/test/cmdtest_d/httpd/thornode.py index 28f6ed35..623110fd 100755 --- a/test/cmdtest_d/httpd/thornode.py +++ b/test/cmdtest_d/httpd/thornode.py @@ -24,9 +24,9 @@ cfg = Config() # https://thornode.ninerealms.com/thorchain/quote/swap?from_asset=BCH.BCH&to_asset=LTC.LTC&amount=1000000 sample_request = 'GET /thorchain/quote/swap?from_asset=BCH.BCH&to_asset=LTC.LTC&amount=1000000000' request_pat = r'/thorchain/quote/swap\?from_asset=(\S+)\.(\S+)&to_asset=(\S+)\.(\S+)&amount=(\d+)' -prices = {'BTC': 97000, 'LTC': 115, 'BCH': 330} -gas_rate_units = {'BTC': 'satsperbyte'} -recommended_gas_rate = {'BTC': '6'} +prices = {'BTC': 97000, 'LTC': 115, 'BCH': 330, 'ETH': 2304} +gas_rate_units = {'ETH': 'gwei', 'BTC': 'satsperbyte'} +recommended_gas_rate = {'ETH': '1', 'BTC': '6'} data_template = { 'inbound_address': None, @@ -59,11 +59,12 @@ data_template = { def make_inbound_addr(proto, mmtype): from mmgen.tool.coin import tool_cmd n = int(time.time()) // (60 * 60 * 24) # increments once every 24 hrs - return tool_cmd( + ret = tool_cmd( cfg = cfg, cmdname = 'pubhash2addr', proto = proto, mmtype = mmtype).pubhash2addr(f'{n:040x}') + return '0x' + ret if proto.is_evm else ret class ThornodeServer(HTTPD): name = 'thornode server' diff --git a/test/cmdtest_d/runner.py b/test/cmdtest_d/runner.py index 88b8fca0..6487962d 100755 --- a/test/cmdtest_d/runner.py +++ b/test/cmdtest_d/runner.py @@ -124,6 +124,7 @@ class CmdTestRunner: extra_desc = '', no_output = False, msg_only = False, + log_only = False, no_msg = False, cmd_dir = 'cmds', no_exec_wrapper = False, @@ -167,6 +168,9 @@ class CmdTestRunner: self.tg.test_name, cmd_disp)) + if log_only: + return + for i in args: # die only after writing log entry if not isinstance(i, str): die(2, 'Error: missing input files in cmd line?:\nName: {}\nCmdline: {!r}'.format( @@ -426,7 +430,7 @@ class CmdTestRunner: return rerun - def run_test(self, cmd): + def run_test(self, cmd, sub=False): if self.deps_only and cmd == self.deps_only: sys.exit(0) @@ -463,6 +467,9 @@ class CmdTestRunner: setattr(self.tg, k, test_cfg[k]) ret = getattr(self.tg, cmd)(*arg_list) # run the test + if sub: + return ret + if type(ret).__name__ == 'coroutine': ret = asyncio.run(ret) diff --git a/test/modtest_d/tx.py b/test/modtest_d/tx.py index 91138139..3507ee44 100755 --- a/test/modtest_d/tx.py +++ b/test/modtest_d/tx.py @@ -9,6 +9,7 @@ import os from mmgen.tx import CompletedTX, UnsignedTX from mmgen.tx.file import MMGenTxFile from mmgen.cfg import Config +from mmgen.color import cyan from ..include.common import cfg, qmsg, vmsg, gr_uc, make_burn_addr @@ -175,10 +176,13 @@ class unit_tests: for coin, addrtype in ( ('ltc', 'bech32'), ('bch', 'compressed'), + ('eth', None), ): proto = init_proto(cfg, coin, need_amt=True) addr = make_burn_addr(proto, addrtype) + vmsg(f'\nTesting coin {cyan(coin.upper())}:') + for limit, limit_chk in ( ('123.4567', 12340000000), ('1.234567', 123400000),