From 38ea93a51f7cd2fe06155f9b42d35e53a941ffbc Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Mon, 21 Apr 2025 14:01:16 +0000 Subject: [PATCH] tx.new_swap: swap to ERC20 token support --- mmgen/swap/proto/thorchain/thornode.py | 42 ++++++++--- mmgen/tx/completed.py | 8 +- test/cmdtest_d/ethswap.py | 75 +++++++++++++++---- test/cmdtest_d/httpd/thornode.py | 61 +++++++++++++-- test/modtest_d/swap.py | 2 + .../mmgen/swap/proto/thorchain/asset.py | 14 ++++ 6 files changed, 171 insertions(+), 31 deletions(-) create mode 100755 test/overlay/fakemods/mmgen/swap/proto/thorchain/asset.py diff --git a/mmgen/swap/proto/thorchain/thornode.py b/mmgen/swap/proto/thorchain/thornode.py index cb1b914c..68a007f5 100755 --- a/mmgen/swap/proto/thorchain/thornode.py +++ b/mmgen/swap/proto/thorchain/thornode.py @@ -12,8 +12,9 @@ swap.proto.thorchain.thornode: THORChain swap protocol network query ops """ -import json +import time, json from collections import namedtuple + from ....amt import UniAmt _gd = namedtuple('gas_unit_data', ['code', 'disp']) @@ -60,15 +61,36 @@ class Thornode: self.rpc = ThornodeRPCClient(tx) def get_quote(self): - self.get_str = '/thorchain/quote/swap?from_asset={a}&to_asset={b}&amount={c}'.format( - a = self.tx.send_asset.full_name, - b = self.tx.recv_asset.full_name, - c = self.in_amt.to_unit('satoshi')) - self.result = self.rpc.get(self.get_str) - self.data = json.loads(self.result.content) - if not 'expiry' in self.data: - from ....util import pp_fmt, die - die(2, pp_fmt(self.data)) + + def get_data(send, recv, amt): + get_str = f'/thorchain/quote/swap?from_asset={send}&to_asset={recv}&amount={amt}' + data = json.loads(self.rpc.get(get_str).content) + if not 'expiry' in data: + from ....util import pp_fmt, die + die(2, pp_fmt(data)) + return data + + if self.tx.proto.tokensym or self.tx.recv_asset.asset: # token swap + in_data = get_data( + self.tx.send_asset.full_name, + 'THOR.RUNE', + self.in_amt.to_unit('satoshi')) + if self.tx.proto.network != 'regtest': + time.sleep(1.1) # ninerealms max request rate 1/sec + out_data = get_data( + 'THOR.RUNE', + self.tx.recv_asset.full_name, + in_data['expected_amount_out']) + self.data = in_data | { + 'expected_amount_out': out_data['expected_amount_out'], + 'fees': out_data['fees'], + 'expiry': min(in_data['expiry'], out_data['expiry']) + } + else: + self.data = get_data( + self.tx.send_asset.full_name, + self.tx.recv_asset.full_name, + self.in_amt.to_unit('satoshi')) async def format_quote(self, trade_limit, usr_trade_limit, *, deduct_est_fee=False): from ....util import make_timestr, ymsg diff --git a/mmgen/tx/completed.py b/mmgen/tx/completed.py index cf16adc9..68f90583 100755 --- a/mmgen/tx/completed.py +++ b/mmgen/tx/completed.py @@ -79,8 +79,12 @@ class Completed(Base): assert p.function == 'SWAP', f'‘{p.function}’: unsupported function in swap memo ‘{text}’' aname = p.chain + (f'.{p.asset}' if p.asset != p.chain else '') assert aname == self.recv_asset.name, f'invalid memo: {aname} != {self.recv_asset.name}' - assert p.chain == p.asset, f'{p.chain} != {p.asset}: chain/asset mismatch in swap memo ‘{text}’' - proto = init_proto(self.cfg, p.asset, network=self.cfg.network, need_amt=True) + proto = init_proto( + self.cfg, + p.chain, + network = self.cfg.network, + tokensym = None if p.chain == p.asset else p.asset, + need_amt = True) if self.swap_recv_addr_mmid: mmid = self.swap_recv_addr_mmid elif self.cfg.allow_non_wallet_swap: diff --git a/test/cmdtest_d/ethswap.py b/test/cmdtest_d/ethswap.py index 00583192..b2f039fa 100755 --- a/test/cmdtest_d/ethswap.py +++ b/test/cmdtest_d/ethswap.py @@ -68,17 +68,20 @@ class CmdTestEthSwap(CmdTestSwapMethods, CmdTestRegtest): eth_group = 'ethswap_eth' 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'), + ('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']), + ('subgroup.token_init', ['eth_fund']), + ('subgroup.token_swap', ['fund', 'token_init']), + ('subgroup.eth_token_swap', ['fund', 'token_init']), + ('stop', 'stopping regtest daemon'), + ('eth_stop', 'stopping Ethereum daemon'), + ('thornode_server_stop', 'stopping the Thornode server'), ) cmd_subgroups = { 'init': ( @@ -107,6 +110,23 @@ class CmdTestEthSwap(CmdTestSwapMethods, CmdTestRegtest): ('eth_fund_mmgen_addr2', ''), ('eth_bal1', ''), ), + 'token_init': ( + 'deploying tokens and initializing the ETH token tracking wallet', + ('eth_token_compile1', ''), + ('eth_token_deploy_a', ''), + ('eth_token_deploy_b', ''), + ('eth_token_deploy_c', ''), + ('eth_token_fund_user', ''), + ('eth_token_addrgen', ''), + ('eth_token_addrimport', ''), + ('eth_token_bal1', ''), + ), + 'token_swap': ( + 'token swap operations (BTC -> MM1)', + ('swaptxcreate3', 'creating a BTC->MM1 swap transaction'), + ('swaptxsign3', 'signing the swap transaction'), + ('swaptxsend3', 'sending the swap transaction'), + ), 'swap': ( 'swap operations (BTC -> ETH)', ('swaptxcreate1', 'creating a BTC->ETH swap transaction'), @@ -131,6 +151,12 @@ class CmdTestEthSwap(CmdTestSwapMethods, CmdTestRegtest): ('eth_swaptxstatus1', ''), ('eth_bal2', ''), ), + 'eth_token_swap': ( + 'swap operations (ETH <-> MM1)', + ('eth_swaptxcreate3', ''), + ('eth_swaptxsign3', ''), + ('eth_swaptxsend3', ''), + ), } eth_tests = [c[0] for v in tuple(cmd_subgroups.values()) + (cmd_group_in,) @@ -178,8 +204,8 @@ class CmdTestEthSwap(CmdTestSwapMethods, CmdTestRegtest): def swaptxsend1(self): return self._swaptxsend() - swaptxsign2 = swaptxsign1 - swaptxsend2 = swaptxsend1 + swaptxsign2 = swaptxsign3 = swaptxsign1 + swaptxsend2 = swaptxsend3 = swaptxsend1 def swaptxbump1(self): # create one-output TX back to self to rescue funds return self._swaptxbump('40s', output_args=[f'{dfl_sid}:B:1']) @@ -198,6 +224,11 @@ class CmdTestEthSwap(CmdTestSwapMethods, CmdTestRegtest): def bob_bal3(self): return self._user_bal_cli('bob', chk='499.77656902') + def swaptxcreate3(self): + t = self._swaptxcreate(['BTC', '0.87654321', 'ETH.MM1', f'{dfl_sid}:E:5']) + t.expect('OK? (Y/n): ', 'y') + return self._swaptxcreate_ui_common(t) + def thornode_server_stop(self): self.spawn(msg_only=True) thornode_server.stop() @@ -224,8 +255,19 @@ class CmdTestEthSwapEth(CmdTestEthSwapMethods, CmdTestSwapMethods, CmdTestEthdev ('swaptxsign1', 'signing the transaction'), ('swaptxsend1', 'sending the transaction'), ('swaptxstatus1', 'getting the transaction status (with --verbose)'), + ('swaptxcreate3', 'creating an ETH->MM1 swap transaction'), + ('swaptxsign3', 'signing the transaction'), + ('swaptxsend3', 'sending the transaction'), ('bal1', 'the ETH balance'), ('bal2', 'the ETH balance'), + ('token_compile1', 'compiling ERC20 token #1'), + ('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_fund_user', 'transferring token funds from dev to user'), + ('token_addrgen', 'generating token addresses'), + ('token_addrimport', 'importing token addresses using token address (MM1)'), + ('token_bal1', 'the token balance'), ) def swaptxcreate1(self): @@ -240,12 +282,19 @@ class CmdTestEthSwapEth(CmdTestEthSwapMethods, CmdTestSwapMethods, CmdTestEthdev add_opts = ['--trade-limit=3%']), expect = ':2019e4/1/0') + def swaptxcreate3(self): + t = self._swaptxcreate(['ETH', '8.765', 'ETH.MM1', f'{dfl_sid}:E:5']) + return self._swaptxcreate_ui_common(t) + def swaptxsign1(self): return self._swaptxsign() def swaptxsend1(self): return self._swaptxsend() + swaptxsign3 = swaptxsign1 + swaptxsend3 = swaptxsend1 + def swaptxstatus1(self): self.mining_delay() return self._swaptxsend(add_opts=['--verbose', '--status'], status=True) diff --git a/test/cmdtest_d/httpd/thornode.py b/test/cmdtest_d/httpd/thornode.py index ededa40c..84c0734f 100755 --- a/test/cmdtest_d/httpd/thornode.py +++ b/test/cmdtest_d/httpd/thornode.py @@ -24,10 +24,52 @@ 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, 'ETH': 2304} +prices = {'BTC': 97000, 'LTC': 115, 'BCH': 330, 'ETH': 2304, 'MM1': 0.998, 'RUNE': 1.4} gas_rate_units = {'ETH': 'gwei', 'BTC': 'satsperbyte'} recommended_gas_rate = {'ETH': '1', 'BTC': '6'} +data_template_from_rune = { + 'outbound_delay_blocks': 0, + 'outbound_delay_seconds': 0, + 'fees': { + 'asset': 'BTC.BTC', + 'affiliate': '0', + 'outbound': '1182', + 'liquidity': '110', + 'total': '1292', + 'slippage_bps': 7, + 'total_bps': 92 + }, + 'warning': 'Do not cache this response. Do not send funds after the expiry.', + 'notes': 'Broadcast a MsgDeposit to the THORChain network with the appropriate memo. Do not use multi-in, multi-out transactions.', + 'max_streaming_quantity': 0, + 'streaming_swap_blocks': 0 +} + +data_template_to_rune = { + 'inbound_confirmation_blocks': 2, + 'inbound_confirmation_seconds': 24, + 'outbound_delay_blocks': 0, + 'outbound_delay_seconds': 0, + 'fees': { + 'asset': 'THOR.RUNE', + 'affiliate': '0', + 'outbound': '2000000', + 'liquidity': '684966', + 'total': '2684966', + 'slippage_bps': 8, + 'total_bps': 31 + }, + 'router': '0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146', + 'warning': 'Do not cache this response. Do not send funds after the expiry.', + 'notes': 'Base Asset: Send the inbound_address the asset with the memo encoded in hex in the data field. 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.', + 'dust_threshold': '1', + 'recommended_gas_rate': '1', + 'max_streaming_quantity': 0, + 'streaming_swap_blocks': 0, + 'total_swap_seconds': 24 +} + data_template_btc = { 'inbound_confirmation_blocks': 4, 'inbound_confirmation_seconds': 2400, @@ -98,17 +140,24 @@ class ThornodeServer(HTTPD): out_amt = in_amt * (prices[send_asset] / prices[recv_asset]) data_template = ( + data_template_from_rune if send_asset == 'RUNE' else + data_template_to_rune if recv_asset == 'RUNE' else data_template_eth if send_asset == 'ETH' else data_template_btc) - from mmgen.protocol import init_proto - send_proto = init_proto(cfg, send_chain, network='regtest', need_amt=True) data = data_template | { 'recommended_min_amount_in': str(int(70 * 10**8 / prices[send_asset])), # $70 'expected_amount_out': str(out_amt.to_unit('satoshi')), 'expiry': int(time.time()) + (10 * 60), - 'inbound_address': make_inbound_addr(send_proto, send_proto.preferred_mmtypes[0]), - 'gas_rate_units': gas_rate_units[send_proto.base_proto_coin], - 'recommended_gas_rate': recommended_gas_rate[send_proto.base_proto_coin], } + + if send_asset != 'RUNE': + from mmgen.protocol import init_proto + send_proto = init_proto(cfg, send_chain, network='regtest', need_amt=True) + data.update({ + 'inbound_address': make_inbound_addr(send_proto, send_proto.preferred_mmtypes[0]), + 'gas_rate_units': gas_rate_units[send_proto.base_proto_coin], + 'recommended_gas_rate': recommended_gas_rate[send_proto.base_proto_coin] + }) + return json.dumps(data).encode() diff --git a/test/modtest_d/swap.py b/test/modtest_d/swap.py index 1e3418bd..9dd755e1 100755 --- a/test/modtest_d/swap.py +++ b/test/modtest_d/swap.py @@ -17,6 +17,7 @@ class unit_tests: ('BTC', 'BTC.BTC', 'b', 'BTC', None, 'recv'), ('LTC', 'LTC.LTC', 'l', 'LTC', None, 'recv'), ('BCH', 'BCH.BCH', 'c', 'BCH', None, 'recv'), + ('ETH.USDT', 'ETH.USDT', 'ETH.USDT', 'ETH', 'USDT', 'recv'), ): a = SwapAsset(name, direction) vmsg(f' {a.name}') @@ -36,6 +37,7 @@ class unit_tests: ('ltc', 'bech32', 'LTC', None), ('bch', 'compressed', 'BCH', None), ('eth', None, 'ETH', None), + ('eth', None, 'ETH.USDT', 'USDT'), ): proto = init_proto(cfg, coin, tokensym=token, need_amt=True) addr = make_burn_addr(proto, addrtype) diff --git a/test/overlay/fakemods/mmgen/swap/proto/thorchain/asset.py b/test/overlay/fakemods/mmgen/swap/proto/thorchain/asset.py new file mode 100755 index 00000000..d04e8be9 --- /dev/null +++ b/test/overlay/fakemods/mmgen/swap/proto/thorchain/asset.py @@ -0,0 +1,14 @@ +from .asset_orig import * + +class overlay_fake_THORChainSwapAsset: + + assets_data = { + 'ETH.MM1': THORChainSwapAsset._ad('MM1 Token (ETH)', None, 'ETH.MM1', None), + 'ETH.USDT': THORChainSwapAsset._ad('Tether (ETH)', None, 'ETH.USDT', None) + } + send = ('ETH.MM1',) + recv = ('ETH.MM1', 'ETH.USDT') + +THORChainSwapAsset.assets_data |= overlay_fake_THORChainSwapAsset.assets_data +THORChainSwapAsset.send += overlay_fake_THORChainSwapAsset.send +THORChainSwapAsset.recv += overlay_fake_THORChainSwapAsset.recv