From 3d8ee62e5a259a3e4412f7cf378989245d2105c6 Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Sat, 3 Jul 2021 12:53:47 +0000 Subject: [PATCH] mmgen-xmrwallet: TX files, offline TX creation & signing - create transactions offline using the --do-not-relay option - send transaction files with the `relay` operation A monerod with a fully synced blockchain must be running on the offline machine. For more details, type `mmgen-xmrwallet --help` Testing: test/test.py --coin=xmr -e xmrwallet --- mmgen/main_xmrwallet.py | 36 ++++++++- mmgen/xmrwallet.py | 131 +++++++++++++++++++++++++++++++-- test/test_py_d/ts_xmrwallet.py | 73 +++++++++++++++++- 3 files changed, 229 insertions(+), 11 deletions(-) diff --git a/mmgen/main_xmrwallet.py b/mmgen/main_xmrwallet.py index d40e1d0b..0ad1b6ca 100755 --- a/mmgen/main_xmrwallet.py +++ b/mmgen/main_xmrwallet.py @@ -33,12 +33,15 @@ opts_data = { '[opts] sync [wallets]', '[opts] transfer ', '[opts] sweep ', + '[opts] relay ', ], 'options': """ -h, --help Print this help message --, --longhelp Print help message for long options (common options) -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 -D, --daemon=H:P Connect to the monerod at {D} -R, --tx-relay-daemon=H:P[:H:P] Relay transactions via a monerod specified by {R} @@ -46,6 +49,7 @@ opts_data = { -p, --hash-preset=P Use scrypt hash preset 'P' for password hashing (default: '{g.dfl_hash_preset}') -r, --restore-height=H Scan from height 'H' when creating wallets +-R, --do-not-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, --wallet-dir=D Output or operate on wallets in directory 'D' @@ -72,6 +76,8 @@ transfer - transfer specified XMR amount to specified address from specified wallet:account 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 --do-not-relay option CREATE AND SYNC OPERATION NOTES @@ -113,11 +119,21 @@ The user is prompted before addresses are created or funds are transferred. Note that multiple sweep operations may be required to sweep all the funds in an account. + + 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. + + WARNING -Note that the use of this command requires private data to be exposed on a -network-connected machine in order to unlock the Monero wallets. This is a -violation of good security practice. +To avoid exposing your private keys on a network-connected machine, you’re +strongly advised to create all transactions offline using the --do-not-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. EXAMPLES @@ -142,6 +158,13 @@ $ mmgen-xmrwallet sweep *.akeys.mmenc 1:0,2 Send 0.1 XMR from account #0 of wallet 2 to an external address: $ mmgen-xmrwallet transfer *.akeys.mmenc 2:0:,0.1 + +Sweep all funds from account #0 of wallet 2 to a new address, saving the +transaction to a file: +$ mmgen-xmrwallet --do-not-relay sweep *.akeys.mmenc 2:0 + +Relay the created sweep transaction via a host on the Tor network: +$ mmgen-xmrwallet --tx-relay-daemon=abcdefghijklmnop.onion:127.0.0.1:9050 relay *XMR*.sigtx """ }, 'code': { @@ -166,7 +189,10 @@ if op not in MoneroWalletOps.ops: wallets = spec = '' -if op in ('create','sync'): +if op == 'relay': + if len(cmd_args) != 0: + opts.usage() +elif op in ('create','sync'): if len(cmd_args) not in (0,1): opts.usage() if cmd_args: @@ -184,6 +210,7 @@ uo = namedtuple('uopts',[ 'rescan_blockchain', 'no_start_wallet_daemon', 'no_stop_wallet_daemon', + 'do_not_relay', 'wallet_dir', ]) @@ -195,6 +222,7 @@ uopts = uo( opt.rescan_blockchain, opt.no_start_wallet_daemon, opt.no_stop_wallet_daemon, + opt.do_not_relay, opt.wallet_dir, ) diff --git a/mmgen/xmrwallet.py b/mmgen/xmrwallet.py index a83dcc8b..8d72716d 100755 --- a/mmgen/xmrwallet.py +++ b/mmgen/xmrwallet.py @@ -20,11 +20,11 @@ xmrwallet.py - MoneroWalletOps class """ -import os,re,time +import os,re,time,json from collections import namedtuple from .common import * from .addr import KeyAddrList,AddrIdxList -from .rpc import MoneroRPCClientRaw, MoneroWalletRPCClient +from .rpc import MoneroRPCClientRaw,MoneroWalletRPCClient,json_encoder from .daemon import MoneroWalletDaemon from .protocol import _b58a,init_proto from .obj import CoinAddr,CoinTxID,SeedID,AddrIdx,Hilite,InitErrors @@ -77,6 +77,23 @@ class MoneroMMGenTX: class Base: + def make_chksum(self,keys=None): + res = json.dumps( + dict( (k,v) for k,v in self.data._asdict().items() if (not keys or k in keys) ), + cls = json_encoder + ) + return make_chksum_6(res) + + @property + def base_chksum(self): + return self.make_chksum( + ('op','create_time','network','seed_id','source','dest','amount') + ) + + @property + def full_chksum(self): + return self.make_chksum(set(self.data._fields) - {'metadata'}) + xmrwallet_tx_data = namedtuple('xmrwallet_tx_data',[ 'op', 'create_time', @@ -123,6 +140,29 @@ class MoneroMMGenTX: d.dest_address.hl() ) + def write(self,delete_metadata=False): + dict_data = self.data._asdict() + if delete_metadata: + dict_data['metadata'] = None + + out = json.dumps( + { 'MoneroMMGenTX': { + 'base_chksum': self.base_chksum, + 'full_chksum': self.full_chksum, + 'data': dict_data, + } + }, + cls = json_encoder, + indent = 4 # DEBUG + ) + fn = '{}{}-XMR[{!s}]{}.sigtx'.format( + self.base_chksum.upper(), + ('-'+self.full_chksum.upper()) if self.full_chksum else '', + self.data.amount, + (lambda s: '' if s == 'mainnet' else f'.{s}')(self.data.network), + ) + write_data_to_file(fn,out,desc='MoneroMMGenTX data',ask_write=True,ask_write_default_yes=False) + class NewSigned(Base): def __init__(self,*args,**kwargs): @@ -146,9 +186,36 @@ class MoneroMMGenTX: metadata = d.metadata, ) + class Signed(Base): + + def __init__(self,fn): + self.fn = fn + d_wrap = json.loads(get_data_from_file(fn))['MoneroMMGenTX'] + d = self.xmrwallet_tx_data(**d_wrap['data']) + proto = init_proto('xmr',network=d.network) + self.data = self.xmrwallet_tx_data( + op = d.op, + create_time = d.create_time, + sign_time = d.sign_time, + network = d.network, + seed_id = SeedID(sid=d.seed_id), + source = XMRWalletAddrSpec(d.source), + dest = None if d.dest is None else XMRWalletAddrSpec(d.dest), + dest_address = CoinAddr(proto,d.dest_address), + txid = CoinTxID(d.txid), + amount = proto.coin_amt(d.amount), + fee = proto.coin_amt(d.fee), + blob = d.blob, + metadata = d.metadata, + ) + for k in ('base_chksum','full_chksum'): + a = getattr(self,k) + b = d_wrap[k] + assert a == b, f'{k} mismatch: {a} != {b}' + class MoneroWalletOps: - ops = ('create','sync','transfer','sweep') + ops = ('create','sync','transfer','sweep','relay') opts = ( 'wallet_dir', 'daemon', @@ -158,6 +225,7 @@ class MoneroWalletOps: 'restore_height', 'no_start_wallet_daemon', 'no_stop_wallet_daemon', + 'do_not_relay', ) pat_opts = ('daemon','tx_relay_daemon') @@ -618,7 +686,7 @@ class MoneroWalletOps: past = 'swept' spec_id = 'sweep_spec' spec_key = ( (1,'source'), (3,'dest') ) - opts = ('tx_relay_daemon',) + opts = ('do_not_relay','tx_relay_daemon') def create_addr_data(self): m = re.fullmatch(uarg_info[self.spec_id].pat,uarg.spec,re.ASCII) @@ -737,7 +805,10 @@ class MoneroWalletOps: if uopt.tx_relay_daemon: self.display_tx_relay_info(indent=' ') - if keypress_confirm(f'Relay {self.name} transaction?'): + if uopt.do_not_relay: + msg('Saving TX data to file') + new_tx.write(delete_metadata=True) + elif keypress_confirm(f'Relay {self.name} transaction?'): w_desc = 'source' if uopt.tx_relay_daemon: await h.close_wallet('source') @@ -763,3 +834,53 @@ class MoneroWalletOps: past = 'transferred' spec_id = 'transfer_spec' spec_key = ( (1,'source'), ) + + class relay(base): + name = 'relay' + desc = 'Relay' + past = 'relayed' + opts = ('tx_relay_daemon',) + + def __init__(self,uarg_tuple,uopt_tuple): + + super().__init__(uarg_tuple,uopt_tuple) + + if uopt.tx_relay_daemon: + m = re.fullmatch(uarg_info['tx_relay_daemon'].pat,uopt.tx_relay_daemon,re.ASCII) + host,port = m[1].split(':') + proxy = m[2] + else: + from .daemon import CoinDaemon + md = CoinDaemon('xmr',test_suite=g.test_suite) + host,port = md.host,md.rpc_port + proxy = None + + self.dc = MoneroRPCClientRaw( + host = host, + port = int(port), + user = None, + passwd = None, + proxy = proxy ) + + self.tx = MoneroMMGenTX.Signed(uarg.infile) + + async def main(self): + msg('\n' + self.tx.get_info()) + + if uopt.tx_relay_daemon: + self.display_tx_relay_info() + + if keypress_confirm('Relay transaction?'): + res = await self.dc.call( + 'send_raw_transaction', + tx_as_hex = self.tx.data.blob + ) + if res['status'] == 'OK': + msg('Status: ' + green('OK')) + if res['not_relayed']: + ymsg('Transaction not relayed') + return True + else: + raise RPCFailure(repr(res)) + else: + die(1,'Exiting at user request') diff --git a/test/test_py_d/ts_xmrwallet.py b/test/test_py_d/ts_xmrwallet.py index d817fce5..1d65129f 100755 --- a/test/test_py_d/ts_xmrwallet.py +++ b/test/test_py_d/ts_xmrwallet.py @@ -56,6 +56,13 @@ class TestSuiteXMRWallet(TestSuiteBase): ('sweep_to_address_noproxy', 'sweeping to new address (via TX relay, no proxy)'), ('transfer_to_miner_proxy', 'transferring funds to Miner (via TX relay + proxy)'), ('transfer_to_miner_noproxy', 'transferring funds to Miner (via TX relay, no proxy)'), + + ('transfer_to_miner_create1', 'transferring funds to Miner (create TX)'), + ('transfer_to_miner_send1', 'transferring funds to Miner (send TX via proxy)'), + ('transfer_to_miner_create2', 'transferring funds to Miner (create TX)'), + ('transfer_to_miner_send2', 'transferring funds to Miner (send TX, no proxy)'), + + ('sweep_create_and_send', 'sweeping to new account (create TX + send TX, in stages)'), ) def __init__(self,trunner,cfgs,spawn): @@ -343,6 +350,7 @@ class TestSuiteXMRWallet(TestSuiteBase): def do_op(self, op, user, arg2, tx_relay_parm = None, + do_not_relay = False, return_amt = False, reuse_acct = False, add_desc = None, @@ -353,7 +361,8 @@ class TestSuiteXMRWallet(TestSuiteBase): [f'--wallet-dir={data.udir}'], [f'--outdir={data.udir}'], [f'--daemon=localhost:{data.md.rpc_port}'], - [f'--tx-relay-daemon={tx_relay_parm}', tx_relay_parm] + [f'--tx-relay-daemon={tx_relay_parm}', tx_relay_parm], + ['--do-not-relay', do_not_relay] ) add_desc = (', ' + add_desc) if add_desc else '' @@ -375,7 +384,12 @@ class TestSuiteXMRWallet(TestSuiteBase): if return_amt: amt = XMRAmt(strip_ansi_escapes(t.expect_getend('Amt: ')).replace('XMR','').strip()) - t.expect(f'Relay {op} transaction? (y/N): ','y') + if do_not_relay: + t.expect('Save MoneroMMGenTX data? (y/N): ','y') + t.written_to_file('MoneroMMGenTX data') + else: + t.expect(f'Relay {op} transaction? (y/N): ','y') + t.read() return t if do_ret else amt if return_amt else t.ok() @@ -403,6 +417,61 @@ class TestSuiteXMRWallet(TestSuiteBase): self.do_op('transfer','alice',f'2:1:{addr},0.0995',self.tx_relay_daemon_parm) return self.mine_chk('miner',2,0,lambda x: str(x) == '0.2345','unlocked balance == 0.2345') + def transfer_to_miner_create(self,amt): + get_file_with_ext(self.users['alice'].udir,'sigtx',delete_all=True) + addr = read_from_file(self.users['miner'].addrfile_fs.format(2)) + return self.do_op('transfer','alice',f'2:1:{addr},{amt}',do_not_relay=True,do_ret=True) + + def transfer_to_miner_create1(self): + return self.transfer_to_miner_create('0.0111') + + def transfer_to_miner_create2(self): + return self.transfer_to_miner_create('0.0012') + + def relay_tx(self,relay_opt=None,add_desc=None): + user = 'alice' + data = self.users[user] + fn = get_file_with_ext(data.udir,'sigtx') + add_desc = (', ' + add_desc) if add_desc else '' + t = self.spawn( + 'mmgen-xmrwallet', + self.long_opts + + ([relay_opt] if relay_opt else []) + + [ 'relay', fn ], + extra_desc = f'(relaying TX, {capfirst(user)}{add_desc})' ) + t.expect('Relay transaction? ','y') + t.read() + t.ok() + + def transfer_to_miner_send1(self): + self.relay_tx(f'--tx-relay-daemon={self.tx_relay_daemon_proxy_parm}',add_desc='via proxy') + return self.mine_chk('miner',2,0,lambda x: str(x) == '0.2456','unlocked balance == 0.2456') + + def transfer_to_miner_send2(self): + self.relay_tx(f'--tx-relay-daemon={self.tx_relay_daemon_parm}',add_desc='no proxy') + return self.mine_chk('miner',2,0,lambda x: str(x) == '0.2468','unlocked balance == 0.2468') + + async def sweep_create_and_send(self): + bal = XMRAmt('0') + min_bal = XMRAmt('0.9') + + for i in range(4): + if i: ok() + get_file_with_ext(self.users['alice'].udir,'sigtx',delete_all=True) + send_amt = self.do_op( + 'sweep','alice','2:1,3', # '2:1,3' + do_not_relay = True, + reuse_acct = True, + add_desc = f'TX #{i+1}', + return_amt = True ) + ok() + self.relay_tx(f'--tx-relay-daemon={self.tx_relay_daemon_parm}',add_desc=f'send amt: {send_amt} XMR') + bal += await self.mine_chk('alice',3,0,lambda x: 'chk_bal_chg','balance has changed') # 3,0 + if bal >= min_bal: + return 'ok' + + return False + # wallet methods async def open_wallet_user(self,user,wnum):