new mmgen-remote utility

Command invocation syntax is the same as for the `mmgen-tool` utility.

Currently, only THORChain public endpoints are supported

Help:

    $ mmgen-remote --coin=rune --help

Sample invocation:

    $ mmgen-remote --log --proxy=localhost:9050 --coin=rune lastblock coin=btc

Testing:

    $ test/cmdtest.py -e remote
This commit is contained in:
The MMGen Project 2026-10-02 10:02:32 +00:00
commit 8a23f9867f
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
17 changed files with 414 additions and 7 deletions

16
cmds/mmgen-remote Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env python3
#
# MMGen Wallet, a terminal-based cryptocurrency wallet
# Copyright (C)2013-2026 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
"""
mmgen-remote: Retrieve data from a remote JSON-RPC server
"""
from mmgen.main import launch
launch(mod='remote')

View file

@ -81,6 +81,7 @@ class GlobalConstants(Lockable):
'passchg': _cc(False, False, False, None, [], 'lmw'),
'passgen': _cc(False, False, False, None, [], 'lmw'),
'regtest': _cc(True, True, True, None, ['tw'], 'lmw'),
'remote': _cc(True, True, True, None, [], 'lmw'),
'seedjoin': _cc(False, False, False, None, [], 'lmw'),
'seedsplit': _cc(False, False, False, None, [], 'lmw'),
'subwalletgen': _cc(False, False, False, None, [], 'lmw'),

View file

@ -1 +1 @@
September 2026
October 2026

View file

@ -1 +1 @@
16.3.0dev11
16.3.0dev12

46
mmgen/help/remote.py Executable file
View file

@ -0,0 +1,46 @@
#!/usr/bin/env python3
#
# MMGen Wallet, a terminal-based cryptocurrency wallet
# Copyright (C)2013-2026 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
"""
help.remote: command help for the ‘mmgen-remote’ utility
"""
from ..tool.help import gen_main_help
from ..cfg import gc
from ..color import yellow
from ..util import fmt_with_color
def help(proto, cfg):
warning = """
Communicating with a remote server can harm your privacy!
To improve anonymity, proxy requests via Tor or I2P
"""
footer = f"""
For individual command usage, invoke ‘{gc.prog_name} --coin={cfg.coin.lower()} help COMMAND’
"""
match cfg.coin:
case 'RUNE':
from ..proto.rune.rpc.remote import THORChainRemoteRPCClient as rpc
clist = '\n ' + '\n'.join(gen_main_help(
mods_data = rpc.mods,
get_mod_cls_func = lambda x: rpc,
indent = ' '))
case _:
clist = f'No configured commands for coin {cfg.coin}\n Supported coins: RUNE'
warning = ''
footer = ''
return '{a}\nCOMMANDS:\n {b}{c}\n'.format(
a = '\n' + fmt_with_color(warning.strip(), yellow) if warning else '',
b = clist,
c = '\n' + footer.strip() if footer else '')

87
mmgen/main_remote.py Executable file
View file

