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
3d8ee62e5a
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] sync     <xmr_keyaddrfile> [wallets]',
 			'[opts] transfer <xmr_keyaddrfile> <transfer_spec>',
 			'[opts] transfer <xmr_keyaddrfile> <transfer_spec>',
 			'[opts] sweep    <xmr_keyaddrfile> <sweep_spec>',
 			'[opts] sweep    <xmr_keyaddrfile> <sweep_spec>',
+			'[opts] relay    <TX_file>',
 		],
 		],
 		'options': """
 		'options': """
 -h, --help                       Print this help message
 -h, --help                       Print this help message
 --, --longhelp                   Print help message for long options (common
 --, --longhelp                   Print help message for long options (common
                                  options)
                                  options)
 -b, --rescan-blockchain          Rescan the blockchain if wallet fails to sync
 -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}
 -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, --tx-relay-daemon=H:P[:H:P]  Relay transactions via a monerod specified by
                                  {R}
                                  {R}
@@ -46,6 +49,7 @@ opts_data = {
 -p, --hash-preset=P              Use scrypt hash preset 'P' for password
 -p, --hash-preset=P              Use scrypt hash preset 'P' for password
                                  hashing (default: '{g.dfl_hash_preset}')
                                  hashing (default: '{g.dfl_hash_preset}')
 -r, --restore-height=H           Scan from height 'H' when creating wallets
 -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-start-wallet-daemon     Don’t start the wallet daemon at startup
 -S, --no-stop-wallet-daemon      Don’t stop the wallet daemon at exit
 -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'
 -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
             wallet:account
 sweep     - sweep funds in specified wallet:account to new address in same
 sweep     - sweep funds in specified wallet:account to new address in same
             account or new account in another wallet
             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
                       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
 Note that multiple sweep operations may be required to sweep all the funds
 in an account.
 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
                                   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
                                   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:
 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
 $ 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': {
 	'code': {
@@ -166,7 +189,10 @@ if op not in MoneroWalletOps.ops:
 
 
 wallets = spec = ''
 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):
 	if len(cmd_args) not in (0,1):
 		opts.usage()
 		opts.usage()
 	if cmd_args:
 	if cmd_args:
@@ -184,6 +210,7 @@ uo = namedtuple('uopts',[
 	'rescan_blockchain',
 	'rescan_blockchain',
 	'no_start_wallet_daemon',
 	'no_start_wallet_daemon',
 	'no_stop_wallet_daemon',
 	'no_stop_wallet_daemon',
+	'do_not_relay',
 	'wallet_dir',
 	'wallet_dir',
 ])
 ])
 
 
@@ -195,6 +222,7 @@ uopts = uo(
 	opt.rescan_blockchain,
 	opt.rescan_blockchain,
 	opt.no_start_wallet_daemon,
 	opt.no_start_wallet_daemon,
 	opt.no_stop_wallet_daemon,
 	opt.no_stop_wallet_daemon,
+	opt.do_not_relay,
 	opt.wallet_dir,
 	opt.wallet_dir,
 )
 )
 
 

+ 126 - 5
mmgen/xmrwallet.py

@@ -20,11 +20,11 @@
 xmrwallet.py - MoneroWalletOps class
 xmrwallet.py - MoneroWalletOps class
 """
 """
 
 
-import os,re,time
+import os,re,time,json
 from collections import namedtuple
 from collections import namedtuple
 from .common import *
 from .common import *
 from .addr import KeyAddrList,AddrIdxList
 from .addr import KeyAddrList,AddrIdxList
-from .rpc import MoneroRPCClientRaw, MoneroWalletRPCClient
+from .rpc import MoneroRPCClientRaw,MoneroWalletRPCClient,json_encoder
 from .daemon import MoneroWalletDaemon
 from .daemon import MoneroWalletDaemon
 from .protocol import _b58a,init_proto
 from .protocol import _b58a,init_proto
 from .obj import CoinAddr,CoinTxID,SeedID,AddrIdx,Hilite,InitErrors
 from .obj import CoinAddr,CoinTxID,SeedID,AddrIdx,Hilite,InitErrors
