RUNE user-level transaction support

Transaction sends and balance queries are done via the remote Thornode server
at ninerealms.com, so it’s recommended to proxy them via Tor, as shown below.

Sample create-send-sign workflow (assumes `autosign` set in cfg file):

    $ mmgen-txcreate --proxy=localhost:9050 -q --coin=rune 12ABCDEF:X:2,1.234

    remove device - insert - wait for signing - remove - insert

    $ mmgen-txsend --proxy=localhost:9050 -q

Check/refresh tracking wallet after sending:

    $ mmgen-tool --proxy=localhost:9050 --coin=rune twview interactive=1

Testing/demo:

    $ pip install pure-protobuf

    $ test/cmdtest.py --coin=rune --demo rune
This commit is contained in:
The MMGen Project 2025-06-12 12:48:51 +00:00
commit ef76cf6460
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
17 changed files with 406 additions and 21 deletions

View file

@ -51,8 +51,9 @@ class GlobalConstants(Lockable):
min_time_precision = 18
# must match CoinProtocol.coins
core_coins = ('btc', 'bch', 'ltc', 'eth', 'etc', 'zec', 'xmr')
core_coins = ('btc', 'bch', 'ltc', 'eth', 'etc', 'zec', 'xmr', 'rune')
rpc_coins = ('btc', 'bch', 'ltc', 'eth', 'etc', 'xmr')
remote_rpc_coins = ('rune',)
btc_fork_rpc_coins = ('btc', 'bch', 'ltc')
eth_fork_coins = ('eth', 'etc')

View file

@ -1 +1 @@
15.1.dev43
15.1.dev44

View file

