Browse Source

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
The MMGen Project 3 years ago
parent
commit
3d8ee62
3 changed files with 229 additions and 11 deletions
  1. 32 4
      mmgen/main_xmrwallet.py
  2. 126 5
      mmgen/xmrwallet.py
  3. 71 2
      test/test_py_d/ts_xmrwallet.py

+ 32 - 4
mmgen/main_xmrwallet.py

@@ -33,12 +33,15 @@ opts_data = {
 			'[opts] sync     <xmr_keyaddrfile> [wallets]',
 			'[opts] transfer <xmr_keyaddrfile> <transfer_spec>',
 			'[opts] sweep    <xmr_keyaddrfile> <sweep_spec>',
+			'[opts] relay    <TX_file>',
 		],
 		'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:<monero address>,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,
 )
 

+ 126 - 5
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')

+ 71 - 2
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):