From de77f9c27d5413ec79480dedc490fbd2835ce389 Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Mon, 24 Apr 2023 13:23:45 +0000 Subject: [PATCH] Monero offline transaction autosigning Cold signing of Monero transactions with maximum convenience. For general information about Monero offline signing, see: https://monerodocs.org/cold-storage/offline-transaction-signing/ The MMGen implementation automates the workflow described in that document, with some minor changes. A unique security feature is that signing wallets are created afresh in volatile memory for each transacting session and thus disappear when the signing machine is powered down. Documentation: $ mmgen-xmrwallet --help # scroll to OFFLINE AUTOSIGNING Testing: $ test/test.py --coin=xmr -e xmr_autosign --- mmgen/autosign.py | 127 +++++- mmgen/data/version | 2 +- mmgen/exception.py | 1 + mmgen/help/xmrwallet.py | 270 +++++++++++- mmgen/main_autosign.py | 16 +- mmgen/main_xmrwallet.py | 63 ++- mmgen/proto/xmr/daemon.py | 6 +- mmgen/proto/xmr/params.py | 1 + mmgen/xmrwallet.py | 689 ++++++++++++++++++++++++++++-- test/test-release.d/cfg.sh | 5 +- test/test_py_d/cfg.py | 2 + test/test_py_d/ts_autosign.py | 2 + test/test_py_d/ts_xmr_autosign.py | 281 ++++++++++++ test/test_py_d/ts_xmrwallet.py | 68 ++- 14 files changed, 1437 insertions(+), 96 deletions(-) create mode 100755 test/test_py_d/ts_xmr_autosign.py diff --git a/mmgen/autosign.py b/mmgen/autosign.py index a16e5e81..694174fd 100755 --- a/mmgen/autosign.py +++ b/mmgen/autosign.py @@ -9,7 +9,7 @@ # https://gitlab.com/mmgen/mmgen """ -autosign: Auto-sign MMGen transactions and message files +autosign: Auto-sign MMGen transactions, message files and XMR wallet output files """ import sys,os,asyncio @@ -17,7 +17,7 @@ from subprocess import run,PIPE,DEVNULL from collections import namedtuple from .cfg import Config -from .util import msg,msg_r,ymsg,rmsg,gmsg,bmsg,die,suf,fmt,fmt_list +from .util import msg,msg_r,ymsg,rmsg,gmsg,bmsg,die,suf,fmt,fmt_list,async_run from .color import yellow,red,orange from .wallet import Wallet,get_wallet_cls from .filename import find_file_in_dir @@ -39,6 +39,10 @@ class Signable: def unsigned(self): return self._unprocessed( '_unsigned', self.rawext, self.sigext ) + @property + def unsubmitted(self): + return self._unprocessed( '_unsubmitted', self.sigext, self.subext ) + def _unprocessed(self,attrname,rawext,sigext): if not hasattr(self,attrname): dirlist = tuple(os.scandir(self.dir)) @@ -116,6 +120,58 @@ class Signable: for f in bad_files: yield red(f.path) + class xmr_transaction(transaction): + dir_name = 'xmr_tx_dir' + desc = 'Monero transaction' + subext = 'subtx' + + def __init__(self,*args,**kwargs): + super().__init__(*args,**kwargs) + if len(self.unsigned) > 1: + die('AutosignTXError', 'Only one unsigned XMR transaction allowed at a time!') + + async def sign(self,f): + from .xmrwallet import MoneroMMGenTX,MoneroWalletOps,xmrwallet_uargs + tx1 = MoneroMMGenTX.Completed( self.parent.xmrwallet_cfg, f.path ) + m = MoneroWalletOps.sign( + self.parent.xmrwallet_cfg, + xmrwallet_uargs( + infile = self.parent.wallet_files[0], # MMGen wallet file + wallets = str(tx1.src_wallet_idx), + spec = None ), + ) + tx2 = await m.main(f.path) # TODO: stop wallet daemon? + tx2.write(ask_write=False) + return tx2 + + def print_summary(self,txs): + bmsg('\nAutosign summary:\n') + msg_r('\n'.join(tx.get_info() for tx in txs)) + + class xmr_wallet_outputs_file(transaction): + desc = 'Monero wallet outputs file' + rawext = 'raw' + sigext = 'sig' + dir_name = 'xmr_outputs_dir' + + async def sign(self,f): + from .xmrwallet import MoneroWalletOps,xmrwallet_uargs + wallet_idx = MoneroWalletOps.wallet.get_idx_from_fn(f.name) + m = MoneroWalletOps.export_key_images( + self.parent.xmrwallet_cfg, + xmrwallet_uargs( + infile = self.parent.wallet_files[0], # MMGen wallet file + wallets = str(wallet_idx), + spec = None ), + ) + obj = await m.main( f, wallet_idx ) + obj.write() + return obj + + def print_summary(self,txs): + bmsg('\nAutosign summary:') + msg(' ' + '\n '.join(tx.get_info() for tx in txs) + '\n') + class message(base): desc = 'message file' rawext = 'rawmsg.json' @@ -200,10 +256,18 @@ class Autosign: self.coins = cfg.coins.upper().split(',') if cfg.coins else [] + if cfg.xmrwallets and not 'XMR' in self.coins: + self.coins.append('XMR') + if not self.coins: ymsg('Warning: no coins specified, defaulting to BTC') self.coins = ['BTC'] + if 'XMR' in self.coins: + self.xmr_dir = os.path.join( self.mountpoint, 'xmr' ) + self.xmr_tx_dir = os.path.join( self.mountpoint, 'xmr', 'tx' ) + self.xmr_outputs_dir = os.path.join( self.mountpoint, 'xmr', 'outputs' ) + async def check_daemons_running(self): from .protocol import init_proto for coin in self.coins: @@ -239,7 +303,7 @@ class Autosign: return self._wallet_files - def do_mount(self): + def do_mount(self,no_xmr_chk=False): from stat import S_ISDIR,S_IWUSR,S_IRUSR @@ -272,6 +336,9 @@ class Autosign: if self.have_msg_dir: check_dir(self.msg_dir) + if 'XMR' in self.coins and not no_xmr_chk: + check_dir(self.xmr_tx_dir) + def do_umount(self): if os.path.ismount(self.mountpoint): run( ['sync'], check=True ) @@ -330,7 +397,10 @@ class Autosign: self.led.set('busy') ret1 = await self.sign_all('transaction') ret2 = await self.sign_all('message') if self.have_msg_dir else True - ret = ret1 and ret2 + # import XMR wallet outputs BEFORE signing transactions: + ret3 = await self.sign_all('xmr_wallet_outputs_file') if 'XMR' in self.coins else True + ret4 = await self.sign_all('xmr_transaction') if 'XMR' in self.coins else True + ret = ret1 and ret2 and ret3 and ret4 self.do_umount() self.led.set(('standby','off','error')[(not ret)*2 or bool(self.cfg.stealth_led)]) return ret @@ -365,7 +435,7 @@ class Autosign: self.create_wallet_dir() if not self.get_insert_status(): die(1,'Removable device not present!') - self.do_mount() + self.do_mount(no_xmr_chk=True) self.wipe_existing_key() self.create_key() if not no_unmount: @@ -398,6 +468,53 @@ class Autosign: ss_out = Wallet( self.cfg, ss=ss_in ) ss_out.write_to_file( desc='autosign wallet', outdir=self.wallet_dir ) + @property + def xmrwallet_cfg(self): + if not hasattr(self,'_xmrwallet_cfg'): + from .cfg import Config + self._xmrwallet_cfg = Config({ + 'coin': 'xmr', + 'wallet_rpc_user': 'autosigner', + 'wallet_rpc_password': 'my very secret password', + 'passwd_file': self.cfg.passwd_file, + 'wallet_dir': self.wallet_dir, + 'autosign': True, + 'autosign_mountpoint': self.mountpoint, + 'outdir': self.xmr_dir, # required by vkal.write() + }) + return self._xmrwallet_cfg + + def xmr_setup(self): + + import shutil + try: shutil.rmtree(self.xmr_outputs_dir) + except: pass + + os.makedirs(self.xmr_outputs_dir) + + os.makedirs(self.xmr_tx_dir,exist_ok=True) + + from .addrfile import ViewKeyAddrFile + from .fileutil import shred_file + for f in os.scandir(self.xmr_dir): + if f.name.endswith(ViewKeyAddrFile.ext): + msg(f'Shredding old viewkey-address file {f.name!r}') + shred_file(f.path) + + if len(self.wallet_files) > 1: + ymsg(f'Warning: more that one wallet file, using the first ({self.wallet_files[0]}) for xmrwallet generation') + + from .xmrwallet import MoneroWalletOps,xmrwallet_uargs + m = MoneroWalletOps.create_offline( + self.xmrwallet_cfg, + xmrwallet_uargs( + infile = self.wallet_files[0], # MMGen wallet file + wallets = self.cfg.xmrwallets, # XMR wallet idxs + spec = None ), + ) + async_run(m.main()) + async_run(m.stop_wallet_daemon()) + def get_insert_status(self): if self.cfg.no_insert_check: return True diff --git a/mmgen/data/version b/mmgen/data/version index 35fef34c..5ac39dbd 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -13.3.dev47 +13.3.dev48 diff --git a/mmgen/exception.py b/mmgen/exception.py index 8e5db8a3..96c47c74 100755 --- a/mmgen/exception.py +++ b/mmgen/exception.py @@ -66,6 +66,7 @@ class ObjectInitError(Exception): mmcode = 2 class ClassFlagsError(Exception): mmcode = 2 class ExtensionModuleError(Exception): mmcode = 2 class MoneroMMGenTXFileParseError(Exception): mmcode = 2 +class AutosignTXError(Exception): mmcode = 2 # 3: yellow hl, 'MMGen Error' + exception + message class RPCFailure(Exception): mmcode = 3 diff --git a/mmgen/help/xmrwallet.py b/mmgen/help/xmrwallet.py index 5ed12799..6677a5d2 100755 --- a/mmgen/help/xmrwallet.py +++ b/mmgen/help/xmrwallet.py @@ -46,8 +46,17 @@ sweep - sweep funds in specified wallet:account to new address in same account or new account in another wallet relay - relay a transaction from a transaction file created using ‘sweep’ or ‘transfer’ with the --no-relay option +submit - submit an autosigned transaction to a wallet and the network txview - view a transaction file or files created using ‘sweep’ or ‘transfer’ with the --no-relay option +dump - produce JSON dumps of wallet metadata (accounts, addresses and + labels) for a list or range of wallets +restore - same as ‘create’, but additionally restore wallet metadata from + the corresponding JSON dump files created with ‘dump’ +export-outputs - export outputs of watch-only wallets for later import + into their corresponding offline wallets +import-key-images - import key images signed by offline wallets into their + corresponding watch-only wallets ‘LABEL’ OPERATION NOTES @@ -103,24 +112,42 @@ Note that multiple sweep operations may be required to sweep all the funds in an account. - ‘RELAY’ OPERATION NOTES + ‘SUBMIT’ AND ‘RELAY’ OPERATION NOTES By default, transactions are relayed to a monerod running on localhost at the default RPC port. To relay transactions to a remote or non-default monerod via optional SOCKS proxy, use the --tx-relay-daemon option described above. +When ‘submit’ is used with --autosign, the transaction filename must be +omitted. + + + ‘DUMP’ AND ‘RESTORE’ OPERATION NOTES + +These commands produce and read JSON wallet dump files with the same +filenames as their source wallets, plus a .dump extension. + +It’s highly advisable to make regular dumps of your Monero wallets and back +up the dump files, which can be used to easily regenerate the wallets using +the ‘restore’ operation, should the need arise. For watch-only autosigning +wallets, creating the dumps is as easy as executing ‘mmgen-xmrwallet +--autosign dump’ from your wallet directory. The dump files are formatted +JSON and thus suitable for efficient incremental backup using git. + SECURITY WARNING -To avoid exposing your private keys on a network-connected machine, you’re -strongly advised to create all transactions offline using the --no-relay -option. For this, a monerod with a fully synced blockchain must be running -on the offline machine. The resulting transaction files are then sent using -the 'relay' operation. +If you have an existing MMGen Monero hot wallet setup, you’re strongly +advised to migrate to offline autosigning to avoid further exposing your +private keys on your network-connected machine. See OFFLINE AUTOSIGNING +and ‘Replacing Existing Hot Wallets with Watch-Only Wallets’ below. EXAMPLES +Note that the transacting examples in this section apply for a hot wallet +setup, which is now deprecated. See OFFLINE AUTOSIGNING below. + Generate an XMR key-address file with 5 addresses from your default wallet: $ mmgen-keygen --coin=xmr 1-5 @@ -158,4 +185,235 @@ $ mmgen-xmrwallet new *.akeys.mmenc 2:1,"from ABC exchange" View all the XMR transaction files in the current directory, sending output to pager: $ mmgen-xmrwallet --pager txview *XMR*.sigtx + + + OFFLINE AUTOSIGNING + + Tutorial + +Master the basic concepts of the MMGen wallet system and the processes of +wallet creation, conversion and backup described in the Getting Started +guide. Optionally create a default MMGen wallet on your offline machine +using ‘mmgen-walletgen’. If you choose not to do this, you’ll be prompted +for a seed phrase at the start of each signing session. + +Familiarize yourself with the autosigning setup process as described in +‘mmgen-autosign --help’. Prepare your removable device and set up the +mountpoints on your offline and online machines according to the instructions +therein. Install ‘monero-wallet-rpc’ on your offline machine and the Monero +CLI wallet and daemon binaries on your online machine. + +On the offline machine, insert the removable device and execute: + +$ mmgen-autosign --xmrwallets=1-2,7 setup + +This will create 3 Monero signing wallets with indexes 1, 2 and 7 and primary +addresses matching your seed’s Monero addresses with the same indexes. (Note +that these particular indexes are arbitrary, for purposes of illustration +only. Feel free to choose your own list and/or range – or perhaps just the +number ‘1’ if one wallet is all you require). + +These signing wallets are written to volatile memory and exist only for the +duration of the signing session, just like the temporary MMGen signing wallet +they’re generated from (see ‘mmgen-autosign --help’). + +A viewkey-address file for the 3 addresses will also be written to the +removable device. The data in this file will be used to create and access +watch-only wallets on your online machine that match the signing wallets +you’ve just created. + +When the setup operation completes, extract the removable device and restart +the autosign script in wait mode: + +$ mmgen-autosign --coins=xmr --stealth-led wait + +Your only further physical interaction with the offline signing machine now +(assuming everything goes as planned) will be inserting and extracting the +removable device on it. And this is the whole point of autosigning: to make +cold signing as convenient as possible, almost like transacting with a hot +wallet. + +If your signing machine is an SoC with MMGen LED support (see ‘mmgen-autosign +--help’), a quickly flashing LED will indicate that signing is in progress, a +slowly flashing LED an error condition, and no LED that the program is idle +and waiting for device insertion. + +On your online machine, start monerod, wait until it’s fully synced with the +network, insert the removable device and execute: + +$ mmgen-xmrwallet --autosign --restore-height=current create + +This will create 3 watch-only wallets matching your 3 offline signing wallets +and write them to the current directory (an alternate wallet directory may be +specified with the --wallet-dir option). + +Note that --restore-height=current is required to prevent a time-consuming +full sync of the wallets from the Genesis block, a meaningless waste of time +in this case since the wallets contain no funds. + +Also make note of the --autosign option, a requirement for ALL autosigning +operations with ‘mmgen-xmrwallet’. + +Now list your newly created wallets: + +$ mmgen-xmrwallet --autosign list + +Note that you can also use the ‘sync’ operation here, which produces more +abbreviated output than ‘list’. + +Send some XMR (preferably a tiny amount) to the primary address of wallet #7. +Once the transaction has confirmed, invoke ‘sync’ or ‘list’ again to verify +the funds have arrived. + +Since offline wallet #7 has no knowledge of the funds received by its online +counterpart, we need to update its state. Export the outputs of watch-only +wallet #7 as follows: + +$ mmgen-xmrwallet --autosign export-outputs 7 + +The outputs are now saved to the removable device and will be imported into +offline wallet #7 when you sign your first transaction. + +Now you’re ready to begin transacting. Let’s start by sweeping your funds in +wallet #7’s primary address (account 0) to a new address in the same account: + +$ mmgen-xmrwallet --autosign sweep 7:0 + +This operation creates an unsigned sweep transaction and saves it to the +removable device. + +Now extract the removable device and insert it on the offline machine. Wait +for the quick LED flashing to stop (or the blue ‘safe to extract’ message, in +the absence of LED support), signalling that signing is complete. + +Note that the offline wallet has performed two operations in one go here: +an import of wallet outputs from the previous step and the signing of your +just-created sweep transaction. + +Extract the removable device, insert it on your online machine and submit the +signed sweep transaction to the watch-only wallet, which will broadcast it to +the network: + +$ mmgen-xmrwallet --autosign submit + +Note that you may also relay the transaction to a remote daemon, optionally +via a Tor proxy, using the --tx-relay-daemon option documented above. + +Once your transaction has confirmed, invoke ‘list’ or ‘sync’ to view your +wallets’ balances. + +Congratulations, you’ve performed your first autosigned Monero transaction! + +For other examples, consult the EXAMPLES section above, noting the following +differences that apply to autosigning: + + 1) The --autosign option must always be included. + 2) The key-address file argument must always be omitted. + 3) The ‘relay’ operation is replaced by ‘submit’, with TX filename omitted. + 4) Always remember to sign your transactions after a ‘sweep’ or ‘transfer’ + operation. + 5) Always remember to export a wallet’s outputs when it has received funds + from an outside source. + + + Exporting Outputs + +Exporting outputs from a wallet is generally required only in two cases: + + a) after the wallet has received funds from an outside source or another + wallet; and + c) at the start of each signing session (after ‘mmgen-autosign setup’). + +However, sometimes things go wrong and an offline wallet may be unable to +sign a transaction due to missing outputs. In this case, you may export all +outputs from its corresponding watch-only wallet with the following command: + +$ mmgen-xmrwallet --autosign --export-all export-outputs + +Then reinsert the removable device on the offline machine to redo the +signing operation, which should now succeed. + +At the start of each new signing session, you must export ALL outputs from +ALL wallets you intend to transact with: + +$ mmgen-xmrwallet --autosign --export-all export-outputs [wallet indexes] + +This is necessary because the signing wallets have just been created and +therefore know nothing about the state of their watch-only counterparts. + + + Replacing Existing Hot Wallets with Watch-Only Wallets + +If you have an existing MMGen Monero hot wallet setup, you can migrate to +offline transaction signing by ‘cloning’ your existing hot wallets as +watch-only ones via the ‘dump’ and ‘restore’ operations described below. + +For additional security, it’s also wise to create new watch-only wallets that +have never had keys exposed on an online machine and gradually transfer all +funds from your ‘cloned’ wallets to them. The creation of new wallets is +explained in the Tutorial above. + +Start the cloning process by making dump files of your hot wallets’ metadata +(accounts, subaddresses and labels). ‘cd’ to the wallet directory (or use +--wallet-dir) and execute: + +$ mmgen-xmrwallet dump /path/to/key-address-file.akeys{.mmenc} + +If you’ve been transacting with the wallets, you know where their key-address +file is along with its encryption password, if any. Supply an additional +index range and/or list at the end of the command line if the key-address +file contains more wallets than exist on disk or there are wallets you wish +to ignore. + +Do a directory listing to verify that the dump files are present alongside +their source wallet files ending with ‘MoneroWallet’. Then execute: + +$ mmgen-xmrwallet --watch-only restore /path/to/key-address-file.akeys{.mmenc} + +This will create watch-only wallets that “mirror” the old hot wallets and +populate them with the metadata saved in the dump files. + +Note that watch-only wallet filenames end with ‘MoneroWatchOnlyWallet’. Your +old hot wallets will be ignored from here on. Eventually, you’ll want to +destroy them. + +Your new wallets must now be synced with the blockchain. Begin by starting +monerod and synchronizing with the network. + +Mount ‘/mnt/mmgen_autosign’ and locate the file in the ‘xmr’ directory with +the .vkeys extension, which contains the passwords you’ll need to log into +the wallets. This is a plain text file viewable with ‘cat’, ‘less’ or your +favorite text editor. + +Then log into each watch-only wallet in turn as follows: + +$ monero-wallet-cli --wallet + +Upon login, each wallet will begin syncing, a process which can take more +than an hour depending on your hardware. Note, however, that the process +is interruptible: you may exit ‘monero-wallet-cli’ at any point, log back +in again and resume where you left off. + +Once your watch-only wallets are synced, you need to export their outputs: + +$ mmgen-xmrwallet --autosign export-outputs + +Now insert the removable device on the offline machine and wait until the LED +stops flashing (or ‘safe to extract’). The wallet outputs are now imported +into the signing wallets and corresponding signed key images have been +written to the removable device. + +Insert the removable device on your online machine and import the key images +into your watch-only wallets: + +$ mmgen-xmrwallet --autosign import-key-images + +Congratulations, your watch-only wallets are now complete and you may begin +transacting! First perform a ‘sync’ or ‘list’ to ensure that your balances +are correct. Then you might try sweeping some funds as described in the +Tutorial above. + +Once you’ve gained proficiency with the autosigning process and feel ready +to delete your old hot wallets, make sure to do so securely using ‘shred’, +‘wipe’ or some other secure deletion utility. """.strip() diff --git a/mmgen/main_autosign.py b/mmgen/main_autosign.py index ff07bcb9..2d3ed749 100755 --- a/mmgen/main_autosign.py +++ b/mmgen/main_autosign.py @@ -17,20 +17,20 @@ # along with this program. If not, see . """ -mmgen-autosign: Auto-sign MMGen transactions and message files +autosign: Auto-sign MMGen transactions, message files and XMR wallet output files """ import sys from .cfg import Config -from .util import die,fmt_list,exit_if_mswin,async_run +from .util import msg,die,fmt_list,exit_if_mswin,async_run exit_if_mswin('autosigning') opts_data = { 'sets': [('stealth_led', True, 'led', True)], 'text': { - 'desc': 'Auto-sign MMGen transactions and message files', + 'desc': 'Auto-sign MMGen transactions, message files and XMR wallet output files', 'usage':'[opts] [operation]', 'options': """ -h, --help Print this help message @@ -52,6 +52,7 @@ opts_data = { -v, --verbose Produce more verbose output -w, --wallet-dir=D Specify an alternate wallet dir (default: {asi.dfl_wallet_dir!r}) +-x, --xmrwallets=L Range or list of wallets to be used for XMR autosigning """, 'notes': """ @@ -66,8 +67,9 @@ wait - start in loop mode: wait-mount-sign-unmount-wait USAGE NOTES If no operation is specified, this program mounts a removable device -(typically a USB flash drive) containing unsigned MMGen transactions and/or -message files, signs them, unmounts the removable device and exits. +(typically a USB flash drive) containing unsigned MMGen transactions, message +files, and/or XMR wallet output files, signs them, unmounts the removable +device and exits. If invoked with ‘wait’, the program waits in a loop, mounting the removable device, performing signing operations and unmounting the device every time it @@ -170,6 +172,10 @@ if len(cmd_args) == 1: sys.exit(0) elif cmd == 'setup': asi.setup() + from .ui import keypress_confirm + if cfg.xmrwallets and keypress_confirm( cfg, '\nContinue with Monero setup?', default_yes=True ): + msg('') + asi.xmr_setup() sys.exit(0) elif cmd != 'wait': die(1,f'{cmd!r}: unrecognized command') diff --git a/mmgen/main_xmrwallet.py b/mmgen/main_xmrwallet.py index 97fce600..323797c1 100755 --- a/mmgen/main_xmrwallet.py +++ b/mmgen/main_xmrwallet.py @@ -30,25 +30,43 @@ from .xmrwallet import ( MoneroWalletOps, xmrwallet_uarg_info, xmrwallet_uargs, + get_autosign_obj, ) opts_data = { + 'sets': [ + ('autosign',True,'watch_only',True), + ('autosign_mountpoint',bool,'autosign',True), + ('autosign_mountpoint',bool,'watch_only',True), + ], 'text': { 'desc': """Perform various Monero wallet and transacting operations for addresses in an MMGen XMR key-address file""", 'usage2': [ - '[opts] create | sync | list [wallets]', - '[opts] label LABEL_SPEC', - '[opts] new NEW_ADDRESS_SPEC', - '[opts] transfer TRANSFER_SPEC', - '[opts] sweep SWEEP_SPEC', + '[opts] create | sync | list | dump | restore [xmr_keyaddrfile] [wallets]', + '[opts] label [xmr_keyaddrfile] LABEL_SPEC', + '[opts] new [xmr_keyaddrfile] NEW_ADDRESS_SPEC', + '[opts] transfer [xmr_keyaddrfile] TRANSFER_SPEC', + '[opts] sweep [xmr_keyaddrfile] SWEEP_SPEC', + '[opts] submit [TX_file]', '[opts] relay ', '[opts] txview ...', + '[opts] export-outputs [wallets]', + '[opts] import-key-images [wallets]', ], 'options': """ -h, --help Print this help message --, --longhelp Print help message for long options (common options) +-A, --export-all Export all outputs when performing the + ‘export-outputs’ operation +-a, --autosign Use appropriate outdir and other params for + autosigning operations (implies --watch-only). + When this option is in effect, the viewkey- + address file is located automatically, so the + xmr_keyaddrfile argument must be omitted. +-m, --autosign-mountpoint=P Specify the autosign mountpoint (defaults to + ‘/mnt/mmgen_autosign’, implies --autosign) -b, --rescan-blockchain Rescan the blockchain if wallet fails to sync -d, --outdir=D Save transaction files to directory 'D' instead of the working directory @@ -64,6 +82,7 @@ opts_data = { -R, --no-relay Save transaction to file instead of relaying -s, --no-start-wallet-daemon Don’t start the wallet daemon at startup -S, --no-stop-wallet-daemon Don’t stop the wallet daemon at exit +-W, --watch-only Create or operate on watch-only wallets -w, --wallet-dir=D Output or operate on wallets in directory 'D' instead of the working directory -H, --wallet-rpc-host=host Wallet RPC hostname (currently: {cfg.monero_wallet_rpc_host!r}) @@ -92,6 +111,12 @@ cfg = Config(opts_data=opts_data) cmd_args = cfg._args +if cmd_args and cfg.autosign and ( + cmd_args[0] in (MoneroWalletOps.kafile_arg_ops + ('export-outputs','import-key-images')) + or len(cmd_args) == 1 and cmd_args[0] == 'submit' + ): + cmd_args.insert(1,None) + if len(cmd_args) < 2: cfg._opts.usage() @@ -99,25 +124,36 @@ op = cmd_args.pop(0) infile = cmd_args.pop(0) wallets = spec = None -if op not in MoneroWalletOps.ops: +if op.replace('-','_') not in MoneroWalletOps.ops: die(1,f'{op!r}: unrecognized operation') -if op == 'relay': +if op in ('relay','submit'): if len(cmd_args) != 0: cfg._opts.usage() elif op == 'txview': infile = [infile] + cmd_args -elif op in ('create','sync','list'): - if len(cmd_args) not in (0,1): +elif op in ('create','sync','list','dump','restore'): # kafile_arg_ops + if len(cmd_args) > 1: cfg._opts.usage() - if cmd_args: - wallets = cmd_args[0] + wallets = cmd_args.pop(0) if cmd_args else None elif op in ('new','transfer','sweep','label'): if len(cmd_args) != 1: cfg._opts.usage() spec = cmd_args[0] +elif op in ('export-outputs','import-key-images'): + if not cfg.autosign: # --autosign only for now - TODO + die(f'--autosign must be used with command {op!r}') + if len(cmd_args) > 1: + cfg._opts.usage() + wallets = cmd_args.pop(0) if cmd_args else None -m = getattr(MoneroWalletOps,op)( +if cfg.autosign and not cfg.test_suite: + asi = get_autosign_obj(cfg) + if not asi.get_insert_status(): + die(1,'Removable device not present!') + asi.do_mount() + +m = getattr(MoneroWalletOps,op.replace('-','_'))( cfg, xmrwallet_uargs(infile, wallets, spec)) @@ -131,3 +167,6 @@ try: async_run(m.stop_wallet_daemon()) except Exception as e: ymsg(f'Unable to stop wallet daemon: {type(e).__name__}: {e}') + +if cfg.autosign and not cfg.test_suite: + asi.do_umount() diff --git a/mmgen/proto/xmr/daemon.py b/mmgen/proto/xmr/daemon.py index 1dabfcb0..2e248638 100755 --- a/mmgen/proto/xmr/daemon.py +++ b/mmgen/proto/xmr/daemon.py @@ -101,7 +101,8 @@ class MoneroWalletDaemon(RPCDaemon): daemon_addr = None, proxy = None, port_shift = None, - datadir = None ): + datadir = None, + trust_daemon = False ): self.proto = proto self.test_suite = test_suite @@ -143,7 +144,8 @@ class MoneroWalletDaemon(RPCDaemon): "the MMGen config file." ) self.daemon_args = list_gen( - ['--untrusted-daemon'], + ['--trusted-daemon', trust_daemon], + ['--untrusted-daemon', not trust_daemon], [f'--rpc-bind-port={self.rpc_port}'], ['--wallet-dir='+self.wallet_dir], ['--log-file='+self.logfile], diff --git a/mmgen/proto/xmr/params.py b/mmgen/proto/xmr/params.py index bb55ca8b..e2e54728 100755 --- a/mmgen/proto/xmr/params.py +++ b/mmgen/proto/xmr/params.py @@ -39,6 +39,7 @@ class mainnet(CoinProtocol.DummyWIF,CoinProtocol.Base): mmcaps = ('key','addr') ignore_daemon_version = False coin_amt = 'XMRAmt' + sign_mode = 'standalone' def get_addr_len(self,addr_fmt): return (64,72)[addr_fmt == 'monero_integrated'] diff --git a/mmgen/xmrwallet.py b/mmgen/xmrwallet.py index 465ee08b..f958a75c 100755 --- a/mmgen/xmrwallet.py +++ b/mmgen/xmrwallet.py @@ -23,13 +23,15 @@ xmrwallet.py - MoneroWalletOps class import os,re,time,json from collections import namedtuple from .objmethods import MMGenObject,Hilite,InitErrors -from .obj import CoinTxID +from .obj import CoinTxID,Int from .color import red,yellow,green,blue,cyan,pink,orange from .util import ( msg, msg_r, gmsg, + bmsg, ymsg, + rmsg, gmsg_r, pp_msg, die, @@ -45,7 +47,7 @@ from .seed import SeedID from .protocol import init_proto from .proto.btc.common import b58a from .addr import CoinAddr,AddrIdx -from .addrlist import KeyAddrList,AddrIdxList +from .addrlist import KeyAddrList,ViewKeyAddrList,AddrIdxList from .rpc import json_encoder from .proto.xmr.rpc import MoneroRPCClient,MoneroWalletRPCClient from .proto.xmr.daemon import MoneroWalletDaemon @@ -70,6 +72,16 @@ xmrwallet_uarg_info = ( r'(?:[^:]+):(?:\d+)' ) +def get_autosign_obj(cfg): + from .autosign import Autosign,AutosignConfig + return Autosign( + AutosignConfig({ + 'mountpoint': cfg.autosign_mountpoint, + 'test_suite': cfg.test_suite, + 'coins': 'XMR', + }) + ) + class XMRWalletAddrSpec(str,Hilite,InitErrors,MMGenObject): color = 'cyan' width = 0 @@ -160,6 +172,7 @@ class MoneroMMGenTX: data_label = 'MoneroMMGenTX' base_chksum_fields = ('op','create_time','network','seed_id','source','dest','amount') + full_chksum_fields = ('op','create_time','network','seed_id','source','dest','amount','fee','blob') chksum_nchars = 6 xmrwallet_tx_data = namedtuple('xmrwallet_tx_data',[ 'op', @@ -175,12 +188,18 @@ class MoneroMMGenTX: 'fee', 'blob', 'metadata', + 'unsigned_txset', + 'signed_txset', + 'complete', ]) - full_chksum_fields = set(xmrwallet_tx_data._fields) - {'metadata'} def __init__(self): self.name = type(self).__name__ + @property + def src_wallet_idx(self): + return int(self.data.source.split(':')[0]) + def get_info(self,indent=''): d = self.data if d.dest: @@ -208,6 +227,10 @@ class MoneroMMGenTX: if pmid: fs += ' Payment ID: {pmid}' + coldsign_status = ( + pink(' [cold signed{}]'.format(', submitted' if d.complete else '')) + if d.signed_txset else '' ) + from .util2 import format_elapsed_hr return fmt(fs,strip_char='\t',indent=indent).format( a = orange(self.base_chksum.upper()), @@ -216,9 +239,9 @@ class MoneroMMGenTX: d = d.txid.hl(), e = make_timestr(d.create_time), f = format_elapsed_hr(d.create_time), - g = make_timestr(d.sign_time), - h = format_elapsed_hr(d.sign_time), - i = blue(capfirst(d.op)), + g = make_timestr(d.sign_time) if d.sign_time else '-', + h = format_elapsed_hr(d.sign_time) if d.sign_time else '-', + i = blue(capfirst(d.op)) + coldsign_status, j = d.source.wallet.hl(), k = red(f'#{d.source.account}'), l = to_entry if d.dest else '', @@ -241,6 +264,9 @@ class MoneroMMGenTX: e = self.ext ) + if self.cfg.autosign: + fn = os.path.join( get_autosign_obj(self.cfg).xmr_tx_dir, fn ) + from .fileutil import write_data_to_file write_data_to_file( cfg = self.cfg, @@ -249,7 +275,8 @@ class MoneroMMGenTX: desc = self.desc, ask_write = ask_write, ask_write_default_yes = not ask_write, - ask_overwrite = ask_overwrite ) + ask_overwrite = ask_overwrite, + ignore_opt_outdir = self.cfg.autosign ) class New(Base): @@ -259,7 +286,13 @@ class MoneroMMGenTX: assert not args, 'Non-keyword args not permitted' - d = namedtuple('kwargs_tuple',kwargs)(**kwargs) + if '_in_tx' in kwargs: + in_data = kwargs.pop('_in_tx').data._asdict() + in_data.update(kwargs) + else: + in_data = kwargs + + d = namedtuple('monero_tx_in_data_tuple',in_data)(**in_data) self.cfg = d.cfg proto = init_proto( self.cfg, 'xmr', network=d.network, need_amt=True ) @@ -268,8 +301,8 @@ class MoneroMMGenTX: self.data = self.xmrwallet_tx_data( op = d.op, - create_time = now, - sign_time = now, + create_time = getattr(d,'create_time',now), + sign_time = (getattr(d,'sign_time',None) or now) if self.signed else None, network = d.network, seed_id = SeedID(sid=d.seed_id), source = XMRWalletAddrSpec(d.source), @@ -280,15 +313,31 @@ class MoneroMMGenTX: fee = proto.coin_amt(d.fee,from_unit='atomic'), blob = d.blob, metadata = d.metadata, + unsigned_txset = d.unsigned_txset, + signed_txset = getattr(d,'signed_txset',None), + complete = True if self.name == 'NewSigned' else getattr(d,'complete',False), ) + class NewUnsigned(New): + desc = 'unsigned transaction' + ext = 'rawtx' + signed = False + class NewSigned(New): desc = 'signed transaction' ext = 'sigtx' signed = True + class NewColdSigned(NewSigned): + pass + + class NewSubmitted(NewColdSigned): + desc = 'submitted transaction' + ext = 'subtx' + class Completed(Base): desc = 'transaction' + forbidden_fields = () def __init__(self,cfg,fn): @@ -302,11 +351,23 @@ class MoneroMMGenTX: except Exception as e: die( 'MoneroMMGenTXFileParseError', f'{type(e).__name__}: {e}\nCould not load transaction file' ) + if not 'unsigned_txset' in d_wrap['data']: # backwards compat: use old checksum fields + self.full_chksum_fields = ( + set(self.xmrwallet_tx_data._fields) - + {'metadata','unsigned_txset','signed_txset','complete'} ) + + for key in self.xmrwallet_tx_data._fields: # backwards compat: fill in missing fields + if not key in d_wrap['data']: + d_wrap['data'][key] = None + d = self.xmrwallet_tx_data(**d_wrap['data']) if self.name != 'Completed': assert fn.endswith('.'+self.ext), 'TX filename {fn!r} has incorrect extension (not {self.ext!r})' assert getattr(d,self.req_field), f'{self.name} TX missing required field {self.req_field!r}' + assert bool(d.sign_time)==self.signed,'{} has {}sign time!'.format(self.desc,'no 'if self.signed else'') + for f in self.forbidden_fields: + assert not getattr(d,f), f'{self.name} TX mismatch: contains forbidden field {f!r}' proto = init_proto( cfg, 'xmr', network=d.network, need_amt=True ) @@ -324,20 +385,182 @@ class MoneroMMGenTX: fee = proto.coin_amt(d.fee), blob = d.blob, metadata = d.metadata, + unsigned_txset = d.unsigned_txset, + signed_txset = d.signed_txset, + complete = d.complete, ) self.check_checksums(d_wrap) + class Unsigned(Completed): + desc = 'unsigned transaction' + ext = 'rawtx' + signed = False + req_field = 'unsigned_txset' + forbidden_fields = ('signed_txset',) + class Signed(Completed): desc = 'signed transaction' ext = 'sigtx' signed = True req_field = 'blob' + forbidden_fields = ('signed_txset','unsigned_txset') + + class ColdSigned(Signed): + req_field = 'signed_txset' + forbidden_fields = () + + class Submitted(ColdSigned): + desc = 'submitted transaction' + ext = 'subtx' + +class MoneroWalletOutputsFile: + + class Base(MoneroMMGenFile): + + desc = 'wallet outputs' + data_label = 'MoneroMMGenWalletOutputsFile' + base_chksum_fields = ('seed_id','wallet_index','outputs_data_hex',) + full_chksum_fields = ('seed_id','wallet_index','outputs_data_hex','signed_key_images') + fn_fs = '{a}-outputs-{b}.{c}' + ext_offset = 25 # len('-outputs-') + len(chksum) ({b}) + chksum_nchars = 16 + data_tuple = namedtuple('wallet_outputs_data',[ + 'seed_id', + 'wallet_index', + 'outputs_data_hex', + 'signed_key_images', + ]) + + def __init__(self,cfg): + self.name = type(self).__name__ + self.cfg = cfg + + def write(self,add_suf=''): + from .fileutil import write_data_to_file + write_data_to_file( + cfg = self.cfg, + outfile = self.get_outfile( self.cfg, self.wallet_fn ) + add_suf, + data = self.make_wrapped_data(self.data._asdict()), + desc = self.desc, + ask_overwrite = False, + ignore_opt_outdir = True ) + + def get_outfile(self,cfg,wallet_fn): + fn = self.fn_fs.format( + a = wallet_fn, + b = self.base_chksum, + c = self.ext, + ) + return os.path.join( + get_autosign_obj(cfg).xmr_outputs_dir, + os.path.basename(fn) ) if cfg.autosign else fn + + def get_wallet_fn(self,fn): + assert fn.endswith(f'.{self.ext}'), ( + f'{type(self).__name__}: filename does not end with {"."+self.ext!r}' + ) + return fn[:-(len(self.ext)+self.ext_offset+1)] + + def get_info(self,indent=''): + if self.data.signed_key_images is not None: + data = self.data.signed_key_images or [] + return f'{self.wallet_fn}: {len(data)} signed key image{suf(data)}' + else: + return f'{self.wallet_fn}: no key images' + + class New(Base): + ext = 'raw' + + def __init__( self, parent, wallet_fn, data, wallet_idx=None ): + super().__init__(parent.cfg) + self.wallet_fn = wallet_fn + init_data = dict.fromkeys(self.data_tuple._fields) + init_data.update({ + 'seed_id': parent.kal.al_id.sid, + 'wallet_index': wallet_idx or parent.get_idx_from_fn(os.path.basename(wallet_fn)), + }) + init_data.update({k:v for k,v in data.items() if k in init_data}) + self.data = self.data_tuple(**init_data) + + class Completed(New): + + def __init__( self, parent, fn=None, wallet_fn=None ): + def check_equal(desc,a,b): + assert a == b, f'{desc} mismatch: {a} (from file) != {b} (from filename)' + fn = fn or self.get_outfile( parent.cfg, wallet_fn ) + wallet_fn = wallet_fn or self.get_wallet_fn(fn) + d_wrap = self.extract_data_from_file( parent.cfg, fn ) + data = d_wrap['data'] + check_equal( 'Seed ID', data['seed_id'], parent.kal.al_id.sid ) + wallet_idx = parent.get_idx_from_fn(os.path.basename(wallet_fn)) + check_equal( 'Wallet index', data['wallet_index'], wallet_idx ) + super().__init__( + parent = parent, + wallet_fn = wallet_fn, + data = data, + wallet_idx = wallet_idx, + ) + self.check_checksums(d_wrap) + + @classmethod + def find_fn_from_wallet_fn(cls,cfg,wallet_fn,ret_on_no_match=False): + path = get_autosign_obj(cfg).xmr_outputs_dir or os.curdir + fn = os.path.basename(wallet_fn) + pat = cls.fn_fs.format( + a = fn, + b = f'[0-9a-f]{{{cls.chksum_nchars}}}\\', + c = cls.ext, + ) + matches = [f for f in os.scandir(path) if re.match(pat,f.name)] + if not matches and ret_on_no_match: + return None + if not matches or len(matches) > 1: + die(2,'{a} matching pattern {b!r} found in {c}!'.format( + a = 'No files' if not matches else 'More than one file', + b = pat, + c = path + )) + return matches[0].path + + class Unsigned(Completed): + pass + + class SignedNew(New): + desc = 'signed key images' + ext = 'sig' + + class Signed(Completed,SignedNew): + pass + +class MoneroWalletDumpFile: + + class Base: + desc = 'Monero wallet dump' + data_label = 'MoneroMMGenWalletDumpFile' + base_chksum_fields = ('seed_id','wallet_index','wallet_metadata') + full_chksum_fields = None + ext = 'dump' + ext_offset = 0 + data_tuple = namedtuple('wallet_dump_data',[ + 'seed_id', + 'wallet_index', + 'wallet_metadata', + ]) + def get_outfile(self,cfg,wallet_fn): + return f'{wallet_fn}.{self.ext}' + + class New(Base,MoneroWalletOutputsFile.New): + pass + + class Completed(Base,MoneroWalletOutputsFile.Completed): + pass class MoneroWalletOps: ops = ( 'create', + 'create_offline', 'sync', 'list', 'new', @@ -345,7 +568,24 @@ class MoneroWalletOps: 'sweep', 'relay', 'txview', - 'label' ) + 'label', + 'sign', + 'submit', + 'dump', + 'restore', + 'export_outputs', + 'import_key_images' ) + + kafile_arg_ops = ( + 'create', + 'sync', + 'list', + 'label', + 'new', + 'transfer', + 'sweep', + 'dump', + 'restore' ) opts = ( 'wallet_dir', @@ -356,13 +596,16 @@ class MoneroWalletOps: 'restore_height', 'no_start_wallet_daemon', 'no_stop_wallet_daemon', - 'no_relay' ) + 'no_relay', + 'watch_only', + 'autosign' ) pat_opts = ('daemon','tx_relay_daemon') class base(MMGenObject): opts = ('wallet_dir',) + trust_daemon = False def __init__(self,cfg,uarg_tuple): @@ -448,8 +691,12 @@ class MoneroWalletOps: 'daemon', 'no_start_wallet_daemon', 'no_stop_wallet_daemon', + 'autosign', + 'watch_only', ) wallet_exists = True + start_daemon = True + offline = False skip_wallet_check = False # for debugging def __init__(self,cfg,uarg_tuple): @@ -470,11 +717,36 @@ class MoneroWalletOps: super().__init__(cfg,uarg_tuple) - self.kal = KeyAddrList( - cfg = cfg, - proto = self.proto, - addrfile = uarg.infile, - key_address_validity_check = True ) + if self.offline: + from .wallet import Wallet + self.seed_src = Wallet( + cfg = cfg, + fn = uarg.infile, + ignore_in_fmt = True ) + + gmsg('\nCreating ephemeral key-address list for offline wallets') + self.kal = KeyAddrList( + cfg = cfg, + proto = self.proto, + seed = self.seed_src.seed, + addr_idxs = uarg.wallets, + skip_chksum_msg = True ) + else: + # with watch_only, make a second attempt to open the file as KeyAddrList: + for first_try in (True,False): + try: + self.kal = (ViewKeyAddrList if (self.cfg.watch_only and first_try) else KeyAddrList)( + cfg = cfg, + proto = self.proto, + addrfile = self.autosign_viewkey_addr_file if self.cfg.autosign else uarg.infile, + key_address_validity_check = True, + skip_chksum_msg = True ) + break + except: + if first_try: + msg(f'Attempting to open {uarg.infile} as key-address list') + continue + raise msg('') @@ -483,17 +755,25 @@ class MoneroWalletOps: if not self.skip_wallet_check: check_wallets() + relay_opt = self.parse_tx_relay_opt() if self.name == 'submit' and self.cfg.tx_relay_daemon else None + self.wd = MoneroWalletDaemon( cfg = self.cfg, proto = self.proto, wallet_dir = self.cfg.wallet_dir or '.', test_suite = self.cfg.test_suite, - daemon_addr = self.cfg.daemon or None, + daemon_addr = relay_opt[1] if relay_opt else (self.cfg.daemon or None), + trust_daemon = self.trust_daemon, ) u = self.wd.usr_daemon_args = [] - if self.name == 'create' and self.cfg.restore_height is None: + if self.offline or (self.name in ('create','restore') and self.cfg.restore_height is None): u.append('--offline') + if relay_opt: + if self.cfg.test_suite: + u.append('--daemon-ssl-allow-any-cert') + if relay_opt[2]: + u.append(f'--proxy={relay_opt[2]}') self.c = MoneroWalletRPCClient( cfg = self.cfg, @@ -501,9 +781,13 @@ class MoneroWalletOps: test_connection = False, ) - if not self.cfg.no_start_wallet_daemon: + if self.start_daemon and not self.cfg.no_start_wallet_daemon: async_run(self.c.restart_daemon()) + @classmethod + def get_idx_from_fn(cls,fn): + return int( re.match(r'[0-9a-fA-F]{8}-(\d+)-Monero(WatchOnly)?Wallet.*',fn)[1] ) + def get_coin_daemon_rpc(self): host,port = self.cfg.daemon.split(':') if self.cfg.daemon else ('localhost',self.wd.daemon_port) @@ -518,6 +802,22 @@ class MoneroWalletOps: user = None, passwd = None ) + @property + def autosign_viewkey_addr_file(self): + from .addrfile import ViewKeyAddrFile + mpdir = get_autosign_obj(self.cfg).xmr_dir + fnlist = [f for f in os.listdir(mpdir) if f.endswith(ViewKeyAddrFile.ext)] + if len(fnlist) != 1: + die(2, + '{a} viewkey-address files found in autosign mountpoint directory {b!r}!\n'.format( + a = 'Multiple' if fnlist else 'No', + b = mpdir + ) + + 'Have you run ‘mmgen-autosign setup’ on your offline machine with the --xmrwallets option?' + ) + else: + return os.path.join( mpdir, fnlist[0] ) + def create_addr_data(self): if uarg.wallets: idxs = AddrIdxList(uarg.wallets) @@ -531,18 +831,22 @@ class MoneroWalletOps: if not self.cfg.no_stop_wallet_daemon: await self.c.stop_daemon() - def get_wallet_fn(self,data): + def get_wallet_fn(self,data,watch_only=None): + if watch_only is None: + watch_only = self.cfg.watch_only return os.path.join( - self.cfg.wallet_dir or '.','{a}-{b}-MoneroWallet{c}'.format( + self.cfg.wallet_dir or '.','{a}-{b}-Monero{c}Wallet{d}'.format( a = self.kal.al_id.sid, b = data.idx, - c = f'.{self.cfg.network}' if self.cfg.network != 'mainnet' else '')) + c = 'WatchOnly' if watch_only else '', + d = f'.{self.cfg.network}' if self.cfg.network != 'mainnet' else '')) async def main(self): - gmsg('\n{a}ing {b} wallet{c}'.format( + gmsg('\n{a}ing {b} {c}wallet{d}'.format( a = self.stem.capitalize(), b = len(self.addr_data), - c = suf(self.addr_data) )) + c = 'watch-only ' if self.cfg.watch_only else '', + d = suf(self.addr_data) )) processed = 0 for n,d in enumerate(self.addr_data): # [d.sec,d.addr,d.wallet_passwd,d.viewkey] fn = self.get_wallet_fn(d) @@ -559,6 +863,14 @@ class MoneroWalletOps: gmsg(f'\n{processed} wallet{suf(processed)} {self.stem}ed') return processed + def head_msg(self,wallet_idx,fn): + gmsg('\n{} {} wallet #{} ({})'.format( + self.action.capitalize(), + self.wallet_desc, + wallet_idx, + os.path.basename(fn) + )) + class rpc: def __init__(self,parent,d): @@ -567,6 +879,9 @@ class MoneroWalletOps: self.c = parent.c self.d = d self.fn = parent.get_wallet_fn(d) + self.new_tx_cls = ( + MoneroMMGenTX.NewUnsigned if self.cfg.watch_only else + MoneroMMGenTX.NewSigned ) def open_wallet(self,desc,refresh=True): gmsg_r(f'\n Opening {desc} wallet...') @@ -577,7 +892,8 @@ class MoneroWalletOps: gmsg('done') if refresh: - gmsg_r(f' Refreshing {desc} wallet...') + m = ' and contacting relay' if self.parent.name == 'submit' and self.cfg.tx_relay_daemon else '' + gmsg_r(f' Refreshing {desc} wallet{m}...') ret = self.c.call('refresh') gmsg('done') if ret['received_money']: @@ -695,7 +1011,7 @@ class MoneroWalletOps: get_tx_hex = True, get_tx_metadata = True ) - return MoneroMMGenTX.NewSigned( + return self.new_tx_cls( cfg = self.cfg, op = self.parent.name, network = self.parent.proto.network, @@ -708,6 +1024,7 @@ class MoneroWalletOps: fee = res['fee'], blob = res['tx_blob'], metadata = res['tx_metadata'], + unsigned_txset = res['unsigned_txset'] if self.cfg.watch_only else None, ) def make_sweep_tx(self,account,dest_acct,dest_addr_idx,addr): @@ -723,7 +1040,7 @@ class MoneroWalletOps: if len(res['tx_hash_list']) > 1: die(3,'More than one TX required. Cannot perform this sweep') - return MoneroMMGenTX.NewSigned( + return self.new_tx_cls( cfg = self.cfg, op = self.parent.name, network = self.parent.proto.network, @@ -739,6 +1056,7 @@ class MoneroWalletOps: fee = res['fee_list'][0], blob = res['tx_blob_list'][0], metadata = res['tx_metadata_list'][0], + unsigned_txset = res['unsigned_txset'] if self.cfg.watch_only else None, ) def relay_tx(self,tx_hex): @@ -766,26 +1084,137 @@ class MoneroWalletOps: else: restore_height = self.cfg.restore_height - from .xmrseed import xmrseed - ret = self.c.call( - 'restore_deterministic_wallet', - filename = os.path.basename(fn), - password = d.wallet_passwd, - seed = xmrseed().fromhex(d.sec.wif,tostr=True), - restore_height = restore_height, - language = 'English' ) + if self.cfg.watch_only: + ret = self.c.call( + 'generate_from_keys', + filename = os.path.basename(fn), + password = d.wallet_passwd, + address = d.addr, + viewkey = d.viewkey, + restore_height = restore_height ) + else: + from .xmrseed import xmrseed + ret = self.c.call( + 'restore_deterministic_wallet', + filename = os.path.basename(fn), + password = d.wallet_passwd, + seed = xmrseed().fromhex(d.sec.wif,tostr=True), + restore_height = restore_height, + language = 'English' ) pp_msg(ret) if self.cfg.debug else msg(' Address: {}'.format( ret['address'] )) return True - class sync(wallet): - opts = ('rescan_blockchain',) + class create_offline(create): + offline = True def __init__(self,cfg,uarg_tuple): super().__init__(cfg,uarg_tuple) - host,port = self.cfg.daemon.split(':') if self.cfg.daemon else ('localhost',self.wd.daemon_port) + gmsg('\nCreating viewkey-address file for watch-only wallets') + vkal = ViewKeyAddrList( + cfg = self.cfg, + proto = self.proto, + addrfile = None, + addr_idxs = uarg.wallets, + seed = self.seed_src.seed, + skip_chksum_msg = True ) + vkf = vkal.file + + # before writing viewkey-address file, delete any old ones in the directory: + for fn in os.listdir(self.cfg.outdir): + if fn.endswith(vkf.ext): + os.unlink(os.path.join(self.cfg.outdir,fn)) + + vkf.write() # write file to self.cfg.outdir + + class restore(create): + + def check_uopts(self): + if self.cfg.restore_height is not None: + die(1,f'--restore-height must be unset when running the ‘restore’ command') + + async def process_wallet(self,d,fn,last): + + def get_dump_data(): + fns = [fn for fn in + [self.get_wallet_fn(d,watch_only=wo) + '.dump' for wo in (True,False)] + if os.path.exists(fn)] + if not fns: + die(1,f'No suitable dump file found for {fn!r}') + elif len(fns) > 1: + ymsg(f'Warning: more than one dump file found for {fn!r} - using the first!') + return MoneroWalletDumpFile.Completed( + parent = self, + fn = fns[0] ).data._asdict()['wallet_metadata'] + + def restore_accounts(): + bmsg(' Restoring accounts:') + for acct_idx,acct_data in enumerate(data[1:],1): + msg(fs.format(acct_idx, 0, acct_data['address'])) + self.c.call('create_account') + + def restore_subaddresses(): + bmsg(' Restoring subaddresses:') + for acct_idx,acct_data in enumerate(data): + for addr_idx,addr_data in enumerate(acct_data['addresses'][1:],1): + msg(fs.format(acct_idx, addr_idx, addr_data['address'])) + ret = self.c.call( 'create_address', account_index=acct_idx ) + + def restore_labels(): + bmsg(' Restoring labels:') + for acct_idx,acct_data in enumerate(data): + for addr_idx,addr_data in enumerate(acct_data['addresses']): + addr_data['used'] = False # do this so that restored data matches + msg(fs.format(acct_idx, addr_idx, addr_data['label'])) + self.c.call( + 'label_address', + index = { 'major': acct_idx, 'minor': addr_idx }, + label = addr_data['label'], + ) + + def make_format_str(): + return ' acct {:O>%s}, addr {:O>%s} [{}]' % ( + len(str( len(data) - 1 )), + len(str( max(len(acct_data['addresses']) for acct_data in data) - 1)) + ) + + def check_restored_data(): + restored_data = h.get_accts(print=False)[1] + if restored_data != data: + rmsg(f'Restored data does not match original dump! Dumping bad data.') + MoneroWalletDumpFile.New( + parent = self, + wallet_fn = fn, + data = {'wallet_metadata': restored_data} ).write(add_suf='.bad') + die(3,'Fatal error') + + res = await super().process_wallet(d,fn,last) + + h = self.rpc(self,d) + h.open_wallet('newly created') + + msg('') + data = get_dump_data() + fs = make_format_str() + + gmsg('\nRestoring accounts, subaddresses and labels from dump file:\n') + + restore_accounts() + restore_subaddresses() + restore_labels() + + check_restored_data() + + return True + + class sync(wallet): + opts = ('rescan_blockchain',) + + def __init__(self,cfg,uarg_tuple): + + super().__init__(cfg,uarg_tuple) self.dc = self.get_coin_daemon_rpc() @@ -947,7 +1376,11 @@ class MoneroWalletOps: class sweep(spec): spec_id = 'sweep_spec' spec_key = ( (1,'source'), (3,'dest') ) - opts = ('no_relay','tx_relay_daemon') + opts = ('no_relay','tx_relay_daemon','watch_only') + + def check_uopts(self): + if self.cfg.tx_relay_daemon and (self.cfg.no_relay or self.cfg.autosign): + die(1,'--tx-relay-daemon makes no sense in this context!') def init_tx_relay_daemon(self): @@ -1037,7 +1470,7 @@ class MoneroWalletOps: msg('Saving TX data to file') new_tx.write(delete_metadata=True) - if self.cfg.no_relay: + if self.cfg.no_relay or self.cfg.autosign: return True if keypress_confirm( self.cfg, f'Relay {self.name} transaction?' ): @@ -1142,11 +1575,183 @@ class MoneroWalletOps: else: ymsg('\nOperation cancelled by user request') + class sign(wallet): + wallet_desc = 'offline signing' + action = 'signing transaction with' + start_daemon = False + offline = True + + async def main(self,fn): + await self.c.restart_daemon() + tx = MoneroMMGenTX.Unsigned( self.cfg, fn ) + h = self.rpc(self,self.addr_data[0]) + self.head_msg(tx.src_wallet_idx,h.fn) + h.open_wallet('offline signing') + res = self.c.call( + 'sign_transfer', + unsigned_txset = tx.data.unsigned_txset, + export_raw = True, + get_tx_keys = True + ) + new_tx = MoneroMMGenTX.NewColdSigned( + cfg = self.cfg, + txid = res['tx_hash_list'][0], + unsigned_txset = None, + signed_txset = res['signed_txset'], + _in_tx = tx, + ) + await self.stop_wallet_daemon() + return new_tx + + class submit(wallet): + wallet_desc = 'watch-only' + action = 'submitting transaction with' + opts = ('tx_relay_daemon',) + + def check_uopts(self): + if self.cfg.daemon: + die(1,f'--daemon is not supported for the ‘{self.name}’ operation. Use --tx-relay-daemon instead') + + def get_unsubmitted_tx_fn(self): + from .autosign import Signable + t = Signable.xmr_transaction( get_autosign_obj(self.cfg) ) + if len(t.unsubmitted) != 1: + die('AutosignTXError', '{a} unsubmitted transaction{b} in {c!r}!'.format( + a = 'More than one' if t.unsubmitted else 'No', + b = suf(t.unsubmitted), + c = t.parent.xmr_tx_dir, + )) + return t.unsubmitted[0].path + + async def main(self): + tx = MoneroMMGenTX.ColdSigned( + cfg = self.cfg, + fn = uarg.infile or self.get_unsubmitted_tx_fn() ) + h = self.rpc( self, self.kal.entry(tx.src_wallet_idx) ) + self.head_msg(tx.src_wallet_idx,h.fn) + h.open_wallet(self.wallet_desc) + + msg('\n' + tx.get_info()) + + if self.cfg.tx_relay_daemon: + self.display_tx_relay_info() + + if keypress_confirm( self.cfg, 'Submit transaction?' ): + res = self.c.call( + 'submit_transfer', + tx_data_hex = tx.data.signed_txset ) + assert res['tx_hash_list'][0] == tx.data.txid, 'TxID mismatch in ‘submit_transfer’ result!' + else: + die(1,'Exiting at user request') + + new_tx = MoneroMMGenTX.NewSubmitted( + cfg = self.cfg, + complete = True, + _in_tx = tx, + ) + gmsg('\nOK') + new_tx.write( + ask_write = not self.cfg.autosign, + ask_overwrite = not self.cfg.autosign ) + return new_tx + + class dump(wallet): + wallet_desc = 'source' + + async def process_wallet(self,d,fn,last): + h = self.rpc(self,d) + h.open_wallet(self.wallet_desc) + acct_data,addr_data = h.get_accts(print=False) + msg('') + MoneroWalletDumpFile.New( + parent = self, + wallet_fn = fn, + data = {'wallet_metadata': addr_data} ).write() + return True + + class export_outputs(wallet): + wallet_desc = 'watch-only' + action = 'exporting outputs from' + stem = 'process' + opts = ('export_all',) + + async def process_wallet(self,d,fn,last): + h = self.rpc(self,d) + h.open_wallet('source') + self.head_msg(d.idx,h.fn) + for ftype in ('Unsigned','Signed'): + old_fn = getattr(MoneroWalletOutputsFile,ftype).find_fn_from_wallet_fn( + cfg = self.cfg, + wallet_fn = fn, + ret_on_no_match = True ) + if old_fn: + os.unlink(old_fn) + m = MoneroWalletOutputsFile.New( + parent = self, + wallet_fn = fn, + data = self.c.call('export_outputs', all=self.cfg.export_all ), + ) + m.write() + return True + + class export_key_images(wallet): + wallet_desc = 'offline signing' + action = 'signing wallet outputs file with' + start_daemon = False + offline = True + + async def main(self,f,wallet_idx): + await self.c.restart_daemon() + h = self.rpc(self,self.addr_data[0]) + self.head_msg(wallet_idx,f.name) + h.open_wallet('offline signing') + m = MoneroWalletOutputsFile.Unsigned( + parent = self, + fn = f.path ) + res = self.c.call( + 'import_outputs', + outputs_data_hex = m.data.outputs_data_hex ) + idata = res['num_imported'] + bmsg('\n {} output{} imported'.format( idata, suf(idata) )) + data = m.data._asdict() + data.update(self.c.call('export_key_images')) # for testing: all = True + m = MoneroWalletOutputsFile.SignedNew( + parent = self, + wallet_fn = m.get_wallet_fn(f.name), + data = data ) + idata = m.data.signed_key_images or [] + bmsg(' {} key image{} signed'.format( len(idata), suf(idata) )) + await self.stop_wallet_daemon() + return m + + class import_key_images(wallet): + wallet_desc = 'watch-only' + action = 'importing key images into' + stem = 'process' + trust_daemon = True + + async def process_wallet(self,d,fn,last): + h = self.rpc(self,d) + h.open_wallet(self.wallet_desc) + self.head_msg(d.idx,h.fn) + m = MoneroWalletOutputsFile.Signed( + parent = self, + fn = MoneroWalletOutputsFile.Signed.find_fn_from_wallet_fn( self.cfg, fn ), + ) + data = m.data.signed_key_images or [] + bmsg('\n {} signed key image{} to import'.format( len(data), suf(data) )) + if data: + res = self.c.call( 'import_key_images', signed_key_images=data ) + bmsg(f' Success: {res}') + return True + class relay(base): opts = ('tx_relay_daemon',) def __init__(self,cfg,uarg_tuple): + check_uopts = MoneroWalletOps.submit.check_uopts + super().__init__(cfg,uarg_tuple) self.tx = MoneroMMGenTX.Signed( self.cfg, uarg.infile ) @@ -1202,5 +1807,5 @@ class MoneroWalletOps: tx.get_info() for tx in sorted( (MoneroMMGenTX.Completed( self.cfg, fn ) for fn in uarg.infile), - key = lambda x: x.data.sign_time ) + key = lambda x: x.data.sign_time or x.data.create_time ) )) diff --git a/test/test-release.d/cfg.sh b/test/test-release.d/cfg.sh index 64f027c9..a17de2d5 100755 --- a/test/test-release.d/cfg.sh +++ b/test/test-release.d/cfg.sh @@ -125,9 +125,10 @@ init_tests() { [ \( "$ARM32" -o "$ARM64" \) -a "$DISTRO" != 'archarm' ] && t_altgen_skip+=' e' - d_xmr="Monero xmrwallet operations using stagenet" + d_xmr="Monero xmrwallet operations" t_xmr=" - - $test_py --coin=xmr + - $test_py --coin=xmr --exclude=xmr_autosign + - $test_py --coin=xmr xmr_autosign " d_eth="operations for Ethereum and Ethereum Classic using devnet" diff --git a/test/test_py_d/cfg.py b/test/test_py_d/cfg.py index 94b56cf1..9850bd09 100755 --- a/test/test_py_d/cfg.py +++ b/test/test_py_d/cfg.py @@ -35,6 +35,7 @@ cmd_groups_dfl = { # 'chainsplit': ('TestSuiteChainsplit',{}), 'ethdev': ('TestSuiteEthdev',{}), 'xmrwallet': ('TestSuiteXMRWallet',{}), + 'xmr_autosign': ('TestSuiteXMRAutosign',{}), } cmd_groups_extra = { @@ -212,6 +213,7 @@ cfgs = { # addr_idx_lists (except 31,32,33,34) must contain exactly 8 addresses '32': {}, '33': {}, '34': {}, + '39': {}, '40': {}, '41': {}, '99': {}, # dummy diff --git a/test/test_py_d/ts_autosign.py b/test/test_py_d/ts_autosign.py index ea90104c..25e56385 100755 --- a/test/test_py_d/ts_autosign.py +++ b/test/test_py_d/ts_autosign.py @@ -133,6 +133,8 @@ class TestSuiteAutosignBase(TestSuiteBase): def gen_msg_fns(): fmap = dict(filedir_map) for coin in self.coins: + if coin == 'xmr': + continue sdir = os.path.join('test','ref',fmap[coin]) for fn in os.listdir(sdir): if fn.endswith(f'[{coin.upper()}].rawmsg.json'): diff --git a/test/test_py_d/ts_xmr_autosign.py b/test/test_py_d/ts_xmr_autosign.py new file mode 100755 index 00000000..f5e5a2fe --- /dev/null +++ b/test/test_py_d/ts_xmr_autosign.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +# +# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet +# Copyright (C)2013-2023 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 +# https://gitlab.com/mmgen/mmgen + + +""" +test.test_py_d.ts_xmr_autosign: xmr autosigning tests for the test.py test suite +""" + +from .ts_xmrwallet import * +from .ts_autosign import TestSuiteAutosignBase + +def make_burn_addr(): + from mmgen.tool.coin import tool_cmd + return tool_cmd( + cfg = cfg, + cmdname = 'privhex2addr', + proto = cfg._proto, + mmtype = 'monero' ).privhex2addr('beadcafe'*8) + +class TestSuiteXMRAutosign(TestSuiteXMRWallet,TestSuiteAutosignBase): + """ + Monero autosigning operations + """ + + tmpdir_nums = [39] + + # ts_xmrwallet attrs: + user_data = ( + ('miner', '98831F3A', False, 130, '1', []), + ('alice', 'FE3C6545', True, 150, '1-2', []), + ) + + # ts_autosign attrs: + coins = ['xmr'] + daemon_coins = [] + txfile_coins = [] + live = False + simulate = False + bad_tx_count = 0 + tx_relay_user = 'miner' + + cmd_group = ( + ('daemon_version', 'checking daemon version'), + ('create_tmp_wallets', 'creating temporary online wallets for Alice'), + ('new_account_alice', 'adding an account to Alice’s tmp wallet'), + ('new_address_alice', 'adding an address to Alice’s tmp wallet'), + ('new_address_alice_label', 'adding an address to Alice’s tmp wallet (with label)'), + ('dump_tmp_wallets', 'dumping Alice’s tmp wallets'), + ('delete_tmp_wallets', 'deleting Alice’s tmp wallets'), + ('autosign_setup', 'autosign setup with Alice’s seed'), + ('create_watchonly_wallets', 'creating online (watch-only) wallets for Alice'), + ('delete_tmp_dump_files', 'deleting Alice’s dump files'), + ('gen_kafiles', 'generating key-address files for Miner'), + ('create_wallets_miner', 'creating Monero wallets for Miner'), + ('mine_initial_coins', 'mining initial coins'), + ('fund_alice', 'sending funds to Alice'), + ('create_transfer_tx1', 'creating a transfer TX'), + ('sign_transfer_tx1', 'signing the transfer TX'), + ('submit_transfer_tx1', 'submitting the transfer TX'), + ('create_transfer_tx2', 'creating a transfer TX (for relaying via proxy)'), + ('sign_transfer_tx2', 'signing the transfer TX (for relaying via proxy)'), + ('submit_transfer_tx2', 'submitting the transfer TX (relaying via proxy)'), + ('list_wallets', 'listing Alice’s wallets and checking balance'), + ('dump_wallets', 'dumping Alice’s wallets'), + ('delete_wallets', 'deleting Alice’s wallets'), + ('restore_wallets', 'creating online (watch-only) wallets for Alice'), + ('delete_dump_files', 'deleting Alice’s dump files'), + ('export_outputs', 'exporting outputs from Alice’s watch-only wallets'), + ('export_key_images', 'exporting signed key images from Alice’s offline wallets'), + ('import_key_images', 'importing signed key images into Alice’s online wallets'), + ('list_wallets', 'listing Alice’s wallets and checking balance'), + ) + + def __init__(self,trunner,cfgs,spawn): + + TestSuiteXMRWallet.__init__(self,trunner,cfgs,spawn) + TestSuiteAutosignBase.__init__(self,trunner,cfgs,spawn) + + if trunner == None: + return + + from mmgen.cfg import Config + self.cfg = Config({ + 'coin': 'XMR', + 'outdir': self.users['alice'].udir, + 'wallet_dir': self.users['alice'].udir, + 'wallet_rpc_password': 'passwOrd', + }) + + self.burn_addr = make_burn_addr() + + self.opts.append('--xmrwallets={}'.format( self.users['alice'].kal_range )) # mmgen-autosign opts + self.autosign_opts = [f'--autosign-mountpoint={self.mountpoint}'] # mmgen-xmrwallet opts + self.tx_count = 1 + + def create_tmp_wallets(self): + self.spawn('',msg_only=True) + data = self.users['alice'] + from mmgen.wallet import Wallet + from mmgen.xmrwallet import MoneroWalletOps,xmrwallet_uargs + silence() + kal = KeyAddrList( + cfg = self.cfg, + proto = self.proto, + addr_idxs = '1-2', + seed = Wallet(cfg,data.mmwords).seed ) + kal.file.write(ask_overwrite=False) + fn = get_file_with_ext(data.udir,'akeys') + m = MoneroWalletOps.create( + self.cfg, + xmrwallet_uargs(fn, '1-2', None)) + async_run(m.main()) + async_run(m.stop_wallet_daemon()) + end_silence() + return 'ok' + + def _new_addr_alice(self,*args): + data = self.users['alice'] + return self.new_addr_alice( + *args, + kafile = get_file_with_ext(data.udir,'akeys') ) + + def new_account_alice(self): + return self._new_addr_alice( + '2', + 'start', + fr'Creating new account.*Index:\s+{self.na_idx}\s') + + def new_address_alice(self): + return self._new_addr_alice( + '2:1', + 'continue', + fr'Account index:\s+1\s+Creating new address' ) + + def new_address_alice_label(self): + return self._new_addr_alice( + '2:1,Alice’s new address', + 'stop', + fr'Account index:\s+1\s+Creating new address.*Alice’s new address' ) + + def dump_tmp_wallets(self): + return self._dump_wallets(autosign=False) + + def dump_wallets(self): + return self._dump_wallets(autosign=True) + + def _dump_wallets(self,autosign): + data = self.users['alice'] + t = self.spawn( + 'mmgen-xmrwallet', + self.extra_opts + + [f'--wallet-dir={data.udir}', f'--daemon=localhost:{data.md.rpc_port}'] + + (self.autosign_opts if autosign else []) + + ['dump'] + + ([] if autosign else [get_file_with_ext(data.udir,'akeys')]) ) + t.expect('2 wallets dumped') + return t + + def _delete_files(self,*ext_list): + data = self.users['alice'] + self.spawn('',msg_only=True) + for ext in ext_list: + get_file_with_ext(data.udir,ext,no_dot=True,delete_all=True) + return 'ok' + + def delete_tmp_wallets(self): + return self._delete_files( 'MoneroWallet', 'MoneroWallet.keys', '.akeys' ) + + def delete_wallets(self): + return self._delete_files( 'MoneroWatchOnlyWallet', '.keys', '.address.txt' ) + + def delete_tmp_dump_files(self): + return self._delete_files( '.dump' ) + + def delete_dump_files(self): + return self._delete_files( '.dump' ) + + def autosign_setup(self): + from pathlib import Path + Path(self.autosign_xmr_dir).mkdir(parents=True,exist_ok=True) + Path(self.autosign_xmr_dir,'old.vkeys').touch() + t = self.make_wallet( + mn_type = 'mmgen', + mn_file = self.users['alice'].mmwords, + use_dfl_wallet = None ) + t.expect('Continue with Monero setup? (Y/n): ','y') + t.written_to_file('View keys') + return t + + def create_watchonly_wallets(self): + return self.create_wallets( 'alice', op='restore' ) + + def restore_wallets(self): + return self.create_wallets( 'alice', op='restore' ) + + def list_wallets(self): + return self.sync_wallets( + 'alice', + op = 'list', + bal_chk_func = lambda n,bal: (0.83 < bal < 0.8536) if n == 0 else True ) + # 1.234567891234 - 0.124 - 0.257 = 0.853567891234 (minus fees) + + def _create_transfer_tx(self,amt): + return self.do_op('transfer','alice',f'1:0:{self.burn_addr},{amt}',no_relay=True,do_ret=True) + + def create_transfer_tx1(self): + return self._create_transfer_tx('0.124') + + def create_transfer_tx2(self): + get_file_with_ext(self.asi.xmr_tx_dir,'rawtx',delete_all=True) + get_file_with_ext(self.asi.xmr_tx_dir,'sigtx',delete_all=True) + return self._create_transfer_tx('0.257') + + def _sign_transfer_tx(self): + return self.do_sign(['--full-summary'],tx_name='Monero transaction') + + def sign_transfer_tx1(self): + return self._sign_transfer_tx() + + def sign_transfer_tx2(self): + return self._sign_transfer_tx() + + def _xmr_autosign_op(self,op,desc,dtype=None,ext=None,wallet_arg=None,add_opts=[]): + data = self.users['alice'] + args = ( + self.extra_opts + + self.autosign_opts + + [f'--wallet-dir={data.udir}'] + + ([f'--daemon=localhost:{data.md.rpc_port}'] if not op == 'submit' else []) + + add_opts + + [ op ] + + ([get_file_with_ext(self.asi.xmr_tx_dir,ext)] if ext else []) + + ([wallet_arg] if wallet_arg else []) + ) + t = self.spawn( 'mmgen-xmrwallet', args, extra_desc=f'({desc}, Alice)' ) + if dtype: + t.written_to_file(dtype.capitalize()) + return t + + def submit_transfer_tx1(self): + return self._submit_transfer_tx( self.tx_relay_daemon_parm, ext='sigtx' ) + + def submit_transfer_tx2(self): + return self._submit_transfer_tx( self.tx_relay_daemon_proxy_parm, ext=None ) + + def _submit_transfer_tx(self,relay_parm,ext): + t = self._xmr_autosign_op( + op = 'submit', + desc = 'submitting TX', + add_opts = [f'--tx-relay-daemon={relay_parm}'], + ext = ext ) + t.expect( 'Submit transaction? (y/N): ', 'y' ) + t.written_to_file('Submitted transaction') + t.ok() + return self.mine_chk( + 'alice', 1, 0, + lambda x: 0 < x < 1.234567891234, + 'unlocked balance 0 < 1.234567891234' ) + + def export_outputs(self): + return self._xmr_autosign_op( + op = 'export-outputs', + desc = 'exporting outputs', + dtype = 'wallet outputs', + wallet_arg = '1-2' ) + + def export_key_images(self): + self.tx_count = 2 + return self.do_sign(['--full-summary'],tx_name='Monero wallet outputs file') + + def import_key_images(self): + return self._xmr_autosign_op( + op = 'import-key-images', + desc = 'importing key images' ) diff --git a/test/test_py_d/ts_xmrwallet.py b/test/test_py_d/ts_xmrwallet.py index 7bd32f6c..9ea3753d 100755 --- a/test/test_py_d/ts_xmrwallet.py +++ b/test/test_py_d/ts_xmrwallet.py @@ -26,7 +26,7 @@ from subprocess import run,PIPE from mmgen.cfg import gc from mmgen.obj import MMGenRange from mmgen.amt import XMRAmt -from mmgen.addrlist import KeyAddrList,AddrIdxList +from mmgen.addrlist import ViewKeyAddrList,KeyAddrList,AddrIdxList from ..include.common import * from .common import * @@ -44,10 +44,11 @@ class TestSuiteXMRWallet(TestSuiteBase): color = True socks_port = 49237 user_data = ( - ('miner', '98831F3A', 130, '1-2', []), - ('bob', '1378FC64', 140, None, ['--restricted-rpc']), - ('alice', 'FE3C6545', 150, '1-4', []), + ('miner', '98831F3A', False, 130, '1-2', []), + ('bob', '1378FC64', False, 140, None, ['--restricted-rpc']), + ('alice', 'FE3C6545', False, 150, '1-4', []), ) + tx_relay_user = 'bob' cmd_group = ( ('daemon_version', 'checking daemon version'), @@ -91,15 +92,18 @@ class TestSuiteXMRWallet(TestSuiteBase): self.proto = init_proto( cfg, 'XMR', network='mainnet' ) self.datadir_base = os.path.join('test','daemons','xmrtest') self.extra_opts = ['--wallet-rpc-password=passw0rd'] + self.autosign_mountpoint = os.path.join(self.tmpdir,'mmgen_autosign') + self.autosign_xmr_dir = os.path.join(self.tmpdir,'mmgen_autosign','xmr') self.init_users() self.init_daemon_args() + self.autosign_opts = [] for v in self.users.values(): run(['mkdir','-p',v.udir]) self.init_proxy() - self.tx_relay_daemon_parm = 'localhost:{}'.format( self.users['bob'].md.rpc_port ) + self.tx_relay_daemon_parm = 'localhost:{}'.format( self.users[self.tx_relay_user].md.rpc_port ) self.tx_relay_daemon_proxy_parm = ( self.tx_relay_daemon_parm + f':127.0.0.1:{self.socks_port}' ) # must be IP, not 'localhost' @@ -205,6 +209,7 @@ class TestSuiteXMRWallet(TestSuiteBase): ud = namedtuple('user_data',[ 'sid', 'mmwords', + 'autosign', 'udir', 'datadir', 'kal_range', @@ -220,6 +225,7 @@ class TestSuiteXMRWallet(TestSuiteBase): # kal_range must be None, a single digit, or a single hyphenated range for ( user, sid, + autosign, shift, kal_range, add_coind_args ) in self.user_data: @@ -260,15 +266,24 @@ class TestSuiteXMRWallet(TestSuiteBase): daemon = wd, test_connection = False, ) + if autosign: + kafile_suf = 'vkeys' + fn_stem = 'MoneroWatchOnlyWallet' + kafile_dir = self.autosign_xmr_dir + else: + kafile_suf = 'akeys' + fn_stem = 'MoneroWallet' + kafile_dir = udir self.users[user] = ud( sid = sid, mmwords = f'test/ref/{sid}.mmwords', + autosign = autosign, udir = udir, datadir = datadir, kal_range = kal_range, - kafile = f'{udir}/{sid}-XMR-M[{kal_range}].akeys', - walletfile_fs = f'{udir}/{sid}-{{}}-MoneroWallet', - addrfile_fs = f'{udir}/{sid}-{{}}-MoneroWallet.address.txt', + kafile = f'{kafile_dir}/{sid}-XMR-M[{kal_range}].{kafile_suf}', + walletfile_fs = f'{udir}/{sid}-{{}}-{fn_stem}', + addrfile_fs = f'{udir}/{sid}-{{}}-{fn_stem}.address.txt', md = md, md_rpc = md_rpc, wd = wd, @@ -296,6 +311,8 @@ class TestSuiteXMRWallet(TestSuiteBase): for user,data in self.users.items(): if not data.kal_range: continue + if data.autosign: + continue run(['mkdir','-p',data.udir]) run(f'rm -f {data.kafile}',shell=True) t = self.spawn( @@ -312,7 +329,7 @@ class TestSuiteXMRWallet(TestSuiteBase): def create_wallets_miner(self): return self.create_wallets('miner') def create_wallets_alice(self): return self.create_wallets('alice') - def create_wallets(self,user,wallet=None,add_opts=[]): + def create_wallets(self,user,wallet=None,add_opts=[],op='create'): assert wallet is None or is_int(wallet), 'wallet arg' data = self.users[user] stem_glob = data.walletfile_fs.format(wallet or '*') @@ -325,9 +342,10 @@ class TestSuiteXMRWallet(TestSuiteBase): 'mmgen-xmrwallet', [f'--wallet-dir={data.udir}'] + self.extra_opts + + (self.autosign_opts if data.autosign else []) + add_opts - + ['create'] - + [data.kafile] + + [op] + + ([] if data.autosign else [data.kafile]) + [wallet or data.kal_range] ) for i in MMGenRange(wallet or data.kal_range).items: @@ -339,7 +357,7 @@ class TestSuiteXMRWallet(TestSuiteBase): ) return t - def new_addr_alice(self,spec,cfg,expect): + def new_addr_alice(self,spec,cfg,expect,kafile=None): data = self.users['alice'] t = self.spawn( 'mmgen-xmrwallet', @@ -348,7 +366,7 @@ class TestSuiteXMRWallet(TestSuiteBase): [f'--daemon=localhost:{data.md.rpc_port}'] + (['--no-start-wallet-daemon'] if cfg in ('continue','stop') else []) + (['--no-stop-wallet-daemon'] if cfg in ('start','continue') else []) + - [ 'new', data.kafile, spec ] ) + ['new', (kafile or data.kafile), spec] ) res = strip_ansi_escapes(t.read()).replace('\r','') m = re.search(expect,res,re.DOTALL) assert m, f'no match found for {expect!r}' @@ -426,7 +444,7 @@ class TestSuiteXMRWallet(TestSuiteBase): def list_wallets_all(self): return self.sync_wallets('alice',op='list') - def sync_wallets(self,user,op='sync',wallets=None,add_opts=[]): + def sync_wallets(self,user,op='sync',wallets=None,add_opts=[],bal_chk_func=None): data = self.users[user] cmd_opts = list_gen( [f'--wallet-dir={data.udir}'], @@ -436,9 +454,10 @@ class TestSuiteXMRWallet(TestSuiteBase): 'mmgen-xmrwallet', self.extra_opts + cmd_opts + + self.autosign_opts + add_opts + [op] - + [data.kafile] + + ([] if data.autosign else [data.kafile]) + ([wallets] if wallets else []) ) wlist = AddrIdxList(wallets) if wallets else MMGenRange(data.kal_range).items @@ -450,7 +469,10 @@ class TestSuiteXMRWallet(TestSuiteBase): )) t.expect('Chain height: ') t.expect('Wallet height: ') - t.expect('Balance: ') + res = t.expect_getend('Unlocked balance: ') + if bal_chk_func: + bal = XMRAmt(strip_ansi_escapes(res)) + assert bal_chk_func(n,bal), f'balance check for wallet {n} failed!' return t def do_op(self, op, user, arg2, @@ -464,10 +486,11 @@ class TestSuiteXMRWallet(TestSuiteBase): data = self.users[user] cmd_opts = list_gen( [f'--wallet-dir={data.udir}'], - [f'--outdir={data.udir}'], + [f'--outdir={data.udir}', not data.autosign], [f'--daemon=localhost:{data.md.rpc_port}'], [f'--tx-relay-daemon={tx_relay_parm}', tx_relay_parm], - ['--no-relay', no_relay] + ['--no-relay', no_relay and not data.autosign], + [f'--autosign-mountpoint={self.autosign_mountpoint}', data.autosign], ) add_desc = (', ' + add_desc) if add_desc else '' @@ -476,10 +499,13 @@ class TestSuiteXMRWallet(TestSuiteBase): self.extra_opts + cmd_opts + [op] - + [data.kafile] + + ([] if data.autosign else [data.kafile]) + [arg2], extra_desc = f'({capfirst(user)}{add_desc})' ) + if op == 'sign': + return t + if op == 'sweep': t.expect( r'Create new {} .* \(y/N\): '.format(('address','account')[',' in arg2]), @@ -491,7 +517,7 @@ class TestSuiteXMRWallet(TestSuiteBase): if return_amt: amt = XMRAmt(strip_ansi_escapes(t.expect_getend('Amount: ')).replace('XMR','').strip()) - dtype = 'signed' + dtype = 'unsigned' if data.autosign else 'signed' t.expect(f'Save {dtype} transaction? (y/N): ','y') t.written_to_file(f'{dtype.capitalize()} transaction') @@ -597,7 +623,7 @@ class TestSuiteXMRWallet(TestSuiteBase): async def open_wallet_user(self,user,wnum): data = self.users[user] silence() - kal = KeyAddrList( + kal = (ViewKeyAddrList if data.autosign else KeyAddrList)( cfg = cfg, proto = self.proto, addrfile = data.kafile,