From ec84abc907022fb181924583a6964bd5f553b870 Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Sun, 15 Jun 2025 09:17:02 +0000 Subject: [PATCH] RUNE swap support Testing/demo: $ test/cmdtest.py --demo runeswap --- mmgen/data/version | 2 +- mmgen/proto/rune/tx/base.py | 20 +++-- mmgen/proto/rune/tx/info.py | 16 ++-- mmgen/proto/rune/tx/new.py | 8 ++ mmgen/proto/rune/tx/new_swap.py | 25 ++++++ mmgen/proto/rune/tx/signed.py | 8 +- mmgen/proto/rune/tx/unsigned.py | 22 +++-- mmgen/proto/vm/tx/new.py | 4 +- mmgen/swap/proto/thorchain/asset.py | 4 +- mmgen/swap/proto/thorchain/thornode.py | 38 ++++++--- test/cmdtest_d/httpd/thornode/rpc.py | 2 + test/cmdtest_d/include/cfg.py | 4 + test/cmdtest_d/runeswap.py | 113 +++++++++++++++++++++++++ test/cmdtest_d/swap.py | 5 +- test/test-release.d/cfg.sh | 1 + 15 files changed, 230 insertions(+), 42 deletions(-) create mode 100755 mmgen/proto/rune/tx/new_swap.py create mode 100755 test/cmdtest_d/runeswap.py diff --git a/mmgen/data/version b/mmgen/data/version index 5f49b911..c06fe99a 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -15.1.dev45 +15.1.dev46 diff --git a/mmgen/proto/rune/tx/base.py b/mmgen/proto/rune/tx/base.py index 2783a127..f7b7d1fb 100755 --- a/mmgen/proto/rune/tx/base.py +++ b/mmgen/proto/rune/tx/base.py @@ -43,18 +43,20 @@ class Base(TxBase): o = self.txobj b = tx.body.messages[0].body - if s := self.proto.encode_addr_bech32x(b.fromAddress) != o['from']: - raise ValueError(f'{s}: invalid ‘from’ address in serialized data') + if self.is_swap: + from_k, amt_k = ('signer', 'coins') + if b.memo != self.swap_memo: + raise ValueError(f'{b.memo}: invalid swap memo in serialized data') + else: + from_k, amt_k = ('fromAddress', 'amount') + if s := self.proto.encode_addr_bech32x(b.toAddress) != o['to']: + raise ValueError(f'{s}: invalid ‘to’ address in serialized data') - if s := self.proto.encode_addr_bech32x(b.toAddress) != o['to']: - raise ValueError(f'{s}: invalid ‘to’ address in serialized data') + if s := self.proto.encode_addr_bech32x(getattr(b, from_k)) != o['from']: + raise ValueError(f'{s}: invalid {from_k} in serialized data') - if d := self.proto.coin_amt(int(b.amount[0].amount), from_unit='satoshi') != o['amt']: + if d := self.proto.coin_amt(int(getattr(b, amt_k)[0].amount), from_unit='satoshi') != o['amt']: raise ValueError(f'{d}: invalid send amount in serialized data') if n := tx.authInfo.signerInfos[0].sequence != o['sequence']: raise ValueError(f'{n}: invalid sequence number in serialized data') - - if self.is_swap: - if b.memo != self.swap_memo.encode(): - raise ValueError(f'{b.memo}: invalid swap memo in serialized data') diff --git a/mmgen/proto/rune/tx/info.py b/mmgen/proto/rune/tx/info.py index 90e7a7ce..cc2a54a3 100755 --- a/mmgen/proto/rune/tx/info.py +++ b/mmgen/proto/rune/tx/info.py @@ -13,7 +13,7 @@ proto.rune.tx.info: THORChain transaction info class """ from ....tx.info import TxInfo -from ....color import blue, pink +from ....color import pink from ....obj import NonNegativeInt from ...vm.tx.info import VmTxInfo, mmid_disp @@ -24,22 +24,28 @@ class TxInfo(VmTxInfo, TxInfo): tx = self.tx t = tx.txobj fs = """ + From: {f}{f_mmid} + Amount: {a} {c} + Gas limit: {G} + Sequence: {N} + Memo: {m} + """ if tx.is_swap else """ From: {f}{f_mmid} To: {t}{t_mmid} Amount: {a} {c} Gas limit: {G} Sequence: {N} - """.strip().replace('\t', '') + ('\nMemo: {m}' if tx.is_swap else '') - return fs.format( + """ + return fs.strip().replace('\t', '').format( f = t['from'].hl(0), - t = t['to'].hl(0) if tx.outputs else blue('None'), + t = None if tx.is_swap else t['to'].hl(0), a = t['amt'].hl(), N = NonNegativeInt(t['sequence']).hl(), m = pink(tx.swap_memo) if tx.is_swap else None, c = tx.proto.dcoin if tx.outputs else '', G = NonNegativeInt(tx.total_gas).hl(), f_mmid = mmid_disp(tx.inputs[0], nonmm_str), - t_mmid = mmid_disp(tx.outputs[0], nonmm_str) if tx.outputs else '') + '\n\n' + t_mmid = None if tx.is_swap else mmid_disp(tx.outputs[0], nonmm_str)) + '\n\n' def format_abs_fee(self, iwidth, /, *, color=None): return self.tx.fee.fmt(iwidth, color=color) diff --git a/mmgen/proto/rune/tx/new.py b/mmgen/proto/rune/tx/new.py index 78228dc0..7fb37862 100755 --- a/mmgen/proto/rune/tx/new.py +++ b/mmgen/proto/rune/tx/new.py @@ -26,6 +26,14 @@ class New(VmNew, Base, TxBase.New): async def set_gas(self, *, to_addr=None, force=False): self.gas = self.dfl_gas + def set_gas_with_data(self, data): + pass + + def update_txid(self): + return super().update_txid( + self.serialized | + ({'memo': self.swap_memo} if self.is_swap else {})) + async def make_txobj(self): # called by create_serialized() acct_info = self.rpc.get_account_info(self.inputs[0].addr) self.txobj = { diff --git a/mmgen/proto/rune/tx/new_swap.py b/mmgen/proto/rune/tx/new_swap.py new file mode 100755 index 00000000..77756fdb --- /dev/null +++ b/mmgen/proto/rune/tx/new_swap.py @@ -0,0 +1,25 @@ +#!/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.rune.tx.new_swap: THORChain new swap transaction class +""" + +from ....tx.new_swap import NewSwap as TxNewSwap + +from ...vm.tx.new_swap import VmNewSwap + +from .new import New + +class NewSwap(VmNewSwap, New, TxNewSwap): + desc = 'RUNE swap transaction' + + def update_vault_addr(self, c, *, addr='inbound_address'): + pass diff --git a/mmgen/proto/rune/tx/signed.py b/mmgen/proto/rune/tx/signed.py index 8d234f0e..31528a0f 100755 --- a/mmgen/proto/rune/tx/signed.py +++ b/mmgen/proto/rune/tx/signed.py @@ -27,10 +27,12 @@ class Signed(Completed, TxBase.Signed): b = tx.body.messages[0].body i = tx.authInfo + from_k, amt_k = ('signer', 'coins') if self.is_swap else ('fromAddress', 'amount') + self.txobj = { - 'from': self.proto.encode_addr_bech32x(b.fromAddress), - 'to': self.proto.encode_addr_bech32x(b.toAddress), - 'amt': self.proto.coin_amt(int(b.amount[0].amount), from_unit='satoshi'), + 'from': self.proto.encode_addr_bech32x(getattr(b, from_k)), + 'to': None if self.is_swap else self.proto.encode_addr_bech32x(b.toAddress), + 'amt': self.proto.coin_amt(int(getattr(b, amt_k)[0].amount), from_unit='satoshi'), 'gas': NonNegativeInt(i.fee.gasLimit), 'sequence': NonNegativeInt(i.signerInfos[0].sequence)} diff --git a/mmgen/proto/rune/tx/unsigned.py b/mmgen/proto/rune/tx/unsigned.py index 96115ba9..088fdaa7 100755 --- a/mmgen/proto/rune/tx/unsigned.py +++ b/mmgen/proto/rune/tx/unsigned.py @@ -34,18 +34,28 @@ class Unsigned(VmUnsigned, Completed, TxBase.Unsigned): 'chain_id': d['chain_id']} async def do_sign(self, o, wif): - from .protobuf import build_tx, send_tx_parms - tx = build_tx( - self.cfg, - self.proto, - send_tx_parms( + if self.is_swap: + from .protobuf import swap_tx_parms, build_swap_tx as build_tx + parms = swap_tx_parms( + o['from'], + o['amt'], + o['gas'], + o['account_number'], + o['sequence'], + self.swap_memo, + wifkey = wif) + else: + from .protobuf import send_tx_parms, build_tx + parms = send_tx_parms( o['from'], o['to'], o['amt'], o['gas'], o['account_number'], o['sequence'], - wifkey = wif)) + wifkey = wif) + + tx = build_tx(self.cfg, self.proto, parms) self.serialized = bytes(tx).hex() self.coin_txid = CoinTxID(tx.txid) tx.verify_sig(self.proto, o['account_number']) diff --git a/mmgen/proto/vm/tx/new.py b/mmgen/proto/vm/tx/new.py index 071084fe..335f21b2 100755 --- a/mmgen/proto/vm/tx/new.py +++ b/mmgen/proto/vm/tx/new.py @@ -33,11 +33,11 @@ class New: self.serialized = {k:v if v is None else str(v) for k, v in self.txobj.items() if k != 'token_to'} self.update_txid() - def update_txid(self): + def update_txid(self, data=None): import json assert not is_hex_str(self.serialized), ( 'update_txid() must be called only when self.serialized is not hex data') - self.txid = MMGenTxID(make_chksum_6(json.dumps(self.serialized)).upper()) + self.txid = MMGenTxID(make_chksum_6(json.dumps(data or self.serialized)).upper()) async def process_cmdline_args(self, cmd_args, ad_f, ad_w): diff --git a/mmgen/swap/proto/thorchain/asset.py b/mmgen/swap/proto/thorchain/asset.py index 58b011b0..0977f196 100644 --- a/mmgen/swap/proto/thorchain/asset.py +++ b/mmgen/swap/proto/thorchain/asset.py @@ -23,7 +23,7 @@ class THORChainSwapAsset(SwapAsset): 'BCH': _ad('Bitcoin Cash', 'BCH', None, 'c', True), 'ETH': _ad('Ethereum', 'ETH', None, 'e', True), 'DOGE': _ad('Dogecoin', 'DOGE', None, 'd', False), - 'RUNE': _ad('Rune (THORChain)', 'RUNE', 'THOR.RUNE', 'r', False), + 'RUNE': _ad('Rune (THORChain)', 'RUNE', 'THOR.RUNE', 'r', True), 'ETH.AAVE': _ad('Aave (ETH)', None, 'ETH.AAVE', None, True), 'ETH.DAI': _ad('MakerDAO USD (ETH)', None, 'ETH.DAI', None, True), 'ETH.DPI': _ad('DeFi Pulse Index (ETH)', None, 'ETH.DPI', None, True), @@ -63,7 +63,7 @@ class THORChainSwapAsset(SwapAsset): 'ETH.YFI': '0bc529c00c6401aef6d220be8c6ea1667f6ad93e', } - unsupported = ('DOGE', 'RUNE') + unsupported = ('DOGE',) blacklisted = {} diff --git a/mmgen/swap/proto/thorchain/thornode.py b/mmgen/swap/proto/thorchain/thornode.py index 6c9a3b53..997f4622 100755 --- a/mmgen/swap/proto/thorchain/thornode.py +++ b/mmgen/swap/proto/thorchain/thornode.py @@ -58,7 +58,9 @@ class Thornode: die(2, pp_fmt(data)) return data - if self.tx.proto.tokensym or self.tx.recv_asset.tokensym: # token swap + if ( + (self.tx.proto.tokensym or self.tx.recv_asset.tokensym) + and not self.tx.send_asset.chain == 'THOR'): # token swap in_data = get_data( self.tx.send_asset.full_name, 'THOR.RUNE', @@ -92,7 +94,8 @@ class Thornode: out_coin = tx.recv_asset.short_name in_amt = self.in_amt out_amt = UniAmt(int(d['expected_amount_out']), from_unit='satoshi') - gas_unit = d['gas_rate_units'] + if tx.proto.has_usr_fee: + gas_unit = d['gas_rate_units'] if trade_limit: from . import ExpInt4 @@ -121,7 +124,11 @@ class Thornode: _amount_in_label = 'Amount in:' if deduct_est_fee: - if gas_unit in gas_unit_data: + if not tx.proto.has_usr_fee: + in_amt -= tx.usr_fee + out_amt *= (in_amt / self.in_amt) + _amount_in_label = 'Amount in:' + elif gas_unit in gas_unit_data: in_amt -= UniAmt(f'{get_estimated_fee():.8f}') out_amt *= (in_amt / self.in_amt) _amount_in_label = 'Amount in (estimated):' @@ -129,7 +136,6 @@ class Thornode: ymsg(f'Warning: unknown gas unit ‘{gas_unit}’, cannot estimate fee') min_in_amt = UniAmt(int(d['recommended_min_amount_in']), from_unit='satoshi') - gas_unit_disp = _.disp if (_ := gas_unit_data.get(gas_unit)) else gas_unit elapsed_disp = format_elapsed_hr(d['expiry'], future_msg='from now') fees = d['fees'] fees_t = UniAmt(int(fees['total']), from_unit='satoshi') @@ -137,19 +143,26 @@ class Thornode: slip_pct_disp = str(fees['slippage_bps'] / 100) + '%' hdr = f'SWAP QUOTE (source: {self.rpc.host})' + vault_info = '' if tx.send_asset.chain == 'THOR' else """ + Vault address: {}""".format(cyan(self.inbound_address)) + + fee_info = '' if not tx.proto.has_usr_fee else """ + Recommended fee: {} {} + Network-estimated fee: {} (from node)""".format( + pink(d['recommended_gas_rate']), + pink(_.disp if (_ := gas_unit_data.get(gas_unit)) else gas_unit), + await self.tx.network_fee_disp()) + return f""" {cyan(hdr)} Protocol: {blue(name)} - Direction: {orange(f'{tx.send_asset.name} => {tx.recv_asset.name}')} - Vault address: {cyan(self.inbound_address)} + Direction: {orange(f'{tx.send_asset.name} => {tx.recv_asset.name}')}{vault_info} Quote expires: {pink(elapsed_disp)} [{make_timestr(d['expiry'])}] {_amount_in_label:<22} {in_amt.hl()} {in_coin} Expected amount out: {out_amt.hl()} {out_coin}{trade_limit_disp} Rate: {(out_amt / in_amt).hl()} {out_coin}/{in_coin} Reverse rate: {(in_amt / out_amt).hl()} {in_coin}/{out_coin} - Recommended minimum in amount: {min_in_amt.hl()} {in_coin} - Recommended fee: {pink(d['recommended_gas_rate'])} {pink(gas_unit_disp)} - Network-estimated fee: {await self.tx.network_fee_disp()} (from node) + Recommended minimum in amount: {min_in_amt.hl()} {in_coin}{fee_info} Fees: Total: {fees_t.hl()} {out_coin} ({pink(fees_pct_disp)}) Slippage: {pink(slip_pct_disp)} @@ -166,9 +179,10 @@ class Thornode: @property def rel_fee_hint(self): - gas_unit = self.data['gas_rate_units'] - if gas_unit in gas_unit_data: - return self.data['recommended_gas_rate'] + gas_unit_data[gas_unit].code + if self.tx.proto.has_usr_fee: + gas_unit = self.data['gas_rate_units'] + if gas_unit in gas_unit_data: + return self.data['recommended_gas_rate'] + gas_unit_data[gas_unit].code def __str__(self): from pprint import pformat diff --git a/test/cmdtest_d/httpd/thornode/rpc.py b/test/cmdtest_d/httpd/thornode/rpc.py index da23f07b..86936ad4 100755 --- a/test/cmdtest_d/httpd/thornode/rpc.py +++ b/test/cmdtest_d/httpd/thornode/rpc.py @@ -66,6 +66,8 @@ class ThornodeRPCServer(ThornodeServer): res = {'code': 0, 'codespace': '', 'data': '', 'log': ''} if txhex.startswith('0A540A52'): res.update({'hash': '14463C716CF08A814868DB779156BCD85A1DF8EE49E924900A74482E9DEE132D'}) + elif txhex.startswith('0AC1010A'): + res.update({'hash': '17F9411E48542C0DCA4D40A0DD4A1795DE6D5791A873A27CBBDC1031FE8D1BC5'}) else: raise ValueError(f'‘{req_str}’: malformed query path') diff --git a/test/cmdtest_d/include/cfg.py b/test/cmdtest_d/include/cfg.py index db3e67cd..421045bf 100755 --- a/test/cmdtest_d/include/cfg.py +++ b/test/cmdtest_d/include/cfg.py @@ -44,6 +44,7 @@ cmd_groups_dfl = { 'ethdev': ('CmdTestEthdev', {}), 'ethbump': ('CmdTestEthBump', {}), 'rune': ('CmdTestRune', {}), + 'runeswap': ('CmdTestRuneSwap', {}), 'xmrwallet': ('CmdTestXMRWallet', {}), 'xmr_autosign': ('CmdTestXMRAutosign', {}), } @@ -51,6 +52,7 @@ cmd_groups_dfl = { cmd_groups_extra = { 'ethswap_eth': ('CmdTestEthSwapEth', {'modname': 'ethswap'}), 'ethbump_ltc': ('CmdTestEthBumpLTC', {'modname': 'ethbump'}), + 'runeswap_rune': ('CmdTestRuneSwapRune', {'modname': 'runeswap'}), 'dev': ('CmdTestDev', {'modname': 'misc'}), 'regtest_legacy': ('CmdTestRegtestBDBWallet', {'modname': 'regtest'}), 'autosign_btc': ('CmdTestAutosignBTC', {'modname': 'autosign'}), @@ -252,6 +254,8 @@ cfgs = { # addr_idx_lists (except 31, 32, 33, 34) must contain exactly 8 address '48': {}, # ethswap_eth '49': {}, # autosign_automount '50': {}, # rune + '57': {}, # runeswap + '58': {}, # runeswap_rune '59': {}, # autosign_eth '99': {}, # dummy } diff --git a/test/cmdtest_d/runeswap.py b/test/cmdtest_d/runeswap.py new file mode 100755 index 00000000..59626418 --- /dev/null +++ b/test/cmdtest_d/runeswap.py @@ -0,0 +1,113 @@ +#!/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.runeswap: THORChain swap tests for the cmdtest.py test suite +""" + +from .httpd.thornode.swap import ThornodeSwapServer + +from .regtest import CmdTestRegtest +from .swap import CmdTestSwapMethods, create_cross_methods +from .rune import CmdTestRune + +class CmdTestRuneSwap(CmdTestSwapMethods, CmdTestRegtest): + 'RUNE swap operations' + + bdb_wallet = True + tmpdir_nums = [57] + networks = ('btc',) + passthru_opts = ('coin', 'rpc_backend') + cross_group = 'runeswap_rune' + cross_coin = 'rune' + + cmd_group_in = ( + ('setup', 'regtest (Bob and Alice) mode setup'), + ('subgroup.init', []), + ('subgroup.rune_init', ['init']), + ('subgroup.rune_swap', ['rune_init']), + ('stop', 'stopping the regtest daemon'), + ('swap_server_stop', 'stopping the Thornode swap server'), + ('rune_rpc_server_stop', 'stopping the Thornode RPC 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'), + ), + 'rune_init': ( + 'initializing the RUNE tracking wallet', + ('rune_addrgen', ''), + ('rune_addrimport', ''), + ('rune_bal_refresh', ''), + ('rune_twview', ''), + ), + 'rune_swap': ( + 'swap operations (RUNE -> BTC)', + ('rune_swaptxcreate1', ''), + ('rune_swaptxsign1', ''), + ('rune_swaptxsend1', ''), + ('rune_swaptxstatus1', ''), + ('rune_swaptxreceipt1', ''), + ), + } + + exec(create_cross_methods(cross_coin, cross_group, cmd_group_in, cmd_subgroups)) + + def __init__(self, cfg, trunner, cfgs, spawn): + + super().__init__(cfg, trunner, cfgs, spawn) + + if not trunner: + return + + globals()[self.cross_group] = self.create_cross_runner(trunner) + + self.swap_server = ThornodeSwapServer() + self.swap_server.start() + + def swap_server_stop(self): + return self._thornode_server_stop() + +class CmdTestRuneSwapRune(CmdTestSwapMethods, CmdTestRune): + 'RUNE swap operations - RUNE wallet' + + networks = ('rune',) + tmpdir_nums = [58] + input_sels_prompt = 'to spend from: ' + + cmd_group_in = CmdTestRune.cmd_group_in + ( + # rune_swap: + ('swaptxcreate1', 'creating a RUNE->BTC swap transaction'), + ('swaptxsign1', 'signing the transaction'), + ('swaptxsend1', 'sending the transaction'), + ('swaptxstatus1', 'getting the transaction status'), + ('swaptxreceipt1', 'getting the transaction receipt'), + ('thornode_server_stop', 'stopping Thornode server'), + ) + + def swaptxcreate1(self): + t = self._swaptxcreate(['RUNE', '8.765', 'BTC']) + t.expect('OK? (Y/n): ', 'y') + return self._swaptxcreate_ui_common(t, inputs='3') + + 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 swaptxreceipt1(self): + return self._swaptxsend(add_opts=['--receipt'], spawn_only=True) diff --git a/test/cmdtest_d/swap.py b/test/cmdtest_d/swap.py index f4ee047d..5a49ab18 100755 --- a/test/cmdtest_d/swap.py +++ b/test/cmdtest_d/swap.py @@ -151,8 +151,9 @@ class CmdTestSwapMethods: if reload_quote: t.expect('to continue: ', 'r') # reload swap quote t.expect('to continue: ', '\n') # exit swap quote view - t.expect('(Y/n): ', 'y') # fee OK? - t.expect('(Y/n): ', 'y') # change OK? + if self.proto.has_usr_fee: + t.expect('(Y/n): ', 'y') # fee OK? + t.expect('(Y/n): ', 'y') # change OK? t.expect('(y/N): ', 'n') # add comment? t.expect('view: ', 'y') # view TX if expect: diff --git a/test/test-release.d/cfg.sh b/test/test-release.d/cfg.sh index 39480f10..62db8a64 100755 --- a/test/test-release.d/cfg.sh +++ b/test/test-release.d/cfg.sh @@ -276,6 +276,7 @@ init_tests() { d_rune="operations for THORChain RUNE using testnet" t_rune=" - $cmdtest_py --coin=rune rune + - $cmdtest_py runeswap " d_xmr="Monero xmrwallet operations"