RUNE swap support

Testing/demo:

    $ test/cmdtest.py --demo runeswap
This commit is contained in:
The MMGen Project 2025-06-15 09:17:02 +00:00
commit ec84abc907
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
15 changed files with 230 additions and 42 deletions

View file

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

View file

@ -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 d := self.proto.coin_amt(int(b.amount[0].amount), from_unit='satoshi') != o['amt']:
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(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')

View file

@ -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)

View file

@ -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 = {

25
mmgen/proto/rune/tx/new_swap.py Executable file
View file

@ -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

View file

@ -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)}

View file

@ -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'])

View file

@ -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):

View file

@ -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 = {}

View file

@ -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,6 +94,7 @@ 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')
if tx.proto.has_usr_fee:
gas_unit = d['gas_rate_units']
if trade_limit:
@ -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,6 +179,7 @@ class Thornode:
@property
def rel_fee_hint(self):
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

View file

@ -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')

View file

@ -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
}

113
test/cmdtest_d/runeswap.py Executable file
View file

@ -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)

View file

@ -151,6 +151,7 @@ class CmdTestSwapMethods:
if reload_quote:
t.expect('to continue: ', 'r') # reload swap quote
t.expect('to continue: ', '\n') # exit swap quote view
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?

View file

@ -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"