@ -0,0 +1,87 @@
#!/usr/bin/env python3
#
# MMGen Wallet, a terminal-based cryptocurrency wallet
# Copyright (C)2013-2026 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
"""
mmgen-remote: Retrieve data from a remote JSON-RPC server
"""
from .cfg import Config
from .util import Msg, die, fmt_list, async_run
opts_data = {
'filter_codes': ['-'],
'text': {
'desc': 'Retrieve data from a remote JSON-RPC server',
'usage': '[OPTS] COMMAND ARGS',
'options': """
-- -h, --help Print this help message
-- --, --longhelp Print help message for long (global) options
-- -d, --outdir=D Output logfile to directory ‘D’ instead of working dir
-- -l, --log Log output to file
X- -s, --swap-proto Swap protocol to use (Default: {x_dfl}, Choices: {x_all})
X- -x, --proxy=P Connect to remote server(s) via SOCKS5h proxy P (host:port)
+ Use special value ‘env’ to honor *_PROXY env vars instead.
""",
'notes': '{n_rc}',
},
'code': {
'options': lambda cfg, proto, help_notes, s: s.format(
x_all = fmt_list(cfg._autoset_opts['swap_proto'].choices, fmt='no_spc'),
x_dfl = cfg._autoset_opts['swap_proto'].choices[0]),
'notes': lambda cfg, help_mod, help_notes, s: s.format(
n_rc = help_mod('remote'))
}
}
cfg = Config(opts_data=opts_data)
def log(text, cmd, args, kwargs):
args_disp = '-'.join(args) + '-'.join(f'{k}={v}' for k,v in kwargs.items())
fn = 'mmgen-remote-{a}-{b}{c}.json'.format(
a = cfg.coin,
b = cmd.replace('_','-'),
c = '-' + args_disp if args_disp else '')
from .fileutil import write_data_to_file
write_data_to_file(cfg, fn, text)
async def main():
from .tool import get_cmds, process_args
match cfg.coin:
case 'RUNE':
from .proto.rune.rpc.remote import THORChainRemoteRPCClient as cls
cmds = get_cmds(cls.mods)
case _:
cmds = ()
match cfg._args:
case ['help']:
from .help.remote import help
Msg(help(cfg._proto, cfg))
case ['help', cmd]:
if cmd in cmds:
from .tool.help import gen_tool_cmd_usage
Msg('\n'.join(gen_tool_cmd_usage(None, cmd, lambda x: cls)))
else:
fs = '‘{}’: invalid command\n\nSupported commands:\n{}'
die(1, fs.format(cmd, fmt_list(cmds, fmt='col', indent=' ' * 4)))
case [cmd, *cmd_args] if cmd in cmds:
args, kwargs = process_args(cmd, cmd_args, cls)
res = getattr(cls(cfg, cfg._proto), cmd)(*args, **kwargs)
import json
from .ui import colorize_json
text = json.dumps(res, indent=2)
cfg._util.stdout_or_pager_nl(colorize_json(text) if cfg.color else text)
if cfg.log:
log(text, cmd, args, kwargs)
case _:
cfg._usage()
async_run(cfg, main)

View file

@ -50,6 +50,7 @@ class mainnet(CoinProtocol.Secp256k1):
rpc_remote_rest_params = {'host': Hostname('gateway.liquify.com/chain/thorchain_api')}
rpc_remote_rpc_params = {'host': Hostname('gateway.liquify.com/chain/thorchain_rpc')}
rpc_remote_swap_params = {'host': Hostname('gateway.liquify.com/chain/thorchain_api')}
rpc_remote_midgard_params = {'host': Hostname('gateway.liquify.com/chain/thorchain_midgard')}
def decode_addr(self, addr):
hrp, data = bech32.bech32_decode(addr)
@ -82,3 +83,4 @@ class regtest(testnet): # regtest is deprecated testnet
'verify': False}
rpc_remote_rpc_params = rpc_remote_rest_params
rpc_remote_swap_params = rpc_remote_rest_params | {'host': Hostname('localhost:18900')}
rpc_remote_midgard_params = rpc_remote_rest_params | {'host': Hostname('localhost:19000')}

View file

