Browse Source

RUNE swap support

Testing/demo:

    $ test/cmdtest.py --demo runeswap
The MMGen Project 5 months ago
parent
commit
ec84abc907

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-15.1.dev45
+15.1.dev46

+ 11 - 9
mmgen/proto/rune/tx/base.py

@@ -43,18 +43,20 @@ class Base(TxBase):
 			o = self.txobj
 			o = self.txobj
 			b = tx.body.messages[0].body
 			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')
 				raise ValueError(f'{d}: invalid send amount in serialized data')
 
 
 			if n := tx.authInfo.signerInfos[0].sequence != o['sequence']:
 			if n := tx.authInfo.signerInfos[0].sequence != o['sequence']:
 				raise ValueError(f'{n}: invalid sequence number in serialized data')
 				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')

+ 11 - 5
mmgen/proto/rune/tx/info.py

@@ -13,7 +13,7 @@ proto.rune.tx.info: THORChain transaction info class
 """
 """
 
 
 from ....tx.info import TxInfo
 from ....tx.info import TxInfo
-from ....color import blue, pink
+from ....color import pink
 from ....obj import NonNegativeInt
 from ....obj import NonNegativeInt
 
 
 from ...vm.tx.info import VmTxInfo, mmid_disp
 from ...vm.tx.info import VmTxInfo, mmid_disp
@@ -24,22 +24,28 @@ class TxInfo(VmTxInfo, TxInfo):
 		tx = self.tx
 		tx = self.tx
 		t = tx.txobj
 		t = tx.txobj
 		fs = """
 		fs = """
+			From:      {f}{f_mmid}
+			Amount:    {a} {c}
+			Gas limit: {G}
+			Sequence:  {N}
+			Memo:      {m}
+		""" if tx.is_swap else """
 			From:      {f}{f_mmid}
 			From:      {f}{f_mmid}
 			To:        {t}{t_mmid}
 			To:        {t}{t_mmid}
 			Amount:    {a} {c}
 			Amount:    {a} {c}
 			Gas limit: {G}
 			Gas limit: {G}
 			Sequence:  {N}
 			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),
 			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(),
 			a      = t['amt'].hl(),
 			N      = NonNegativeInt(t['sequence']).hl(),
 			N      = NonNegativeInt(t['sequence']).hl(),
 			m      = pink(tx.swap_memo) if tx.is_swap else None,
 			m      = pink(tx.swap_memo) if tx.is_swap else None,
 			c      = tx.proto.dcoin if tx.outputs else '',
 			c      = tx.proto.dcoin if tx.outputs else '',
 			G      = NonNegativeInt(tx.total_gas).hl(),
 			G      = NonNegativeInt(tx.total_gas).hl(),
 			f_mmid = mmid_disp(tx.inputs[0], nonmm_str),
 			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):
 	def format_abs_fee(self, iwidth, /, *, color=None):
 		return self.tx.fee.fmt(iwidth, color=color)
 		return self.tx.fee.fmt(iwidth, color=color)