@@ -77,6 +77,23 @@ class MoneroMMGenTX:
 
 
 	class Base:
 	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',[
 		xmrwallet_tx_data = namedtuple('xmrwallet_tx_data',[
 			'op',
 			'op',
 			'create_time',
 			'create_time',
@@ -123,6 +140,29 @@ class MoneroMMGenTX:
 					d.dest_address.hl()
 					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):
 	class NewSigned(Base):
 
 
 		def __init__(self,*args,**kwargs):
 		def __init__(self,*args,**kwargs):
@@ -146,9 +186,36 @@ class MoneroMMGenTX:
 				metadata       = d.metadata,
 				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:
 class MoneroWalletOps:
 
 
-	ops = ('create','sync','transfer','sweep')
+	ops = ('create','sync','transfer','sweep','relay')
 	opts = (
 	opts = (
 		'wallet_dir',
 		'wallet_dir',
 		'daemon',
 		'daemon',
@@ -158,6 +225,7 @@ class MoneroWalletOps:
 		'restore_height',
 		'restore_height',
 		'no_start_wallet_daemon',
 		'no_start_wallet_daemon',
 		'no_stop_wallet_daemon',
 		'no_stop_wallet_daemon',
+		'do_not_relay',
 	)
 	)
 	pat_opts = ('daemon','tx_relay_daemon')
 	pat_opts = ('daemon','tx_relay_daemon')
 
 
@@ -618,7 +686,7 @@ class MoneroWalletOps:
 		past     = 'swept'
 		past     = 'swept'
 		spec_id  = 'sweep_spec'
 		spec_id  = 'sweep_spec'
 		spec_key = ( (1,'source'), (3,'dest') )
 		spec_key = ( (1,'source'), (3,'dest') )
-		opts     = ('tx_relay_daemon',)
+		opts     = ('do_not_relay','tx_relay_daemon')
 
 
 		def create_addr_data(self):
 		def create_addr_data(self):
 			m = re.fullmatch(uarg_info[self.spec_id].pat,uarg.spec,re.ASCII)
 			m = re.fullmatch(uarg_info[self.spec_id].pat,uarg.spec,re.ASCII)
@@ -737,7 +805,10 @@ class MoneroWalletOps:
 			if uopt.tx_relay_daemon:
 			if uopt.tx_relay_daemon:
 				self.display_tx_relay_info(indent='    ')
 				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'
 				w_desc = 'source'
 				if uopt.tx_relay_daemon:
 				if uopt.tx_relay_daemon:
 					await h.close_wallet('source')
 					await h.close_wallet('source')
@@ -763,3 +834,53 @@ class MoneroWalletOps:
 		past    = 'transferred'
 		past    = 'transferred'
 		spec_id = 'transfer_spec'
 		spec_id = 'transfer_spec'
 		spec_key = ( (1,'source'), )
 		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)'),
 		('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_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_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):
 	def __init__(self,trunner,cfgs,spawn):
@@ -343,6 +350,7 @@ class TestSuiteXMRWallet(TestSuiteBase):
 
 
 	def do_op(self, op, user, arg2,
 	def do_op(self, op, user, arg2,
 			tx_relay_parm = None,
 			tx_relay_parm = None,
+			do_not_relay  = False,
 			return_amt    = False,
 			return_amt    = False,
 			reuse_acct    = False,
 			reuse_acct    = False,
 			add_desc      = None,
 			add_desc      = None,
@@ -353,7 +361,8 @@ class TestSuiteXMRWallet(TestSuiteBase):
 			[f'--wallet-dir={data.udir}'],
 			[f'--wallet-dir={data.udir}'],
 			[f'--outdir={data.udir}'],
 			[f'--outdir={data.udir}'],
 			[f'--daemon=localhost:{data.md.rpc_port}'],
 			[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 ''
 		add_desc = (', ' + add_desc) if add_desc else ''
 
 
@@ -375,7 +384,12 @@ class TestSuiteXMRWallet(TestSuiteBase):
 		if return_amt:
 		if return_amt:
 			amt = XMRAmt(strip_ansi_escapes(t.expect_getend('Amt: ')).replace('XMR','').strip())
 			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()
 		t.read()
 
 
 		return t if do_ret else amt if return_amt else t.ok()
 		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)
 		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')
 		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
 	# wallet methods
 
 
 	async def open_wallet_user(self,user,wnum):
 	async def open_wallet_user(self,user,wnum):