@ -20,10 +20,15 @@ from ....rpc.remote import RemoteRPCClient
# throws exception on error:
def process_response(json_response, errmsg):
data = json.loads(json_response)
if data['result'] is None:
from ....util import die
die('RPCFailure', errmsg)
return data['result']
if isinstance(data, dict) and 'result' in data:
if data['result'] is None:
from ....util import die
die('RPCFailure', errmsg)
return data['result']
return data
def data_for_coin(ret, coin):
return next(iter([e for e in ret if e['chain'] == coin.upper()] or [None])) if coin else ret
# HTTP POST, JSON-RPC response:
class ThornodeRemoteRPCClient(RemoteJSONClient):
@ -35,10 +40,26 @@ class ThornodeRemoteRESTClient(RemoteJSONClient):
params = 'rpc_remote_rest_params'
timeout = 15
class ThornodeRemoteMidgardClient(RemoteJSONClient):
params = 'rpc_remote_midgard_params'
timeout = 30
class THORChainRemoteRPCClient(RemoteRPCClient):
"retrieve data from a remote THORChain JSON-RPC endpoint"
server_proto = 'THORChain'
mods = {
'get': (
'ping',
'health',
'pools',
'lastblock',
'mimir',
'actions',
'inbound_addrs',
'acct_info',
'balance',
'tx_info')}
def __init__(self, cfg, proto):
for k, v in proto.rpc_remote_params.items():
@ -47,6 +68,51 @@ class THORChainRemoteRPCClient(RemoteRPCClient):
self.caps = ('lbl_id',)
self.rest_api = ThornodeRemoteRESTClient(cfg, proto)
self.rpc_api = ThornodeRemoteRPCClient(cfg, proto)
self.midgard_api = ThornodeRemoteMidgardClient(cfg, proto)
def ping(self):
"ping the remote REST endpoint"
return process_response(
self.rest_api.get(path='/thorchain/ping'),
errmsg = f'pinging remote endpoint {self.rest_api.host} failed')
def health(self):
"check the health of the THORChain network"
return process_response(
self.midgard_api.get(path='/v2/health'),
errmsg = f'getting health status from {self.midgard_api.host} failed')
def pools(self):
"get available pools on the THORChain network"
return process_response(
self.midgard_api.get(path='/v2/pools'),
errmsg = f'getting pools from {self.midgard_api.host} failed')
def lastblock(self, *, coin: str=None): # noqa: RUF013
"get last block info for all coins or a given coin"
return data_for_coin(process_response(
self.rest_api.get(path='/thorchain/lastblock'),
errmsg = 'unable to retrieve lastblock data'), coin)
def mimir(self):
"get Mimir constants for the THORChain network"
return process_response(
self.rest_api.get(path='/thorchain/mimir'),
errmsg = 'get mimir info failed')
def actions(self, *, addr: str=None, txid: str=None): # noqa: RUF013
"get actions for a given address or TxID"
assert addr or txid, 'one of addr or txid must be specified'
qs = '&'.join(f'{k}={v}' for k, v in (('address', addr), ('txid', txid)) if v)
return process_response(
self.midgard_api.get(path=f'/v2/actions?{qs}'),
errmsg = 'get actions info failed')
def inbound_addrs(self, *, coin: str=None): # noqa: RUF013
"get current inbound (vault) addresses"
return data_for_coin(process_response(
self.rest_api.get(path='/thorchain/inbound_addresses'),
errmsg = 'unable to retrieve inbound (vault) addresses'), coin)
def acct_info(self, addr: str, *, block=None):
"get account information for a given address"

View file

