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
This commit is contained in:
The MMGen Project 2021-07-03 12:53:47 +00:00
commit 3d8ee62e5a
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
3 changed files with 229 additions and 11 deletions

View file

@ -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 Dont start the wallet daemon at startup
-S, --no-stop-wallet-daemon Dont 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, youre
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,
)

View file

@ -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')

View file

@ -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):