+ 8 - 0
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):
 	async def set_gas(self, *, to_addr=None, force=False):
 		self.gas = self.dfl_gas
 		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()
 	async def make_txobj(self): # called by create_serialized()
 		acct_info = self.rpc.get_account_info(self.inputs[0].addr)
 		acct_info = self.rpc.get_account_info(self.inputs[0].addr)
 		self.txobj = {
 		self.txobj = {

+ 25 - 0
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 <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
+
+"""
+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

+ 5 - 3
mmgen/proto/rune/tx/signed.py

@@ -27,10 +27,12 @@ class Signed(Completed, TxBase.Signed):
 		b = tx.body.messages[0].body
 		b = tx.body.messages[0].body
 		i = tx.authInfo
 		i = tx.authInfo
 
 
+		from_k, amt_k = ('signer', 'coins') if self.is_swap else ('fromAddress', 'amount')
+
 		self.txobj = {
 		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),
 			'gas':      NonNegativeInt(i.fee.gasLimit),
 			'sequence': NonNegativeInt(i.signerInfos[0].sequence)}
 			'sequence': NonNegativeInt(i.signerInfos[0].sequence)}
 
 

+ 16 - 6
mmgen/proto/rune/tx/unsigned.py

@@ -34,18 +34,28 @@ class Unsigned(VmUnsigned, Completed, TxBase.Unsigned):
 			'chain_id':       d['chain_id']}
 			'chain_id':       d['chain_id']}
 
 
 	async def do_sign(self, o, wif):
 	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['from'],
 				o['to'],
 				o['to'],
 				o['amt'],
 				o['amt'],
 				o['gas'],
 				o['gas'],
 				o['account_number'],
 				o['account_number'],
 				o['sequence'],
 				o['sequence'],
-				wifkey = wif))
+				wifkey = wif)
+
+		tx = build_tx(self.cfg, self.proto, parms)
 		self.serialized = bytes(tx).hex()
 		self.serialized = bytes(tx).hex()
 		self.coin_txid = CoinTxID(tx.txid)
 		self.coin_txid = CoinTxID(tx.txid)
 		tx.verify_sig(self.proto, o['account_number'])
 		tx.verify_sig(self.proto, o['account_number'])

+ 2 - 2
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.serialized = {k:v if v is None else str(v) for k, v in self.txobj.items() if k != 'token_to'}
 		self.update_txid()
 		self.update_txid()
 
 
-	def update_txid(self):
+	def update_txid(self, data=None):
 		import json
 		import json
 		assert not is_hex_str(self.serialized), (
 		assert not is_hex_str(self.serialized), (
 			'update_txid() must be called only when self.serialized is not hex data')
 			'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):
 	async def process_cmdline_args(self, cmd_args, ad_f, ad_w):
 
 

+ 2 - 2
mmgen/swap/proto/thorchain/asset.py

@@ -23,7 +23,7 @@ class THORChainSwapAsset(SwapAsset):
 		'BCH':       _ad('Bitcoin Cash',            'BCH',  None,        'c',  True),
 		'BCH':       _ad('Bitcoin Cash',            'BCH',  None,        'c',  True),
 		'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',  True),
 		'ETH.AAVE':  _ad('Aave (ETH)',              None,   'ETH.AAVE',  None, True),
 		'ETH.AAVE':  _ad('Aave (ETH)',              None,   'ETH.AAVE',  None, True),
 		'ETH.DAI':   _ad('MakerDAO USD (ETH)',      None,   'ETH.DAI',   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),
 		'ETH.DPI':   _ad('DeFi Pulse Index (ETH)',  None,   'ETH.DPI',   None, True),
@@ -63,7 +63,7 @@ class THORChainSwapAsset(SwapAsset):
 		'ETH.YFI':   '0bc529c00c6401aef6d220be8c6ea1667f6ad93e',
 		'ETH.YFI':   '0bc529c00c6401aef6d220be8c6ea1667f6ad93e',
 	}
 	}
 
 
-	unsupported = ('DOGE', 'RUNE')
+	unsupported = ('DOGE',)
 
 
 	blacklisted = {}
 	blacklisted = {}
 
 

+ 26 - 12
mmgen/swap/proto/thorchain/thornode.py

@@ -58,7 +58,9 @@ class Thornode:
 				die(2, pp_fmt(data))
 				die(2, pp_fmt(data))
 			return 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(
 			in_data = get_data(
 				self.tx.send_asset.full_name,
 				self.tx.send_asset.full_name,
 				'THOR.RUNE',
 				'THOR.RUNE',
@@ -92,7 +94,8 @@ class Thornode:
 		out_coin = tx.recv_asset.short_name
 		out_coin = tx.recv_asset.short_name
 		in_amt = self.in_amt
 		in_amt = self.in_amt
 		out_amt = UniAmt(int(d['expected_amount_out']), from_unit='satoshi')
 		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:
 		if trade_limit:
 			from . import ExpInt4
 			from . import ExpInt4
@@ -121,7 +124,11 @@ class Thornode:
 
 
 		_amount_in_label = 'Amount in:'
 		_amount_in_label = 'Amount in:'
 		if deduct_est_fee:
 		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}')
 				in_amt -= UniAmt(f'{get_estimated_fee():.8f}')
 				out_amt *= (in_amt / self.in_amt)
 				out_amt *= (in_amt / self.in_amt)
 				_amount_in_label = 'Amount in (estimated):'
 				_amount_in_label = 'Amount in (estimated):'
@@ -129,7 +136,6 @@ class Thornode:
 				ymsg(f'Warning: unknown gas unit ‘{gas_unit}’, cannot estimate fee')
 				ymsg(f'Warning: unknown gas unit ‘{gas_unit}’, cannot estimate fee')
 
 
 		min_in_amt = UniAmt(int(d['recommended_min_amount_in']), from_unit='satoshi')
 		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')
 		elapsed_disp = format_elapsed_hr(d['expiry'], future_msg='from now')
 		fees = d['fees']
 		fees = d['fees']
 		fees_t = UniAmt(int(fees['total']), from_unit='satoshi')
 		fees_t = UniAmt(int(fees['total']), from_unit='satoshi')
@@ -137,19 +143,26 @@ class Thornode:
 		slip_pct_disp = str(fees['slippage_bps'] / 100) + '%'
 		slip_pct_disp = str(fees['slippage_bps'] / 100) + '%'
 		hdr = f'SWAP QUOTE (source: {self.rpc.host})'
 		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"""
 		return f"""
 {cyan(hdr)}
 {cyan(hdr)}
   Protocol:                      {blue(name)}
   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'])}]
   Quote expires:                 {pink(elapsed_disp)} [{make_timestr(d['expiry'])}]
   {_amount_in_label:<22}         {in_amt.hl()} {in_coin}
   {_amount_in_label:<22}         {in_amt.hl()} {in_coin}
   Expected amount out:           {out_amt.hl()} {out_coin}{trade_limit_disp}
   Expected amount out:           {out_amt.hl()} {out_coin}{trade_limit_disp}
   Rate:                          {(out_amt / in_amt).hl()} {out_coin}/{in_coin}
   Rate:                          {(out_amt / in_amt).hl()} {out_coin}/{in_coin}
   Reverse rate:                  {(in_amt / out_amt).hl()} {in_coin}/{out_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:
   Fees:
     Total:    {fees_t.hl()} {out_coin} ({pink(fees_pct_disp)})
     Total:    {fees_t.hl()} {out_coin} ({pink(fees_pct_disp)})
     Slippage: {pink(slip_pct_disp)}
     Slippage: {pink(slip_pct_disp)}
@@ -166,9 +179,10 @@ class Thornode:
 
 
 	@property
 	@property
 	def rel_fee_hint(self):
 	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):
 	def __str__(self):
 		from pprint import pformat
 		from pprint import pformat

+ 2 - 0
test/cmdtest_d/httpd/thornode/rpc.py

@@ -66,6 +66,8 @@ class ThornodeRPCServer(ThornodeServer):
 			res = {'code': 0, 'codespace': '', 'data': '', 'log': ''}
 			res = {'code': 0, 'codespace': '', 'data': '', 'log': ''}
 			if txhex.startswith('0A540A52'):
 			if txhex.startswith('0A540A52'):
 				res.update({'hash': '14463C716CF08A814868DB779156BCD85A1DF8EE49E924900A74482E9DEE132D'})
 				res.update({'hash': '14463C716CF08A814868DB779156BCD85A1DF8EE49E924900A74482E9DEE132D'})
+			elif txhex.startswith('0AC1010A'):
+				res.update({'hash': '17F9411E48542C0DCA4D40A0DD4A1795DE6D5791A873A27CBBDC1031FE8D1BC5'})
 		else:
 		else:
 			raise ValueError(f'‘{req_str}’: malformed query path')
 			raise ValueError(f'‘{req_str}’: malformed query path')
 
 

+ 4 - 0
test/cmdtest_d/include/cfg.py

@@ -44,6 +44,7 @@ cmd_groups_dfl = {
 	'ethdev':             ('CmdTestEthdev',            {}),
 	'ethdev':             ('CmdTestEthdev',            {}),
 	'ethbump':            ('CmdTestEthBump',           {}),
 	'ethbump':            ('CmdTestEthBump',           {}),
 	'rune':               ('CmdTestRune',              {}),
 	'rune':               ('CmdTestRune',              {}),
+	'runeswap':           ('CmdTestRuneSwap',          {}),
 	'xmrwallet':          ('CmdTestXMRWallet',         {}),
 	'xmrwallet':          ('CmdTestXMRWallet',         {}),
 	'xmr_autosign':       ('CmdTestXMRAutosign',       {}),
 	'xmr_autosign':       ('CmdTestXMRAutosign',       {}),
 }
 }
@@ -51,6 +52,7 @@ cmd_groups_dfl = {
 cmd_groups_extra = {
 cmd_groups_extra = {
 	'ethswap_eth':            ('CmdTestEthSwapEth',           {'modname': 'ethswap'}),
 	'ethswap_eth':            ('CmdTestEthSwapEth',           {'modname': 'ethswap'}),
 	'ethbump_ltc':            ('CmdTestEthBumpLTC',           {'modname': 'ethbump'}),
 	'ethbump_ltc':            ('CmdTestEthBumpLTC',           {'modname': 'ethbump'}),
+	'runeswap_rune':          ('CmdTestRuneSwapRune',         {'modname': 'runeswap'}),
 	'dev':                    ('CmdTestDev',                  {'modname': 'misc'}),
 	'dev':                    ('CmdTestDev',                  {'modname': 'misc'}),
 	'regtest_legacy':         ('CmdTestRegtestBDBWallet',     {'modname': 'regtest'}),
 	'regtest_legacy':         ('CmdTestRegtestBDBWallet',     {'modname': 'regtest'}),
 	'autosign_btc':           ('CmdTestAutosignBTC',          {'modname': 'autosign'}),
 	'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
 	'48': {}, # ethswap_eth
 	'49': {}, # autosign_automount
 	'49': {}, # autosign_automount
 	'50': {}, # rune
 	'50': {}, # rune
+	'57': {}, # runeswap
+	'58': {}, # runeswap_rune
 	'59': {}, # autosign_eth
 	'59': {}, # autosign_eth
 	'99': {}, # dummy
 	'99': {}, # dummy
 }
 }

+ 113 - 0
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 <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
+
+"""
+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)

+ 3 - 2
test/cmdtest_d/swap.py

@@ -151,8 +151,9 @@ class CmdTestSwapMethods:
 		if reload_quote:
 		if reload_quote:
 			t.expect('to continue: ', 'r')  # reload swap quote
 			t.expect('to continue: ', 'r')  # reload swap quote
 		t.expect('to continue: ', '\n')     # exit swap quote view
 		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('(y/N): ', 'n')            # add comment?
 		t.expect('view: ', 'y')             # view TX
 		t.expect('view: ', 'y')             # view TX
 		if expect:
 		if expect:

+ 1 - 0
test/test-release.d/cfg.sh

@@ -276,6 +276,7 @@ init_tests() {
 	d_rune="operations for THORChain RUNE using testnet"
 	d_rune="operations for THORChain RUNE using testnet"
 	t_rune="
 	t_rune="
 		- $cmdtest_py --coin=rune rune
 		- $cmdtest_py --coin=rune rune
+		- $cmdtest_py runeswap
 	"
 	"
 
 
 	d_xmr="Monero xmrwallet operations"
 	d_xmr="Monero xmrwallet operations"