@ -44,7 +44,7 @@ opts_data = {
-- -a, --autosign Create a transaction for offline autosigning (see
+ mmgen-autosign). The removable device is mounted and
+ unmounted automatically
-- -A, --fee-adjust= f Adjust transaction fee by factor 'f' (see below)
L- -A, --fee-adjust= f Adjust transaction fee by factor f (see below)
-- -B, --no-blank Don't blank screen before displaying {a_info}
-- -c, --comment-file=f Source the transaction's comment from file 'f'
b- -C, --fee-estimate-confs=c Desired number of confirmations for fee estimation
@ -53,7 +53,7 @@ opts_data = {
e- -D, --contract-data=D Path to file containing hex-encoded contract data
b- -E, --fee-estimate-mode=M Specify the network fee estimate mode. Choices:
+ {fe_all}. Default: {fe_dfl!r}
-- -f, --fee= f Transaction fee, as a decimal {cu} amount or as
L- -f, --fee= f Transaction fee, as a decimal {cu} amount or as
+ {fu} (an integer followed by {fl}).
+ See FEE SPECIFICATION below. If omitted, fee will be
+ calculated using network fee estimation.
@ -81,9 +81,12 @@ opts_data = {
-s -S, --list-assets List available swap assets
-- -v, --verbose Produce more verbose output
b- -V, --vsize-adj= f Adjust transaction's estimated vsize by factor 'f'
-s -x, --proxy=P Fetch the swap quote via SOCKS5h proxy P (host:port).
Ls -x, --proxy=P Fetch the swap quote via SOCKS5h proxy P (host:port).
+ Use special value env to honor *_PROXY environment
+ vars instead.
X- -x, --proxy=P Connect to remote server(s) via SOCKS5h proxy P
+ (host:port). Use special value env to honor *_PROXY
+ environment vars instead.
-- -y, --yes Answer 'yes' to prompts, suppress non-essential output
e- -X, --cached-balances Use cached balances
""",

View file

@ -41,7 +41,7 @@ opts_data = {
'options': """
-- -h, --help Print this help message
-- --, --longhelp Print help message for long (global) options
-- -A, --fee-adjust= f Adjust transaction fee by factor 'f' (see below)
L- -A, --fee-adjust= f Adjust transaction fee by factor f (see below)
-- -b, --brain-params=l,p Use seed length 'l' and hash preset 'p' for
+ brainwallet input
-- -B, --no-blank Don't blank screen before displaying {a_info}
@ -53,7 +53,7 @@ opts_data = {
-- -e, --echo-passphrase Print passphrase to screen when typing it
b- -E, --fee-estimate-mode=M Specify the network fee estimate mode. Choices:
+ {fe_all}. Default: {fe_dfl!r}
-- -f, --fee= f Transaction fee, as a decimal {cu} amount or as
L- -f, --fee= f Transaction fee, as a decimal {cu} amount or as
+ {fu} (an integer followed by {fl}).
+ See FEE SPECIFICATION below. If omitted, fee will be
+ calculated using network fee estimation.
@ -107,9 +107,12 @@ opts_data = {
-- -v, --verbose Produce more verbose output
b- -V, --vsize-adj= f Adjust transaction's estimated vsize by factor 'f'
e- -w, --wait Wait for transaction confirmation
-s -x, --proxy=P Fetch the swap quote via SOCKS5h proxy P (host:port).
Ls -x, --proxy=P Fetch the swap quote via SOCKS5h proxy P (host:port).
+ Use special value env to honor *_PROXY environment
+ vars instead.
X- -x, --proxy=P Connect to remote server(s) via SOCKS5h proxy P
+ (host:port). Use special value env to honor *_PROXY
+ environment vars instead.
e- -X, --cached-balances Use cached balances
-- -y, --yes Answer 'yes' to prompts, suppress non-essential output
-- -z, --show-hash-presets Show information on available hash presets

View file

@ -358,6 +358,8 @@ class UserOpts(Opts):
'e' - Ethereum or Ethereum code fork
'r' - coin supporting RPC
'h' - Bitcoin Cash
'L' - local RPC coin
'X' - remote RPC coin
'-' - other coin
Cmd codes:
'p' - proto required
@ -372,10 +374,11 @@ class UserOpts(Opts):
return ret(
coin = caps.coin_codes or (
None if coin is None else
['-', 'r', 'R', 'b', 'h'] if coin == 'bch' else
['-', 'r', 'R', 'b'] if coin in gc.btc_fork_rpc_coins else
['-', 'r', 'R', 'e'] if coin in gc.eth_fork_coins else
['-', 'r'] if coin in gc.rpc_coins else
['-', 'r', 'R', 'b', 'h', 'L'] if coin == 'bch' else
['-', 'r', 'R', 'b', 'L'] if coin in gc.btc_fork_rpc_coins else
['-', 'r', 'R', 'e', 'L'] if coin in gc.eth_fork_coins else
['-', 'r', 'L'] if coin in gc.rpc_coins else
['-', 'X'] if coin in gc.remote_rpc_coins else
['-']),
cmd = (
['-']

View file

@ -27,7 +27,7 @@ class mainnet(CoinProtocol.Secp256k1):
preferred_mmtypes = ('X',)
dfl_mmtype = 'X'
coin_amt = 'UniAmt'
max_tx_fee = 1 # TODO
max_tx_fee = 0.1
caps = ()
mmcaps = ('tw', 'rpc', 'rpc_init')
base_proto = 'THORChain'

View file

@ -13,9 +13,4 @@ proto.rune.tw.view: THORChain protocol base class for tracking wallet view class
"""
class THORChainTwView:
def gen_subheader(self, cw, color):
yield from super().gen_subheader(cw, color)
if self.proto.network == 'mainnet':
from ....color import red
yield red('For demonstration purposes only! DO NOT SPEND to these addresses!') # TODO
pass

60
mmgen/proto/rune/tx/base.py Executable file
View file

@ -0,0 +1,60 @@
#!/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.base: THORChain base transaction class
"""
from ....tx.base import Base as TxBase
class Base(TxBase):
dfl_fee = 2000000
dfl_gas = 800000
usr_contract_data = None
disable_fee_check = False
@property
def nondata_outputs(self):
return self.outputs
@property
def usr_fee(self):
return self.proto.coin_amt(self.dfl_fee, from_unit='satoshi')
def add_blockcount(self):
pass
def is_replaceable(self):
return False
def check_serialized_integrity(self):
if self.signed:
from .protobuf import RuneTx
tx = RuneTx.loads(bytes.fromhex(self.serialized))
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 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']:
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

@ -0,0 +1,29 @@
#!/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.completed: THORChain completed transaction class
"""
from ....tx import completed as TxBase
from ...vm.tx.completed import Completed as VmCompleted
from .base import Base
class Completed(VmCompleted, Base, TxBase.Completed):
@property
def total_gas(self):
return self.txobj['gas']
@property
def fee(self):
return self.proto.coin_amt(self.dfl_fee, from_unit='satoshi')

49
mmgen/proto/rune/tx/info.py Executable file
View file

@ -0,0 +1,49 @@
#!/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.info: THORChain transaction info class
"""
from ....tx.info import TxInfo, mmid_disp
from ....color import blue, pink
from ....obj import NonNegativeInt
class TxInfo(TxInfo):
def format_body(self, blockcount, nonmm_str, max_mmwid, enl, *, terse, sort):
tx = self.tx
t = tx.txobj
fs = """
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(
f = t['from'].hl(0),
t = t['to'].hl(0) if tx.outputs else blue('None'),
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'
def format_abs_fee(self, iwidth, /, *, color=None):
return self.tx.fee.fmt(iwidth, color=color)
def format_rel_fee(self):
return ''
def format_verbose_footer(self):
return ''

38
mmgen/proto/rune/tx/new.py Executable file
View file

@ -0,0 +1,38 @@
#!/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: THORChain new transaction class
"""
from ....tx import new as TxBase
from ...vm.tx.new import New as VmNew
from .base import Base
class New(VmNew, Base, TxBase.New):
async def get_fee(self, fee, outputs_sum, start_fee_desc):
return await self.twctl.get_balance(self.inputs[0].addr)
async def set_gas(self, *, to_addr=None, force=False):
self.gas = self.dfl_gas
async def make_txobj(self): # called by create_serialized()
acct_info = self.rpc.get_account_info(self.inputs[0].addr)
self.txobj = {
'from': self.inputs[0].addr,
'to': self.outputs[0].addr if self.outputs else None,
'amt': self.outputs[0].amt if self.outputs else self.swap_amt,
'gas': self.gas,
'account_number': int(acct_info['account_number']),
'sequence': int(acct_info['sequence']),
'chain_id': self.proto.chain_id}

51
mmgen/proto/rune/tx/online.py Executable file
View file

@ -0,0 +1,51 @@
#!/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.online: THORChain online signed transaction class
"""
from ....util import pp_msg, die
from ....tx import online as TxBase
from .signed import Signed
class OnlineSigned(Signed, TxBase.OnlineSigned):
async def test_sendable(self, txhex):
res = self.rpc.tx_op(bytes.fromhex(txhex), op='check_tx')
if res['code'] == 0:
return True
else:
pp_msg(res)
return False
async def send_checks(self):
pass
async def send_with_node(self, txhex):
res = self.rpc.tx_op(bytes.fromhex(txhex), op='broadcast_tx_sync') # broadcast_tx_async
if res['code'] == 0:
return res['hash'].lower()
else:
pp_msg(res)
die(2, 'Transaction send failed')
async def post_network_send(self, coin_txid):
return True
class Sent(TxBase.Sent, OnlineSigned):
pass
class AutomountOnlineSigned(TxBase.AutomountOnlineSigned, OnlineSigned):
pass
class AutomountSent(TxBase.AutomountSent, AutomountOnlineSigned):
pass

41
mmgen/proto/rune/tx/signed.py Executable file
View file

@ -0,0 +1,41 @@
#!/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.signed: THORChain signed transaction class
"""
from ....tx import signed as TxBase
from ....obj import CoinTxID, NonNegativeInt
from .completed import Completed
class Signed(Completed, TxBase.Signed):
desc = 'signed transaction'
def parse_txfile_serialized_data(self):
from .protobuf import RuneTx
tx = RuneTx.loads(bytes.fromhex(self.serialized))
b = tx.body.messages[0].body
i = tx.authInfo
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'),
'gas': NonNegativeInt(i.fee.gasLimit),
'sequence': NonNegativeInt(i.signerInfos[0].sequence)}
txid = CoinTxID(tx.txid)
assert txid == self.coin_txid, 'serialized txid doesn’t match txid in MMGen transaction file'
class AutomountSigned(TxBase.AutomountSigned, Signed):
pass

54
mmgen/proto/rune/tx/unsigned.py Executable file
View file

@ -0,0 +1,54 @@
#!/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.unsigned: THORChain unsigned transaction class
"""
from ....tx import unsigned as TxBase
from ....obj import CoinTxID, NonNegativeInt
from ....addr import CoinAddr
from ...vm.tx.unsigned import Unsigned as VmUnsigned
from .completed import Completed
class Unsigned(VmUnsigned, Completed, TxBase.Unsigned):
def parse_txfile_serialized_data(self):
d = self.serialized
self.txobj = {
'from': CoinAddr(self.proto, d['from']),
'to': CoinAddr(self.proto, d['to']) if d['to'] else None,
'amt': self.proto.coin_amt(d['amt']),
'gas': NonNegativeInt(d['gas']),
'account_number': NonNegativeInt(d['account_number']),
'sequence': NonNegativeInt(d['sequence']),
'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(
o['from'],
o['to'],
o['amt'],
o['gas'],
o['account_number'],
o['sequence'],
wifkey = wif))
self.serialized = bytes(tx).hex()
self.coin_txid = CoinTxID(tx.txid)
tx.verify_sig(self.proto, o['account_number'])
class AutomountUnsigned(TxBase.AutomountUnsigned, Unsigned):
pass

View file

@ -47,7 +47,7 @@ class CoinProtocol(MMGenObject):
'etc': proto_info('EthereumClassic', 4),
'zec': proto_info('Zcash', 2),
'xmr': proto_info('Monero', 5),
'rune': proto_info('THORChain', 2)
'rune': proto_info('THORChain', 4)
}
class Base(Lockable):

View file

@ -69,6 +69,19 @@ class ThornodeRPCServer(ThornodeServer):
'codespace': ''
}
}
elif m := re.search(r'/broadcast_tx_sync$', req_str):
assert method == 'POST'
txhex = environ['wsgi.input'].read(24).decode().removeprefix('tx=0x').upper()
if txhex.startswith('0A540A52'):
data = {
'result': {
'code': 0,
'codespace': '',
'data': '',
'hash': '14463C716CF08A814868DB779156BCD85A1DF8EE49E924900A74482E9DEE132D',
'log': ''
}
}
else:
raise ValueError(f'{req_str}’: malformed query path')

View file

@ -12,7 +12,7 @@
test.cmdtest_d.rune: THORChain RUNE tests for the cmdtest.py test suite
"""
from .include.common import dfl_sid
from .include.common import dfl_sid, dfl_words_file
from .httpd.thornode.rpc import ThornodeRPCServer
from .ethdev import CmdTestEthdevMethods
from .base import CmdTestBase
@ -42,6 +42,10 @@ class CmdTestRune(CmdTestEthdevMethods, CmdTestBase, CmdTestShared):
'tracking wallet and transaction operations',
('twview', 'viewing unspent outputs in tracking wallet'),
('bal_refresh', 'refreshing address balance in tracking wallet'),
('txcreate1', 'creating a transaction'),
('txsign1', 'signing the transaction'),
('txsend1_test', 'testing whether the transaction can be sent'),
('txsend1', 'sending the transaction'),
),
}
@ -81,6 +85,47 @@ class CmdTestRune(CmdTestEthdevMethods, CmdTestBase, CmdTestShared):
t.expect(self.menu_prompt, 'q')
return t
def txcreate1(self):
t = self.spawn('mmgen-txcreate', self.rune_opts + ['98831F3A:X:2,54.321'])
t.expect(self.menu_prompt, 'q')
t.expect('spend from: ', '3\n')
t.expect('(y/N): ', 'y') # add comment?
t.expect('Comment: ', 'RUNE Boy\n')
t.expect('view: ', 'y')
t.expect('to continue: ', 'z')
t.expect('(y/N): ', 'y') # save?
t.written_to_file('Unsigned transaction')
return t
def txsign1(self):
return self.txsign_ui_common(
self.spawn(
'mmgen-txsign',
self.rune_opts + [self.get_file_with_ext('rawtx'), dfl_words_file],
no_passthru_opts = ['coin']),
has_label = True)
def txsend1_test(self):
return self._txsend(add_args=['--test'])
def txsend1(self):
return self._txsend()
def _txsend(self, add_args=[]):
t = self.spawn(
'mmgen-txsend',
self.rune_opts + add_args + [self.get_file_with_ext('sigtx')],
no_passthru_opts = ['coin'])
t.expect('view: ', 'y')
t.expect('to continue: ', 'z')
t.expect('(y/N): ', 'n') # edit comment?
if add_args == ['--test']:
t.expect('can be sent')
else:
t.expect('to confirm: ', 'YES\n')
t.written_to_file('Sent transaction')
return t
def thornode_server_stop(self):
return CmdTestSwapMethods._thornode_server_stop(
self, attrname='thornode_server', name='thornode server')