diff --git a/mmgen/addr.py b/mmgen/addr.py index 140bd3a5..75e33fff 100755 --- a/mmgen/addr.py +++ b/mmgen/addr.py @@ -147,12 +147,23 @@ class MMGenID(HiliteStr, InitErrors, MMGenObject): idx, ext = idx.split('-', 1) me.acct_idx, me.addr_idx = [MoneroIdx(e) for e in ext.split('/', 1)] else: + ext = None me = str.__new__(cls, f'{sid}:{mmtype}:{idx}') me.sid = SeedID(sid=sid) me.mmtype = proto.addr_type(mmtype) me.idx = AddrIdx(idx) me.al_id = str.__new__(AddrListID, me.sid + ':' + me.mmtype) # checks already done - me.sort_key = f'{me.sid}:{me.mmtype}:{me.idx:0{me.idx.max_digits}}' + if ext: + me.sort_key = '{}:{}:{:0{w1}}:{:0{w2}}:{:0{w2}}'.format( + me.sid, + me.mmtype, + me.idx, + me.acct_idx, + me.addr_idx, + w1 = me.idx.max_digits, + w2 = MoneroIdx.max_digits) + else: + me.sort_key = '{}:{}:{:0{w}}'.format(me.sid, me.mmtype, me.idx, w=me.idx.max_digits) me.proto = proto return me except Exception as e: diff --git a/mmgen/cfg.py b/mmgen/cfg.py index 80ab8df8..1db0ef46 100755 --- a/mmgen/cfg.py +++ b/mmgen/cfg.py @@ -240,6 +240,7 @@ class Config(Lockable): # Monero: monero_wallet_rpc_user = 'monero' monero_wallet_rpc_password = '' + monero_daemon = '' xmrwallet_compat = False priority = 0 diff --git a/mmgen/data/version b/mmgen/data/version index 4d738446..5c5c5fa3 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -16.1.dev14 +16.1.dev15 diff --git a/mmgen/main_tool.py b/mmgen/main_tool.py index 86e33e02..5d0f6149 100755 --- a/mmgen/main_tool.py +++ b/mmgen/main_tool.py @@ -34,6 +34,7 @@ opts_data = { -- -d, --outdir= d Specify an alternate directory 'd' for output -- -h, --help Print this help message -- --, --longhelp Print help message for long (global) options + x- -a, --autosign Operate on an autosigned transaction -- -e, --echo-passphrase Echo passphrase or mnemonic to screen upon entry -- -k, --use-internal-keccak-module Force use of the internal keccak module -- -K, --keygen-backend=n Use backend 'n' for public key generation. Options diff --git a/mmgen/opts.py b/mmgen/opts.py index d689facd..9ca0e238 100755 --- a/mmgen/opts.py +++ b/mmgen/opts.py @@ -308,6 +308,9 @@ class UserOpts(Opts): br --rpc-user=USER Authenticate to coin daemon using username USER br --rpc-password=PASS Authenticate to coin daemon using password PASS Rr --rpc-backend=backend Use backend 'backend' for JSON-RPC communications + mr --monero-wallet-rpc-user=USER Monero wallet RPC username + mr --monero-wallet-rpc-password=USER Monero wallet RPC password + mr --monero-daemon=HOST:PORT Connect to the monerod at HOST:PORT Rr --aiohttp-rpc-queue-len=N Use N simultaneous RPC connections with aiohttp -p --regtest=0|1 Disable or enable regtest mode -- --testnet=0|1 Disable or enable testnet @@ -361,6 +364,7 @@ class UserOpts(Opts): 'r' - local RPC coin 'X' - remote RPC coin 'x' - local or remote RPC coin + 'm' - Monero '-' - any coin Cmd codes: 'p' - proto required @@ -378,6 +382,7 @@ class UserOpts(Opts): ['-', 'r', 'R', 'b', 'h', 'x'] if coin == 'bch' else ['-', 'r', 'R', 'b', 'x'] if coin in gc.btc_fork_rpc_coins else ['-', 'r', 'R', 'e', 'x'] if coin in gc.eth_fork_coins else + ['-', 'r', 'x', 'm'] if coin == 'xmr' else ['-', 'r', 'x'] if coin in gc.local_rpc_coins else ['-', 'X', 'x'] if coin in gc.remote_rpc_coins else ['-']), diff --git a/mmgen/proto/xmr/tw/addresses.py b/mmgen/proto/xmr/tw/addresses.py new file mode 100755 index 00000000..363b065b --- /dev/null +++ b/mmgen/proto/xmr/tw/addresses.py @@ -0,0 +1,22 @@ +#!/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.xmr.tw.addresses: Monero protocol tracking wallet address list class +""" + +from ....tw.addresses import TwAddresses + +from .view import MoneroTwView + +class MoneroTwAddresses(MoneroTwView, TwAddresses): + + include_empty = True + has_used = True diff --git a/mmgen/proto/xmr/tw/ctl.py b/mmgen/proto/xmr/tw/ctl.py index 44350b47..26e0d287 100755 --- a/mmgen/proto/xmr/tw/ctl.py +++ b/mmgen/proto/xmr/tw/ctl.py @@ -12,8 +12,34 @@ proto.xmr.tw.ctl: Monero tracking wallet control class """ +from ....tw.ctl import write_mode from ....tw.store import TwCtlWithStore class MoneroTwCtl(TwCtlWithStore): tw_subdir = 'tracking-wallets' + use_cached_balances = True + + @write_mode + async def set_comment( + self, + addrspec, + comment = '', + *, + trusted_pair = None, + silent = False): + + from ....ui import keypress_confirm + add_timestr = keypress_confirm(self.cfg, 'Add timestamp to label?') + + m = trusted_pair[0].obj + from ....xmrwallet import op as xmrwallet_op + op = xmrwallet_op( + 'label', + self.cfg, + None, + None, + spec = f'{m.idx}:{m.acct_idx}:{m.addr_idx},{comment}', + compat_call = True) + await op.restart_wallet_daemon() + return await op.main(add_timestr=add_timestr, auto=True) diff --git a/mmgen/proto/xmr/tw/unspent.py b/mmgen/proto/xmr/tw/unspent.py new file mode 100755 index 00000000..0b1d6bea --- /dev/null +++ b/mmgen/proto/xmr/tw/unspent.py @@ -0,0 +1,23 @@ +#!/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.xmr.tw.unspent: Monero protocol tracking wallet unspent outputs class +""" + +from ....tw.addresses import TwAddresses + +from .view import MoneroTwView + +class MoneroTwUnspentOutputs(MoneroTwView, TwAddresses): + + hdr_lbl = 'spendable accounts' + desc = 'address balances' + include_empty = False diff --git a/mmgen/proto/xmr/tw/view.py b/mmgen/proto/xmr/tw/view.py new file mode 100755 index 00000000..90c3bd52 --- /dev/null +++ b/mmgen/proto/xmr/tw/view.py @@ -0,0 +1,81 @@ +#!/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.xmr.tw.view: Monero protocol base class for tracking wallet view classes +""" + +from ....xmrwallet import op as xmrwallet_op +from ....tw.view import TwView + +class MoneroTwView: + + class rpc: + caps = () + is_remote = False + + prompt_fs_repl = {'XMR': ( + (1, 'Filters: show [E]mpty addrs, [u]sed addrs, all [L]abels'), + (3, 'Actions: [q]uit menu, add [l]abel, [R]efresh balances:'))} + extra_key_mappings = { + 'u': 'd_showused', + 'R': 'a_sync_wallets'} + removed_key_mappings = { + 'D': 'i_addr_delete'} + + async def get_rpc_data(self): + from mmgen.tw.shared import TwMMGenID, TwLabel + + op = xmrwallet_op('dump_data', self.cfg, None, None, compat_call=True) + await op.restart_wallet_daemon() + wallets_data = await op.main() + + self.total = self.proto.coin_amt('0') + + def gen_addrs(): + for wdata in wallets_data: + bals_data = {i: {} for i in range(len(wdata['data'].accts_data['subaddress_accounts']))} + + for d in wdata['data'].bals_data.get('per_subaddress', []): + bals_data[d['account_index']].update({d['address_index']: d['unlocked_balance']}) + + for acct_idx, acct_data in enumerate(wdata['data'].addrs_data): + for addr_data in acct_data['addresses']: + addr_idx = addr_data['address_index'] + self.total += (bal := self.proto.coin_amt( + bals_data[acct_idx].get(addr_idx, 0), + from_unit = 'atomic')) + if self.include_empty or bal: + mmid = '{}:M:{}-{}/{}'.format( + wdata['seed_id'], + wdata['wallet_num'], + acct_idx, + addr_idx) + yield (TwMMGenID(self.proto, mmid), { + 'addr': addr_data['address'], + 'amt': bal, + 'recvd': bal, + 'is_used': addr_data['used'], + 'confs': 1, + 'lbl': TwLabel(self.proto, mmid + ' ' + addr_data['label'])}) + + return dict(gen_addrs()) + + class action(TwView.action): + + async def a_sync_wallets(self, parent): + from ....util import msg, msg_r + from ....tw.view import CUR_HOME, ERASE_ALL + msg('') + op = xmrwallet_op('sync', parent.cfg, None, None, compat_call=True) + await op.restart_wallet_daemon() + await op.main() + await parent.get_data() + msg_r(CUR_HOME + ERASE_ALL) diff --git a/mmgen/tw/view.py b/mmgen/tw/view.py index e9699421..d5f36362 100755 --- a/mmgen/tw/view.py +++ b/mmgen/tw/view.py @@ -192,10 +192,11 @@ class TwView(MMGenObject, metaclass=AsyncInit): async def __init__(self, cfg, proto): self.cfg = cfg self.proto = proto - self.rpc = await rpc_init(cfg, proto) + if have_rpc := 'rpc_init' in proto.mmcaps: + self.rpc = await rpc_init(cfg, proto) if self.has_wallet: from .ctl import TwCtl - self.twctl = await TwCtl(cfg, proto, mode='w') + self.twctl = await TwCtl(cfg, proto, mode='w', no_rpc=not have_rpc) self.amt_keys = {'amt':'iwidth', 'amt2':'iwidth2'} if self.has_amt2 else {'amt':'iwidth'} if repl_data := self.prompt_fs_repl.get(self.proto.coin): for repl in [repl_data] if isinstance(repl_data[0], int) else repl_data: @@ -441,7 +442,7 @@ class TwView(MMGenObject, metaclass=AsyncInit): yield 'Network: {}'.format(Green( self.proto.coin + ' ' + self.proto.chain_name.upper())) - if not self.rpc.is_remote: + if hasattr(self.rpc, 'blockcount'): yield 'Block {} [{}]'.format( self.rpc.blockcount.hl(color=color), make_timestr(self.rpc.cur_date)) @@ -778,7 +779,7 @@ class TwView(MMGenObject, metaclass=AsyncInit): a = 'for' if edited else 'added to' if comment else 'removed from', b = desc, c = ' edited' if edited else '')) - return True + return 'redraw' if parent.cfg.coin == 'XMR' else True else: await asyncio.sleep(3) parent.oneshot_msg = red('Label for {desc} could not be {action}'.format( diff --git a/mmgen/xmrwallet/__init__.py b/mmgen/xmrwallet/__init__.py index af95a4fe..71b85939 100755 --- a/mmgen/xmrwallet/__init__.py +++ b/mmgen/xmrwallet/__init__.py @@ -116,8 +116,8 @@ def op_cls(op_name): cls.name = op_name return cls -def op(op, cfg, infile, wallets, *, spec=None): - if cfg.compat if cfg.compat is not None else cfg.xmrwallet_compat: +def op(op, cfg, infile, wallets, *, spec=None, compat_call=False): + if compat_call or (cfg.compat if cfg.compat is not None else cfg.xmrwallet_compat): if cfg.wallet_dir: die(1, '--wallet-dir can not be specified in xmrwallet compatibility mode') from ..tw.ctl import TwCtl @@ -126,5 +126,8 @@ def op(op, cfg, infile, wallets, *, spec=None): cfg = Config({ '_clone': cfg, 'compat': True, + 'no_start_wallet_daemon': cfg.no_start_wallet_daemon or compat_call, + 'daemon': cfg.daemon or cfg.monero_daemon, + 'watch_only': cfg.watch_only or cfg.autosign or bool(cfg.autosign_mountpoint), 'wallet_dir': twctl_cls.get_tw_dir(cfg, cfg._proto)}) return op_cls(op)(cfg, uargs(infile, wallets, spec)) diff --git a/mmgen/xmrwallet/ops/__init__.py b/mmgen/xmrwallet/ops/__init__.py index 9ec1b595..71d4e7c9 100755 --- a/mmgen/xmrwallet/ops/__init__.py +++ b/mmgen/xmrwallet/ops/__init__.py @@ -111,12 +111,13 @@ class OpBase: Proxy: {blue(m[2] or 'None')} """, strip_char='\t', indent=indent)) - def mount_removable_device(self): + def mount_removable_device(self, registered=[]): if self.cfg.autosign: if not self.asi.device_inserted: die(1, 'Removable device not present!') - if self.do_umount: + if self.do_umount and not registered: atexit.register(lambda: self.asi.do_umount()) + registered.append(None) self.asi.do_mount() self.post_mount_action() diff --git a/mmgen/xmrwallet/ops/label.py b/mmgen/xmrwallet/ops/label.py index 7b6775aa..abac6f9a 100755 --- a/mmgen/xmrwallet/ops/label.py +++ b/mmgen/xmrwallet/ops/label.py @@ -29,7 +29,7 @@ class OpLabel(OpMixinSpec, OpWallet): opts = () wallet_offline = True - async def main(self, add_timestr='ask'): + async def main(self, add_timestr='ask', auto=False): gmsg('\n{a} label for wallet {b}, account #{c}, address #{d}'.format( a = 'Setting' if self.label else 'Removing', @@ -40,13 +40,13 @@ class OpLabel(OpMixinSpec, OpWallet): h = MoneroWalletRPC(self, self.source) h.open_wallet('source') - wallet_data = h.get_wallet_data() + wallet_data = h.get_wallet_data(print=not auto) max_acct = len(wallet_data.accts_data['subaddress_accounts']) - 1 if self.account > max_acct: die(2, f'{self.account}: requested account index out of bounds (>{max_acct})') - ret = h.print_acct_addrs(wallet_data, self.account) + ret = h.print_acct_addrs(wallet_data, self.account, silent=auto) if self.address_idx > len(ret) - 1: die(2, '{}: requested address index out of bounds (>{})'.format( @@ -60,21 +60,22 @@ class OpLabel(OpMixinSpec, OpWallet): (self.label + (f' [{make_timestr()}]' if add_timestr else '')) if self.label else '') - ca = CoinAddr(self.proto, addr['address']) - from . import addr_width - msg('\n {a} {b}\n {c} {d}\n {e} {f}'.format( - a = 'Address: ', - b = ca.hl(0) if self.cfg.full_address else ca.fmt(0, addr_width, color=True), - c = 'Existing label:', - d = pink(addr['label']) if addr['label'] else gray('[none]'), - e = 'New label: ', - f = pink(new_label) if new_label else gray('[none]'))) + if not auto: + ca = CoinAddr(self.proto, addr['address']) + from . import addr_width + msg('\n {a} {b}\n {c} {d}\n {e} {f}'.format( + a = 'Address: ', + b = ca.hl(0) if self.cfg.full_address else ca.fmt(0, addr_width, color=True), + c = 'Existing label:', + d = pink(addr['label']) if addr['label'] else gray('[none]'), + e = 'New label: ', + f = pink(new_label) if new_label else gray('[none]'))) op = 'remove' if not new_label else 'update' if addr['label'] else 'set' if addr['label'] == new_label: ymsg('\nLabel is unchanged, operation cancelled') - elif keypress_confirm(self.cfg, f' {op.capitalize()} label?'): + elif auto or keypress_confirm(self.cfg, f' {op.capitalize()} label?'): h.set_label(self.account, self.address_idx, new_label) ret = h.print_acct_addrs(h.get_wallet_data(print=False), self.account) label_chk = ret[self.address_idx]['label'] @@ -83,5 +84,6 @@ class OpLabel(OpMixinSpec, OpWallet): return False else: msg(cyan('\nLabel successfully {}'.format('set' if op == 'set' else op+'d'))) + return new_label else: ymsg('\nOperation cancelled by user request') diff --git a/mmgen/xmrwallet/ops/new.py b/mmgen/xmrwallet/ops/new.py index cae94baf..dcc25807 100755 --- a/mmgen/xmrwallet/ops/new.py +++ b/mmgen/xmrwallet/ops/new.py @@ -14,7 +14,7 @@ xmrwallet.ops.new: Monero wallet ops for the MMGen Suite from ...color import red, pink from ...util import msg, ymsg, make_timestr - +from ...obj import TwComment from ...ui import keypress_confirm from ..rpc import MoneroWalletRPC @@ -32,7 +32,7 @@ class OpNew(OpMixinSpec, OpWallet): h.open_wallet('Monero') desc = 'account' if self.account is None else 'address' - label = ( + label = TwComment( None if self.label == '' else '{} [{}]'.format(self.label or f'xmrwallet new {desc}', make_timestr())) diff --git a/mmgen/xmrwallet/ops/wallet.py b/mmgen/xmrwallet/ops/wallet.py index 24125c1b..184aae30 100755 --- a/mmgen/xmrwallet/ops/wallet.py +++ b/mmgen/xmrwallet/ops/wallet.py @@ -173,8 +173,10 @@ class OpWallet(OpBase): else: self.addr_data = self.kal.data - async def restart_wallet_daemon(self): - atexit.register(lambda: asyncio.run(self.stop_wallet_daemon())) + async def restart_wallet_daemon(self, registered=[]): + if not registered: + atexit.register(lambda: asyncio.run(self.stop_wallet_daemon())) + registered.append(None) await self.c.restart_daemon() async def stop_wallet_daemon(self): diff --git a/mmgen/xmrwallet/rpc.py b/mmgen/xmrwallet/rpc.py index 0549caed..6cde4c9c 100755 --- a/mmgen/xmrwallet/rpc.py +++ b/mmgen/xmrwallet/rpc.py @@ -110,11 +110,12 @@ class MoneroWalletRPC: msg(' Address: {}'.format(cyan(ret['base_address']))) return (ret['account_index'], ret['base_address']) - def print_acct_addrs(self, wallet_data, account): - msg('\n Addresses of account #{} ({}):'.format( - account, - wallet_data.accts_data['subaddress_accounts'][account]['label'])) - msg('\n'.join(gen_acct_addr_info(self, wallet_data, account, indent=' '))) + def print_acct_addrs(self, wallet_data, account, silent=False): + if not silent: + msg('\n Addresses of account #{} ({}):'.format( + account, + wallet_data.accts_data['subaddress_accounts'][account]['label'])) + msg('\n'.join(gen_acct_addr_info(self, wallet_data, account, indent=' '))) return wallet_data.addrs_data[account]['addresses'] def create_new_addr(self, account, label): diff --git a/test/cmdtest_d/include/cfg.py b/test/cmdtest_d/include/cfg.py index 0cee7ce9..f124eaab 100755 --- a/test/cmdtest_d/include/cfg.py +++ b/test/cmdtest_d/include/cfg.py @@ -49,6 +49,7 @@ cmd_groups_dfl = { 'runeswap': gd('CmdTestRuneSwap', {}), 'xmrwallet': gd('CmdTestXMRWallet', {}), 'xmr_autosign': gd('CmdTestXMRAutosign', {}), + 'xmr_compat': gd('CmdTestXMRCompat', {'modname': 'xmr_autosign'}), } cmd_groups_extra = { diff --git a/test/cmdtest_d/xmr_autosign.py b/test/cmdtest_d/xmr_autosign.py index 19ec9e41..f3f1283f 100755 --- a/test/cmdtest_d/xmr_autosign.py +++ b/test/cmdtest_d/xmr_autosign.py @@ -230,6 +230,8 @@ class CmdTestXMRAutosign(CmdTestXMRWallet, CmdTestAutosignThreaded): async def fund_alice1(self): return await self.fund_alice(wallet=1) + fund_alice1b = fund_alice1 + async def check_bal_alice1(self): return await self.check_bal_alice(wallet=1) @@ -492,3 +494,60 @@ class CmdTestXMRAutosignNoCompat(CmdTestXMRAutosign): Monero autosigning operations (non-xmrwallet compat mode) """ compat = False + +class CmdTestXMRCompat(CmdTestXMRAutosign): + """ + Monero autosigning operations (compat mode) + """ + menu_prompt = 'efresh balances:\b' + + cmd_group = ( + ('autosign_setup', 'autosign setup with Alice’s seed'), + ('autosign_xmr_setup', 'autosign setup (creation of Monero signing wallets)'), + ('create_watchonly_wallets', 'creating Alice’s watch-only wallets'), + ('gen_kafile_miner', 'generating key-address file for Miner'), + ('create_wallet_miner', 'creating Monero wallet for Miner'), + ('mine_initial_coins', 'mining initial coins'), + ('fund_alice1', 'sending funds to Alice (wallet #1)'), + ('mine_blocks', 'mining some blocks'), + ('alice_listaddresses', 'performing operations on Alice’s tracking wallets (listaddresses)'), + ('fund_alice1b', 'sending funds to Alice (wallet #1)'), + ('mine_blocks', 'mining some blocks'), + ('alice_twview', 'performing operations on Alice’s tracking wallets (twview)'), + ) + + def __init__(self, cfg, trunner, cfgs, spawn): + super().__init__(cfg, trunner, cfgs, spawn) + if trunner is None: + return + self.alice_opts = [ + '--alice', + '--coin=xmr', + '--monero-wallet-rpc-password=passwOrd', + f'--monero-daemon=localhost:{self.users["alice"].md.rpc_port}'] + + def create_watchonly_wallets(self): + return self._create_wallets() + + async def mine_blocks(self): + self.spawn(msg_only=True) + return await self.mine(10) + + def alice_listaddresses(self): + return self._alice_twops('listaddresses', 2, 'y', r'Primary account.*1\.234567891234') + + def alice_twview(self): + return self._alice_twops('twview', 1, 'n', r'New Label.*2\.469135782468') + + def _alice_twops(self, op, addr_num, add_timestr_resp, expect_str): + self.insert_device_online() + t = self.spawn('mmgen-tool', self.alice_opts + self.autosign_opts + [op, 'interactive=1']) + t.expect(self.menu_prompt, 'l') + t.expect('main menu): ', str(addr_num)) + t.expect(': ', 'New Label\n') + t.expect('(y/N): ', add_timestr_resp) + t.expect(self.menu_prompt, 'R') + t.expect(expect_str, regex=True) + t.expect(self.menu_prompt, 'q') + self.remove_device_online() + return t