@ -60,8 +60,10 @@ class Util:
if cfg.pager:
from .ui import do_pager
self.stdout_or_pager = do_pager
self.stdout_or_pager_nl = do_pager
else:
self.stdout_or_pager = Msg_r
self.stdout_or_pager_nl = Msg
def compare_chksums(
self,
@ -192,6 +194,17 @@ def fmt(s, *, indent='', strip_char=None, append='\n'):
+ ('\n'+indent).join(l.lstrip(strip_char) for l in s.strip().splitlines())
+ append)
def fmt_with_color(s, color_func, *, indent='', strip_char=None, append='\n'):
"""
de-indent multiple lines of text, or indent with specified string, with color
each line is colored separately as a workaround for a ‘less’ pager ANSI color bug
"""
return (
indent
+ ('\n'+indent).join(color_func(l.lstrip(strip_char)) for l in s.strip().splitlines())
+ append)
def fmt_list(iterable, *, fmt='dfl', indent='', conv=None):
"pretty-format a list"
_conv, sep, lq, rq = {

View file

@ -131,6 +131,7 @@ scripts =
cmds/mmgen-passchg
cmds/mmgen-passgen
cmds/mmgen-regtest
cmds/mmgen-remote
cmds/mmgen-seedjoin
cmds/mmgen-seedsplit
cmds/mmgen-subwalletgen

View file

@ -0,0 +1,68 @@
#!/usr/bin/env python3
#
# MMGen Wallet, a terminal-based cryptocurrency wallet
# Copyright (C)2013-2026 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.httpd.thornode.midgard: Thornode Midgard HTTP server
"""
import re, json
from wsgiref.util import request_uri
from . import ThornodeServer
actions_info = {
'actions': [{
'date': '987654321',
'height': '1234',
},
],
'count': '1',
'meta': {
'nextPageToken': '123456789',
'prevPageToken': '123456788'}}
health_info = {
'database': True,
'inSync': True,
'scannerHeight': '987654321'}
class ThornodeMidgardServer(ThornodeServer):
port = 19000
name = 'thornode Midgard server'
def make_response_body(self, method, environ):
class responses:
# pylint: disable=unsubscriptable-object
def health(m):
return health_info
def actions(m):
assert m[1] in ( # FIXME - for now, just check well-formedness of query string
'?address=thor1abcdefg',
'?txid=deadbeef',
'?address=thor1abcdefg&txid=deadbeef')
return actions_info
pat_info = (
('health', r'/v2/health'),
('actions', r'/v2/actions?(.*)'))
req_str = request_uri(environ)
for name, pat in pat_info:
if m := re.search(pat, req_str):
assert method == 'GET'
res = getattr(responses, name)(m)
return json.dumps({'result': res}).encode()
raise ValueError(f'‘{req_str}’: malformed query path')

View file

@ -17,6 +17,19 @@ from wsgiref.util import request_uri
from . import ThornodeServer
ping_info = {'ping': 'pong'}
lastblock_info = [{
'chain': 'BTC',
'last_observed_in': 969302,
'last_signed_out': 28043381,
'thorchain': 28043387
}, {
'chain': 'ETH',
'last_observed_in': 26090346,
'last_signed_out': 28043381,
'thorchain': 28043387}]
class ThornodeRPCServer(ThornodeServer):
port = 18800
name = 'thornode RPC server'
@ -74,7 +87,20 @@ class ThornodeRPCServer(ThornodeServer):
res.update({'hash': 'BD30B8FD9FC278A26E7A81ABB36C4709260DE64087EB578900BD23CEBF11D1A2'})
return res
def ping(m, length):
return ping_info
def lastblock(m, length):
return lastblock_info
def inbound_addrs(m, length):
with open('test/ref/thorchain/inbound.json') as fh:
return json.loads(fh.read())
pat_info = ( # these are matched in order, so more specific patterns must go first
('ping', 'GET', r'/thorchain/ping'),
('lastblock', 'GET', r'/thorchain/lastblock(.*)'),
('inbound_addrs', 'GET', r'/thorchain/inbound_addresses'),
('balance', 'GET', r'/bank/balances/(\S+)'),
('acct_info', 'GET', r'/auth/accounts/(\S+)'),
('tx_info', 'POST', r'/tx$'),

View file

@ -43,6 +43,7 @@ cmd_groups_dfl = {
'regtest': gd('CmdTestRegtest', {}),
'op_return': gd('CmdTestOpReturn', {}),
'swap': gd('CmdTestSwap', {}),
'remote': gd('CmdTestRemote', {}),
'ethswap': gd('CmdTestEthSwap', {}),
# 'chainsplit': gd('CmdTestChainsplit', {}),
'ethdev': gd('CmdTestEthdev', {}),

72
test/cmdtest_d/remote.py Executable file
View file

@ -0,0 +1,72 @@
#!/usr/bin/env python3
#
# MMGen Wallet, a terminal-based cryptocurrency wallet
# Copyright (C)2013-2026 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.remote: THORChain remote RPC tests for the cmdtest.py test suite
"""
from .base import CmdTestBase
from .swap import CmdTestSwapMethods
from .rune import CmdTestRuneMethods
class CmdTestRemote(CmdTestBase, CmdTestSwapMethods, CmdTestRuneMethods):
'fetching data from remote JSON-RPC server'
networks = ('btc',)
tmpdir_nums = [37]
thornode_servers = ('rpc', 'midgard')
cmd_group = (
('ping', 'pinging remote REST server'),
('health', 'checking health of THORChain network'),
('inbounds', 'getting inbound addresses (all coins)'),
('inbound_ltc', 'getting inbound address for LTC'),
('lastblock', 'getting lastblock info'),
('lastblock_btc', 'getting lastblock info for BTC'),
('actions_addr', 'getting actions for address'),
('actions_txid', 'getting actions for txid'),
('actions_addr_txid', 'getting actions for txid and addr'),
('stop_thornode_servers','stopping the Thornode RPC and Midgard servers'))
def __init__(self, cfg, trunner, cfgs, spawn):
super().__init__(cfg, trunner, cfgs, spawn)
if trunner is None:
return
self.start_thornode_servers()
def ping(self, *, asset=None):
return self._rune_remote('ping')
def health(self, *, asset=None):
return self._rune_remote('health')
def inbounds(self):
return self._rune_remote('inbound_addrs')
def inbound_ltc(self):
return self._rune_remote('inbound_addrs', ['coin=ltc'])
def lastblock(self, *, asset=None):
return self._rune_remote('lastblock')
def lastblock_btc(self, *, asset=None):
return self._rune_remote('lastblock', ['coin=btc'])
def actions_addr(self, *, asset=None):
return self._rune_remote('actions', ['addr=thor1abcdefg'])
def actions_txid(self, *, asset=None):
return self._rune_remote('actions', ['txid=deadbeef'])
def actions_addr_txid(self, *, asset=None):
return self._rune_remote('actions', ['addr=thor1abcdefg', 'txid=deadbeef'])
def stop_servers(self):
self.stop_thornode_servers(['rpc','midgard'])

View file

@ -16,7 +16,7 @@ from hashlib import md5
from mmgen.fileutil import get_data_from_file
from .include.common import dfl_words_file
from .include.common import dfl_words_file, cleanup_env
from .include.proxy import TestProxy
from .ethdev import CmdTestEthdevMethods
from .base import CmdTestBase
@ -77,6 +77,12 @@ class CmdTestRuneMethods:
return 'ok'
return t
def _rune_remote(self, cmd, cmd_args=(), color=True):
return self.spawn(
'mmgen-remote',
['--coin=rune', '--regtest=1', f'--color={int(color)}'] + [cmd] + list(cmd_args),
env = cleanup_env(self.cfg))
class CmdTestRune(CmdTestRuneMethods, CmdTestEthdevMethods, CmdTestBase, CmdTestShared):
'THORChain RUNE tracking wallet and transacting operations'
networks = ('rune',)

View file

@ -0,0 +1 @@
[{"chain":"BCH","pub_key":"thorpub1addwnpepqvg02tp8umuqp36wy5ptpeqavkdu58gk0z8e7amnjyh3cpslfq9fqxhlmvp","address":"qpxmqf20pnzywzmjevclquekjzlvq5hx25s4ucj53r","halted":false,"global_trading_paused":false,"chain_trading_paused":false,"chain_lp_actions_paused":false,"observed_fee_rate":"2","gas_rate":"3","gas_rate_units":"satsperbyte","outbound_tx_size":"1500","outbound_fee":"74450","dust_threshold":"10000"},{"chain":"BTC","pub_key":"thorpub1addwnpepqvg02tp8umuqp36wy5ptpeqavkdu58gk0z8e7amnjyh3cpslfq9fqxhlmvp","address":"bc1qfkcz2ncvc3rskuktx8c8xd5shmq99ej45gq8wu","halted":false,"global_trading_paused":false,"chain_trading_paused":false,"chain_lp_actions_paused":false,"observed_fee_rate":"4","gas_rate":"6","gas_rate_units":"satsperbyte","outbound_tx_size":"1000","outbound_fee":"2120","dust_threshold":"1000"},{"chain":"ETH","pub_key":"thorpub1addwnpepqvg02tp8umuqp36wy5ptpeqavkdu58gk0z8e7amnjyh3cpslfq9fqxhlmvp","address":"0x41c88d3c0eda3f4df0bb4e5d0f4c81cc0232902c","router":"0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146","halted":false,"global_trading_paused":false,"chain_trading_paused":false,"chain_lp_actions_paused":false,"observed_fee_rate":"10","gas_rate":"15","gas_rate_units":"gwei","outbound_tx_size":"100000","outbound_fee":"9190","dust_threshold":"1000"},{"chain":"LTC","pub_key":"thorpub1addwnpepqvg02tp8umuqp36wy5ptpeqavkdu58gk0z8e7amnjyh3cpslfq9fqxhlmvp","address":"rltc1qlvwym7fha5cuvs0c2vy2l86r7a8ayt8chwqsyr","halted":false,"global_trading_paused":false,"chain_trading_paused":false,"chain_lp_actions_paused":false,"observed_fee_rate":"74","gas_rate":"111","gas_rate_units":"satsperbyte","outbound_tx_size":"250","outbound_fee":"355075","dust_threshold":"100000"}]

View file

@ -311,6 +311,7 @@ init_tests() {
t_rune="
- $cmdtest_py --coin=rune rune
- $cmdtest_py --coin=ltc runeswap
- $cmdtest_py remote
"
[ "$SOC" ] && xmr_env=(