offline transaction signing with automount for BTC, BCH, LTC and ETH/ERC20

Previously supported only for XMR, offline transaction autosigning with no
filename arguments and automatic mounting/unmounting of the removable device
on the online machine is now available for all coins MMGen Wallet supports
transacting with.  To activate, invoke ‘mmgen-txcreate’ and ‘mmgen-txsend’
with the --autosign option.

Be aware that transactions must be created, signed and sent one at a time when
using this feature.  For bulk transaction signing, you must use the old manual
mounting method.

Example create-sign-send workflow for BTC:

    $ mmgen-txcreate --autosign bc1qxmymxf8p5ckvlxkmkwgw8ap5t2xuaffmrpexap,0.00123 B

    (remove device - insert offline - wait for signing - remove - insert online)

    $ mmgen-txsend --autosign

Unsigned or unsent transactions may be aborted as follows:

    $ mmgen-txsend --abort

And sent RBF transactions may be fee-bumped:

    $ mmgen-txbump --autosign

You can check the status of the current transaction, whether sent or unsent,
with the following command:

    $ mmgen-txsend --status

That’s all there is to it!

Testing (add the -e option to see script output):

    $ test/cmdtest.py autosign_automount
    $ test/cmdtest.py --coin=eth autosign_eth
This commit is contained in:
The MMGen Project 2024-02-29 15:37:34 +00:00
commit 1c5c3319d4
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
27 changed files with 719 additions and 45 deletions

View file

@ -28,6 +28,7 @@ class Signable:
non_xmr_signables = (
'transaction',
'automount_transaction',
'message')
xmr_signables = ( # order is important!
@ -62,6 +63,13 @@ class Signable:
def unsubmitted(self):
return self._unprocessed( '_unsubmitted', self.sigext, self.subext )
@property
def unsubmitted_raw(self):
return self._unprocessed( '_unsubmitted_raw', self.rawext, self.subext )
unsent = unsubmitted
unsent_raw = unsubmitted_raw
def _unprocessed(self,attrname,rawext,sigext):
if not hasattr(self,attrname):
dirlist = sorted(self.dir.iterdir())
@ -91,20 +99,45 @@ class Signable:
e = f'in ‘{getattr(self.parent, self.dir_name)}' if show_dir else 'on removable device',
))
def check_create_ok(self):
if len(self.unsigned):
self.die_wrong_num_txs('unsigned', msg='Cannot create transaction')
def get_unsubmitted(self, tx_type='unsubmitted'):
if len(self.unsubmitted) == 1:
return self.unsubmitted[0]
else:
self.die_wrong_num_txs(tx_type)
def get_unsent(self):
return self.get_unsubmitted('unsent')
def get_submitted(self):
if len(self.submitted) == 0:
self.die_wrong_num_txs('submitted')
else:
return self.submitted
def get_abortable(self):
if len(self.unsent_raw) != 1:
self.die_wrong_num_txs('unsent_raw', desc='unsent')
if len(self.unsent) > 1:
self.die_wrong_num_txs('unsent')
if self.unsent:
if self.unsent[0].stem != self.unsent_raw[0].stem:
die(1, f'{self.unsent[0]}, {self.unsent_raw[0]}: file mismatch')
return self.unsent_raw + self.unsent
async def get_last_created(self):
from .tx import CompletedTX
ext = '.' + Signable.automount_transaction.subext
files = [f for f in self.dir.iterdir() if f.name.endswith(ext)]
return sorted(
[await CompletedTX(cfg=self.cfg, filename=str(txfile), quiet_open=True) for txfile in files],
key = lambda x: x.timestamp)[-1]
class transaction(base):
desc = 'transaction'
desc = 'non-automount transaction'
rawext = 'rawtx'
sigext = 'sigtx'
dir_name = 'tx_dir'
@ -112,7 +145,10 @@ class Signable:
async def sign(self,f):
from .tx import UnsignedTX
tx1 = UnsignedTX( cfg=self.cfg, filename=f )
tx1 = UnsignedTX(
cfg = self.cfg,
filename = f,
automount = self.name=='automount_transaction')
if tx1.proto.sign_mode == 'daemon':
from .rpc import rpc_init
tx1.rpc = await rpc_init( self.cfg, tx1.proto, ignore_wallet=True )
@ -168,6 +204,14 @@ class Signable:
for f in bad_files:
yield red(f.name)
class automount_transaction(transaction):
desc = 'automount transaction'
dir_name = 'txauto_dir'
rawext = 'arawtx'
sigext = 'asigtx'
subext = 'asubtx'
multiple_ok = False
class xmr_signable(transaction): # mixin class
def need_daemon_restart(self,m,new_idx):
@ -278,6 +322,7 @@ class Autosign:
non_xmr_dirs = {
'tx_dir': 'tx',
'txauto_dir': 'txauto',
'msg_dir': 'msg',
}
xmr_dirs = {

View file

@ -1 +1 @@
14.1.dev14
14.1.dev15

View file

@ -87,9 +87,7 @@ the status LED indicates whether the program is busy or in standby mode, i.e.
ready for device insertion or removal.
The removable device must have a partition labeled MMGEN_TX with a user-
writable root directory and a directory named /tx, where unsigned MMGen
transactions are placed. Optionally, the directory /msg may be created
and unsigned message files produced by mmgen-msg placed there.
writable root directory.
On both the signing and online machines the mountpoint {asi.mountpoint}
(as currently configured) must exist and /etc/fstab must contain the

View file

@ -33,10 +33,17 @@ opts_data = {
creating a new transaction, and optionally sign and send the
new transaction
""",
'usage': f'[opts] <{gc.proj_name} TX file> [seed source] ...',
'usage': f'[opts] [{gc.proj_name} TX file] [seed source] ...',
'options': """
-h, --help Print this help message
--, --longhelp Print help message for long options (common options)
-a, --autosign Bump the most recent transaction created and sent with
the --autosign option. The removable device is mounted
and unmounted automatically. The transaction file
argument must be omitted. Note that only sent trans-
actions may be bumped with this option. To redo an
unsent --autosign transaction, first delete it using
mmgen-txsend --abort and then create a new one
-b, --brain-params=l,p Use seed length 'l' and hash preset 'p' for
brainwallet input
-c, --comment-file= f Source the transaction's comment from file 'f'
@ -103,10 +110,10 @@ FMT CODES:
cfg = Config(opts_data=opts_data)
tx_file = cfg._args.pop(0)
from .fileutil import check_infile
check_infile(tx_file)
if not cfg.autosign:
tx_file = cfg._args.pop(0)
from .fileutil import check_infile
check_infile(tx_file)
from .tx import CompletedTX, BumpTX, UnsignedTX, OnlineSignedTX
from .tx.sign import txsign,get_seed_files,get_keyaddrlist,get_keylist
@ -120,20 +127,37 @@ silent = cfg.yes and cfg.fee is not None and cfg.output_to_reduce is not None
async def main():
orig_tx = await CompletedTX(cfg=cfg,filename=tx_file)
if cfg.autosign:
from .tx.util import init_removable_device
from .autosign import Signable
asi = init_removable_device(cfg)
asi.do_mount()
si = Signable.automount_transaction(asi)
if si.unsigned or si.unsent:
state = 'unsigned' if si.unsigned else 'unsent'
die(1,
'Only sent transactions can be bumped with --autosign. Instead of bumping\n'
f'your {state} transaction, abort it with ‘mmgen-txsend --abort’ and create\n'
'a new one.')
orig_tx = await si.get_last_created()
kal = kl = sign_and_send = None
else:
orig_tx = await CompletedTX(cfg=cfg, filename=tx_file)
if not silent:
msg(green('ORIGINAL TRANSACTION'))
msg(orig_tx.info.format(terse=True))
kal = get_keyaddrlist(cfg,orig_tx.proto)
kl = get_keylist(cfg)
sign_and_send = bool(seed_files or kl or kal)
if not cfg.autosign:
kal = get_keyaddrlist(cfg, orig_tx.proto)
kl = get_keylist(cfg)
sign_and_send = any([seed_files, kl, kal])
from .tw.ctl import TwCtl
tx = await BumpTX(
cfg = cfg,
data = orig_tx.__dict__,
automount = cfg.autosign,
check_sent = cfg.autosign or sign_and_send,
twctl = await TwCtl(cfg,orig_tx.proto) if orig_tx.proto.tokensym else None )
@ -181,6 +205,7 @@ async def main():
die(2,'Transaction could not be signed')
else:
tx.file.write(
outdir = asi.txauto_dir if cfg.autosign else None,
ask_write = not cfg.yes,
ask_write_default_yes = False,
ask_overwrite = not cfg.yes)

View file

@ -32,6 +32,9 @@ opts_data = {
'options': """
-h, --help Print this help message
--, --longhelp Print help message for long options (common options)
-a, --autosign Create a transaction for offline autosigning (see
mmgen-autosign). The removable device is mounted and
unmounted automatically
-A, --fee-adjust= f Adjust transaction fee by factor 'f' (see below)
-B, --no-blank Don't blank screen before displaying unspent outputs
-c, --comment-file=f Source the transaction's comment from file 'f'
@ -83,6 +86,13 @@ cfg = Config(opts_data=opts_data)
async def main():
if cfg.autosign:
from .tx.util import init_removable_device
from .autosign import Signable
asi = init_removable_device(cfg)
asi.do_mount()
Signable.automount_transaction(asi).check_create_ok()
from .tx import NewTX
tx1 = await NewTX(cfg=cfg,proto=cfg._proto)
@ -95,6 +105,7 @@ async def main():
do_info = cfg.info )
tx2.file.write(
outdir = asi.txauto_dir if cfg.autosign else None,
ask_write = not cfg.yes,
ask_overwrite = not cfg.yes,
ask_write_default_yes = False)

View file

@ -23,19 +23,31 @@ mmgen-txsend: Broadcast a transaction signed by 'mmgen-txsign' to the network
import sys
from .cfg import gc,Config
from .util import async_run
from .util import async_run, msg, suf, die, fmt_list
from .fileutil import shred_file
opts_data = {
'sets': [('yes', True, 'quiet', True)],
'sets': [
('yes', True, 'quiet', True),
('abort', True, 'autosign', True),
],
'text': {
'desc': f'Send a signed {gc.proj_name} cryptocoin transaction',
'usage': '[opts] <signed transaction file>',
'usage': '[opts] [signed transaction file]',
'options': """
-h, --help Print this help message
--, --longhelp Print help message for long options (common options)
-a, --autosign Send an autosigned transaction created by mmgen-txcreate
--autosign. The removable device is mounted and unmounted
automatically. The transaction file argument must be omitted
when using this option
-A, --abort Abort an unsent transaction created by mmgen-txcreate
--autosign and delete it from the removable device. The
transaction may be signed or unsigned.
-d, --outdir= d Specify an alternate directory 'd' for output
-q, --quiet Suppress warnings; overwrite files without prompting
-s, --status Get status of a sent transaction
-s, --status Get status of a sent transaction (or the current transaction,
whether sent or unsent, when used with --autosign)
-v, --verbose Be more verbose
-y, --yes Answer 'yes' to prompts, suppress non-essential output
"""
@ -44,10 +56,41 @@ opts_data = {
cfg = Config(opts_data=opts_data)
if cfg.autosign and cfg.outdir:
die(1, '--outdir cannot be used in combination with --autosign')
if len(cfg._args) == 1:
infile = cfg._args[0]
from .fileutil import check_infile
check_infile(infile)
elif not cfg._args and cfg.autosign:
from .tx.util import init_removable_device
from .autosign import Signable
asi = init_removable_device(cfg)
asi.do_mount()
si = Signable.automount_transaction(asi)
if cfg.abort:
files = si.get_abortable() # raises AutosignTXError if no unsent TXs available
from .ui import keypress_confirm
if keypress_confirm(
cfg,
'The following file{} will be securely deleted:\n{}\nOK?'.format(
suf(files),
fmt_list(map(str, files), fmt='col', indent=' '))):
for f in files:
msg(f'Shredding file ‘{f}')
shred_file(f)
sys.exit(0)
else:
die(1, 'Exiting at user request')
elif cfg.status:
if si.unsent:
die(1, 'Transaction is unsent')
if si.unsigned:
die(1, 'Transaction is unsigned')
else:
infile = si.get_unsent()
cfg._util.qmsg(f'Got signed transaction file ‘{infile}')
else:
cfg._opts.usage()
@ -59,10 +102,14 @@ async def main():
from .tx import OnlineSignedTX, SentTX
tx = await OnlineSignedTX(
cfg = cfg,
filename = infile,
quiet_open = True)
if cfg.status and cfg.autosign:
tx = await si.get_last_created()
else:
tx = await OnlineSignedTX(
cfg = cfg,
filename = infile,
automount = cfg.autosign,
quiet_open = True)
from .rpc import rpc_init
tx.rpc = await rpc_init(cfg,tx.proto)
@ -78,11 +125,13 @@ async def main():
if not cfg.yes:
tx.info.view_with_prompt('View transaction details?')
if tx.add_comment(): # edits an existing comment, returns true if changed
tx.file.write(ask_write_default_yes=True)
if not cfg.autosign:
tx.file.write(ask_write_default_yes=True)
if await tx.send():
tx2 = await SentTX(cfg=cfg, data=tx.__dict__)
tx2 = await SentTX(cfg=cfg, data=tx.__dict__, automount=cfg.autosign)
tx2.file.write(
outdir = asi.txauto_dir if cfg.autosign else None,
ask_overwrite = False,
ask_write = False)
tx2.print_contract_addr()

View file

@ -16,6 +16,7 @@ from ....tx import bump as TxBase
from ....util import msg
from .new import New
from .completed import Completed
from .unsigned import AutomountUnsigned
class Bump(Completed,New,TxBase.Bump):
desc = 'fee-bumped transaction'
@ -52,3 +53,8 @@ class Bump(Completed,New,TxBase.Bump):
c = self.coin ))
return False
return ret
class AutomountBump(Bump):
desc = 'unsigned fee-bumped automount transaction'
ext = AutomountUnsigned.ext
automount = AutomountUnsigned.automount

View file

@ -77,3 +77,9 @@ class OnlineSigned(Signed,TxBase.OnlineSigned):
class Sent(TxBase.Sent, OnlineSigned):
pass
class AutomountOnlineSigned(TxBase.AutomountOnlineSigned, OnlineSigned):
pass
class AutomountSent(TxBase.AutomountSent, AutomountOnlineSigned):
pass

View file

@ -30,3 +30,6 @@ class Signed(Completed,TxBase.Signed):
Your transaction fee estimates will be inaccurate
Please re-create and re-sign the transaction using the option --vsize-adj={1/ratio:1.2f}
""").strip())
class AutomountSigned(TxBase.AutomountSigned, Signed):
pass

View file

@ -67,7 +67,7 @@ class Unsigned(Completed,TxBase.Unsigned):
try:
self.update_serialized(ret['hex'])
from ....tx import SignedTX
new = await SignedTX(cfg=self.cfg,data=self.__dict__)
new = await SignedTX(cfg=self.cfg, data=self.__dict__, automount=self.automount)
tx_decoded = await self.rpc.call( 'decoderawtransaction', ret['hex'] )
new.compare_size_and_estimated_size(tx_decoded)
new.coin_txid = CoinTxID(self.deserialized.txid)
@ -81,3 +81,6 @@ class Unsigned(Completed,TxBase.Unsigned):
import sys,traceback
ymsg( '\n' + ''.join(traceback.format_exception(*sys.exc_info())) )
return False
class AutomountUnsigned(TxBase.AutomountUnsigned, Unsigned):
pass

View file

@ -33,3 +33,9 @@ class Bump(Completed,New,TxBase.Bump):
class TokenBump(TokenCompleted,TokenNew,Bump):
desc = 'fee-bumped transaction'
class AutomountBump(Bump):
pass
class TokenAutomountBump(TokenBump):
pass

View file

@ -77,3 +77,15 @@ class Sent(TxBase.Sent, OnlineSigned):
class TokenSent(TxBase.Sent, TokenOnlineSigned):
pass
class AutomountOnlineSigned(TxBase.AutomountOnlineSigned, OnlineSigned):
pass
class AutomountSent(TxBase.AutomountSent, AutomountOnlineSigned):
pass
class TokenAutomountOnlineSigned(TxBase.AutomountOnlineSigned, TokenOnlineSigned):
pass
class TokenAutomountSent(TxBase.AutomountSent, TokenAutomountOnlineSigned):
pass

View file

@ -54,3 +54,9 @@ class TokenSigned(TokenCompleted,Signed):
def parse_txfile_serialized_data(self):
raise NotImplementedError(
'Signed transaction files cannot be parsed offline, because tracking wallet is required!')
class AutomountSigned(TxBase.AutomountSigned, Signed):
pass
class TokenAutomountSigned(TxBase.AutomountSigned, TokenSigned):
pass

View file

@ -80,7 +80,7 @@ class Unsigned(Completed,TxBase.Unsigned):
await self.do_sign(keys[0].sec.wif)
msg('OK')
from ....tx import SignedTX
return await SignedTX(cfg=self.cfg,data=self.__dict__)
return await SignedTX(cfg=self.cfg, data=self.__dict__, automount=self.automount)
except Exception as e:
msg(f'{e}: transaction signing failed!')
return False
@ -107,3 +107,9 @@ class TokenUnsigned(TokenCompleted,Unsigned):
gasPrice = o['gasPrice'],
nonce = o['nonce'])
(self.serialized,self.coin_txid) = await t.txsign(tx_in,wif,o['from'],chain_id=o['chainId'])
class AutomountUnsigned(TxBase.AutomountUnsigned, Unsigned):
pass
class TokenAutomountUnsigned(TxBase.AutomountUnsigned, TokenUnsigned):
pass

View file

@ -52,6 +52,11 @@ def _get_cls_info(clsname,modname,args,kwargs):
kwargs['proto'] = proto
if 'automount' in kwargs:
if kwargs['automount']:
clsname = 'Automount' + clsname
del kwargs['automount']
return ( kwargs['cfg'], proto, clsname, modname, kwargs )
@ -74,7 +79,9 @@ async def _get_obj_async( _clsname, _modname, *args, **kwargs ):
if proto and proto.tokensym and clsname in (
'New',
'OnlineSigned',
'Sent'):
'AutomountOnlineSigned',
'Sent',
'AutomountSent'):
from ..tw.ctl import TwCtl
kwargs['twctl'] = await TwCtl(cfg,proto)

View file

@ -54,15 +54,17 @@ class Completed(Base):
"""
see twctl:import_token()
"""
from .unsigned import Unsigned
from .online import Sent
for cls in (Unsigned, Sent):
from .unsigned import Unsigned, AutomountUnsigned
from .online import Sent, AutomountSent
for cls in (Unsigned, AutomountUnsigned, Sent, AutomountSent):
if ext == getattr(cls, 'ext'):
return cls
if proto.tokensym:
from .online import OnlineSigned as Signed
from .online import AutomountOnlineSigned as AutomountSigned
else:
from .signed import Signed
if ext == Signed.ext:
return Signed
from .signed import Signed, AutomountSigned
for cls in (Signed, AutomountSigned):
if ext == getattr(cls, 'ext'):
return cls

View file

@ -436,7 +436,7 @@ class New(Base):
self.cfg._util.qmsg('Transaction successfully created')
from . import UnsignedTX
new = UnsignedTX(cfg=self.cfg,data=self.__dict__)
new = UnsignedTX(cfg=self.cfg, data=self.__dict__, automount=self.cfg.autosign)
if not self.cfg.yes:
new.info.view_with_prompt('View transaction details?')

View file

@ -12,7 +12,7 @@
tx.online: online signed transaction class
"""
from .signed import Signed
from .signed import Signed, AutomountSigned
class OnlineSigned(Signed):
@ -31,6 +31,13 @@ class OnlineSigned(Signed):
expect = 'YES' if self.cfg.quiet or self.cfg.yes else 'YES, I REALLY WANT TO DO THIS' )
msg('Sending transaction')
class AutomountOnlineSigned(AutomountSigned, OnlineSigned):
pass
class Sent(OnlineSigned):
desc = 'sent transaction'
ext = 'subtx'
class AutomountSent(AutomountOnlineSigned):
desc = 'sent automount transaction'
ext = 'asubtx'

View file

@ -112,8 +112,8 @@ def _pop_matching_fns(args,cmplist): # strips found args
return list(reversed([args.pop(args.index(a)) for a in reversed(args) if get_extension(a) in cmplist]))
def get_tx_files(cfg, args):
from .unsigned import Unsigned
ret = _pop_matching_fns(args,[Unsigned.ext])
from .unsigned import Unsigned, AutomountUnsigned
ret = _pop_matching_fns(args, [(AutomountUnsigned if cfg.autosign else Unsigned).ext])
if not ret:
die(1,'You must specify a raw transaction file!')
return ret

View file

@ -18,3 +18,7 @@ class Signed(Completed):
desc = 'signed transaction'
ext = 'sigtx'
signed = True
class AutomountSigned(Signed):
desc = 'signed automount transaction'
ext = 'asigtx'

View file

@ -18,6 +18,7 @@ from ..util import remove_dups
class Unsigned(Completed):
desc = 'unsigned transaction'
ext = 'rawtx'
automount = False
def delete_attrs(self,desc,attr):
for e in getattr(self,desc):
@ -28,3 +29,8 @@ class Unsigned(Completed):
return remove_dups(
(e.mmid.sid for e in getattr(self,desc) if e.mmid),
quiet = True )
class AutomountUnsigned(Unsigned):
desc = 'unsigned automount transaction'
ext = 'arawtx'
automount = True

35
mmgen/tx/util.py Executable file
View file

@ -0,0 +1,35 @@
#!/usr/bin/env python3
#
# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
# Copyright (C)2013-2024 The MMGen Project <mmgen@tuta.io>
# Licensed under the GNU General Public License, Version 3:
# https://www.gnu.org/licenses
# Public project repositories:
# https://github.com/mmgen/mmgen-wallet
# https://gitlab.com/mmgen/mmgen-wallet
"""
tx.util: transaction utilities
"""
def get_autosign_obj(cfg):
from ..cfg import Config
from ..autosign import Autosign
return Autosign(
Config({
'mountpoint': cfg.autosign_mountpoint,
'test_suite': cfg.test_suite,
'test_suite_root_pfx': cfg.test_suite_root_pfx,
'coins': cfg.coin,
'online': True, # used only in online environment (txcreate, txsend)
})
)
def init_removable_device(cfg):
asi = get_autosign_obj(cfg)
if not asi.get_insert_status():
from ..util import die
die(1, 'Removable device not present!')
import atexit
atexit.register(lambda: asi.do_umount())
return asi

View file

@ -34,6 +34,8 @@ cmd_groups_dfl = {
'output': ('CmdTestOutput',{'modname':'misc','full_data':True}),
'autosign_clean': ('CmdTestAutosignClean', {'modname':'autosign'}),
'autosign': ('CmdTestAutosign',{}),
'autosign_automount': ('CmdTestAutosignAutomount', {'modname':'automount'}),
'autosign_eth': ('CmdTestAutosignETH', {'modname':'automount_eth'}),
'regtest': ('CmdTestRegtest',{}),
# 'chainsplit': ('CmdTestChainsplit',{}),
'ethdev': ('CmdTestEthdev',{}),
@ -221,6 +223,8 @@ cfgs = { # addr_idx_lists (except 31,32,33,34) must contain exactly 8 addresses
'39': {}, # xmr_autosign
'40': {}, # cfgfile
'41': {}, # opts
'49': {}, # autosign_automount
'59': {}, # autosign_eth
'99': {}, # dummy
}

259
test/cmdtest_py_d/ct_automount.py Executable file
View file

@ -0,0 +1,259 @@
#!/usr/bin/env python3
#
# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
# Copyright (C)2013-2024 The MMGen Project <mmgen@tuta.io>
# Licensed under the GNU General Public License, Version 3:
# https://www.gnu.org/licenses
# Public project repositories:
# https://github.com/mmgen/mmgen-wallet
# https://gitlab.com/mmgen/mmgen-wallet
"""
test.cmdtest_py_d.ct_automount: autosigning with automount tests for the cmdtest.py test suite
"""
import os, time
from pathlib import Path
from .ct_autosign import CmdTestAutosignThreaded
from .ct_regtest import CmdTestRegtest, rt_pw
from .common import get_file_with_ext
from ..include.common import cfg
class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtest):
'automounted transacting operations via regtest mode'
networks = ('btc', 'bch', 'ltc')
tmpdir_nums = [49]
rtFundAmt = None # pylint
rt_data = {
'rtFundAmt': {'btc':'500', 'bch':'500', 'ltc':'5500'},
}
cmd_group = (
('setup', 'regtest mode setup'),
('walletgen_alice', 'wallet generation (Alice)'),
('addrgen_alice', 'address generation (Alice)'),
('addrimport_alice', 'importing Alice’s addresses'),
('fund_alice', 'funding Alice’s wallet'),
('generate', 'mining a block'),
('alice_bal1', 'checking Alice’s balance'),
('alice_txcreate1', 'creating a transaction'),
('alice_txcreate_bad_have_unsigned', 'creating the transaction again (error)'),
('copy_wallet', 'copying Alice’s wallet'),
('alice_run_autosign_setup', 'running ‘autosign setup’ (with default wallet)'),
('autosign_start_thread', 'starting autosign wait loop'),
('alice_txstatus1', 'getting transaction status (unsigned)'),
('alice_txstatus2', 'getting transaction status (unsent)'),
('alice_txsend1', 'sending a transaction, editing comment'),
('alice_txstatus3', 'getting transaction status (in mempool)'),
('alice_txsend_bad_no_unsent', 'sending the transaction again (error)'),
('generate', 'mining a block'),
('alice_txstatus4', 'getting transaction status (one confirmation)'),
('alice_txcreate2', 'creating a transaction'),
('alice_txsend_abort1', 'aborting the transaction (raw only)'),
('alice_txsend_abort2', 'aborting the transaction again (error)'),
('alice_txcreate3', 'creating a transaction'),
('alice_txsend_abort3', 'aborting the transaction (user exit)'),
('alice_txsend_abort4', 'aborting the transaction (raw + signed)'),
('alice_txsend_abort5', 'aborting the transaction again (error)'),
('generate', 'mining a block'),
('alice_txcreate4', 'creating a transaction'),
('alice_txbump1', 'bumping the unsigned transaction (error)'),
('alice_txbump2', 'bumping the unsent transaction (error)'),
('alice_txsend2', 'sending the transaction'),
('alice_txbump3', 'bumping the transaction'),
('alice_txsend3', 'sending the bumped transaction'),
('autosign_kill_thread', 'stopping autosign wait loop'),
('stop', 'stopping regtest daemon'),
('txview', 'viewing transactions'),
)
def __init__(self, trunner, cfgs, spawn):
self.coins = [cfg.coin.lower()]
CmdTestAutosignThreaded.__init__(self, trunner, cfgs, spawn)
CmdTestRegtest.__init__(self, trunner, cfgs, spawn)
if trunner == None:
return
self.opts.append('--alice')
def _alice_txcreate(self, chg_addr, opts=[], exit_val=0):
self.insert_device_online()
sid = self._user_sid('alice')
t = self.spawn(
'mmgen-txcreate',
opts
+ ['--alice', '--autosign']
+ [f'{self.burn_addr},1.23456', f'{sid}:{chg_addr}'])
if exit_val:
t.read()
self.remove_device_online()
t.req_exit_val = exit_val
return t
t = self.txcreate_ui_common(
t,
inputs = '1',
interactive_fee = '32s',
file_desc = 'Unsigned automount transaction')
t.read()
self.remove_device_online()
return t
def alice_txcreate1(self):
return self._alice_txcreate(chg_addr='C:5')
def alice_txcreate2(self):
return self._alice_txcreate(chg_addr='L:5')
alice_txcreate3 = alice_txcreate2
def alice_txcreate4(self):
if cfg.coin == 'BCH':
return 'skip'
return self._alice_txcreate(chg_addr='L:4')
def _alice_txsend_abort(self, err=False, user_exit=False, del_expect=[]):
self.insert_device_online()
t = self.spawn('mmgen-txsend', ['--quiet', '--abort'])
if err:
t.expect('No unsent transactions')
t.req_exit_val = 2
else:
t.expect('(y/N): ', 'n' if user_exit else 'y')
if user_exit:
t.expect('Exiting at user request')
t.req_exit_val = 1
else:
for pat in del_expect:
t.expect(pat, regex=True)
self.remove_device_online()
return t
def alice_txsend_abort1(self):
return self._alice_txsend_abort(del_expect=['Shredding .*arawtx'])
def alice_txsend_abort2(self):
return self._alice_txsend_abort(err=True)
def alice_txsend_abort3(self):
return self._alice_txsend_abort(user_exit=True)
def alice_txsend_abort4(self):
self._wait_signed('transaction')
return self._alice_txsend_abort(del_expect=[r'Shredding .*arawtx', r'Shredding .*asigtx'])
alice_txsend_abort5 = alice_txsend_abort2
def alice_txcreate_bad_have_unsigned(self):
return self._alice_txcreate(chg_addr='C:5', exit_val=2)
def copy_wallet(self):
self.spawn('', msg_only=True)
if cfg.coin == 'BTC':
return 'skip_msg'
src = Path(self.tr.data_dir, 'regtest', cfg.coin.lower(), 'alice')
dest = Path(self.tr.data_dir, 'regtest', 'btc', 'alice')
dest.mkdir(parents=True, exist_ok=True)
wf = Path(get_file_with_ext(src, 'mmdat')).absolute()
link_path = dest / wf.name
if not link_path.exists():
link_path.symlink_to(wf)
return 'ok'
def alice_run_autosign_setup(self):
self.insert_device()
t = self.run_setup(mn_type='default', use_dfl_wallet=True, passwd=rt_pw)
t.read()
self.remove_device()
return t
def alice_txsend1(self):
return self._alice_txsend('This one’s worth a comment', no_wait=True)
def alice_txsend2(self):
if cfg.coin == 'BCH':
return 'skip'
return self._alice_txsend()
def alice_txsend3(self):
if cfg.coin == 'BCH':
return 'skip'
return self._alice_txsend()
def _alice_txstatus(self, expect, exit_val=None):
self.insert_device_online()
t = self.spawn('mmgen-txsend', ['--alice', '--autosign', '--status', '--verbose'])
t.expect(expect)
self.remove_device_online()
if exit_val:
t.req_exit_val = exit_val
return t
def alice_txstatus1(self):
return self._alice_txstatus('unsigned', 1)
def alice_txstatus2(self):
self._wait_signed('transaction')
return self._alice_txstatus('unsent', 1)
def alice_txstatus3(self):
return self._alice_txstatus('in mempool')
def alice_txstatus4(self):
return self._alice_txstatus('1 confirmation')
def _alice_txsend(self, comment=None, no_wait=False):
if not no_wait:
self._wait_signed('transaction')
self.insert_device_online()
t = self.spawn('mmgen-txsend', ['--quiet', '--autosign'])
t.view_tx('t')
t.do_comment(comment)
self._do_confirm_send(t, quiet=True)
t.written_to_file('Sent automount transaction')
t.read()
self.remove_device_online()
return t
def alice_txsend_bad_no_unsent(self):
self.insert_device_online()
t = self.spawn('mmgen-txsend', ['--quiet', '--autosign'])
t.expect('No unsent transactions')
t.read()
t.req_exit_val = 2
self.remove_device_online()
return t
def _alice_txbump(self, bad_tx_desc=None):
if cfg.coin == 'BCH':
return 'skip'
self.insert_device_online()
t = self.spawn('mmgen-txbump', ['--autosign'])
if bad_tx_desc:
t.expect('Only sent transactions')
t.expect(bad_tx_desc)
t.req_exit_val = 1
else:
t.expect(f'to deduct the fee from .* change output\): ', '\n', regex=True)
t.expect(r'(Y/n): ', 'y') # output OK?
t.expect('transaction fee: ', '200s\n')
t.expect(r'(Y/n): ', 'y') # fee OK?
t.expect(r'(y/N): ', '\n') # add comment?
t.expect(r'(y/N): ', 'y') # save?
t.read()
self.remove_device_online()
return t
def alice_txbump1(self):
return self._alice_txbump(bad_tx_desc='unsigned transaction')
def alice_txbump2(self):
self._wait_signed('transaction')
return self._alice_txbump(bad_tx_desc='unsent transaction')
def alice_txbump3(self):
return self._alice_txbump()

View file

@ -0,0 +1,141 @@
#!/usr/bin/env python3
#
# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
# Copyright (C)2013-2024 The MMGen Project <mmgen@tuta.io>
# Licensed under the GNU General Public License, Version 3:
# https://www.gnu.org/licenses
# Public project repositories:
# https://github.com/mmgen/mmgen-wallet
# https://gitlab.com/mmgen/mmgen-wallet
"""
test.cmdtest_py_d.ct_automount_eth: Ethereum automount autosigning tests for the cmdtest.py test suite
"""
import os, re
from .ct_autosign import CmdTestAutosignThreaded
from .ct_ethdev import CmdTestEthdev, parity_devkey_fn
from .common import dfl_words_file
from ..include.common import cfg
class CmdTestAutosignETH(CmdTestAutosignThreaded, CmdTestEthdev):
'automounted transacting operations for Ethereum via ethdev'
networks = ('eth', 'etc')
tmpdir_nums = [59]
cmd_group = (
('setup', f'dev mode tests for coin {cfg.coin} (start daemon)'),
('addrgen', 'generating addresses'),
('addrimport', 'importing addresses'),
('addrimport_dev_addr', "importing dev faucet address 'Ox00a329c..'"),
('fund_dev_address', 'funding the default (Parity dev) address'),
('fund_mmgen_address', 'funding an MMGen address'),
('create_tx', 'creating a transaction'),
('run_autosign_setup', 'running ‘autosign setup’'),
('autosign_start_thread', 'starting autosign wait loop'),
('send_tx', 'sending the transaction'),
('token_compile1', 'compiling ERC20 token #1'),
('token_deploy1a', 'deploying ERC20 token #1 (SafeMath)'),
('token_deploy1b', 'deploying ERC20 token #1 (Owned)'),
('token_deploy1c', 'deploying ERC20 token #1 (Token)'),
('tx_status2', 'getting the transaction status'),
('token_fund_user', 'transferring token funds from dev to user'),
('token_addrgen_addr1', 'generating token addresses'),
('token_addrimport_addr1', 'importing token addresses using token address (MM1)'),
('token_bal1', f'the {cfg.coin} balance and token balance'),
('create_token_tx', 'creating a token transaction'),
('send_token_tx', 'sending a token transaction'),
('token_bal2', f'the {cfg.coin} balance and token balance'),
('autosign_kill_thread', 'stopping autosign wait loop'),
('txview', 'viewing transactions'),
('stop', 'stopping daemon'),
)
def __init__(self, trunner, cfgs, spawn):
self.coins = [cfg.coin.lower()]
CmdTestAutosignThreaded.__init__(self, trunner, cfgs, spawn)
CmdTestEthdev.__init__(self, trunner, cfgs, spawn)
if trunner == None:
return
self.opts.append('--alice')
def fund_mmgen_address(self):
keyfile = os.path.join(self.tmpdir, parity_devkey_fn)
t = self.spawn(
'mmgen-txdo',
['--quiet']
+ [f'--keys-from-file={keyfile}']
+ ['--fee=40G', '98831F3A:E:1,123.456', dfl_words_file],
)
t.expect('efresh balance:\b', 'q')
t.expect('from: ', '10')
t.expect('(Y/n): ', 'y')
t.expect('(Y/n): ', 'y')
t.expect('(y/N): ', 'n')
t.expect('view: ', 'n')
t.expect('confirm: ', 'YES')
return t
def create_tx(self):
self.insert_device_online()
t = self.txcreate(
args = ['--autosign', '98831F3A:E:11,54.321'],
menu = [],
acct = '1')
t.read()
self.remove_device_online()
return t
def run_autosign_setup(self):
self.insert_device()
t = self.run_setup(mn_type='bip39', mn_file='test/ref/98831F3A.bip39', use_dfl_wallet=None)
t.read()
self.remove_device()
return t
def send_tx(self, add_args=[]):
self._wait_signed('transaction')
self.insert_device_online()
t = self.spawn('mmgen-txsend', ['--quiet', '--autosign'] + add_args)
t.view_tx('t')
t.expect('(y/N): ', 'n')
self._do_confirm_send(t, quiet=True)
t.written_to_file('Sent automount transaction')
self.remove_device_online()
return t
def token_fund_user(self):
return self.token_transfer_ops(op='do_transfer', num_tokens=1)
def token_addrgen_addr1(self):
return self.token_addrgen(num_tokens=1)
def token_bal1(self):
return self.token_bal(pat=r':E:11\s+1000\s+54\.321\s+')
def token_bal2(self):
return self.token_bal(pat=r':E:11\s+998.76544\s+54.318\d+\s+.*:E:12\s+1\.23456\s+')
def token_bal(self, pat):
t = self.spawn('mmgen-tool', ['--quiet', '--token=mm1', 'twview', 'wide=1'])
text = t.read(strip_color=True)
assert re.search(pat, text, re.DOTALL), f'output failed to match regex {pat}'
return t
def create_token_tx(self):
self.insert_device_online()
t = self.token_txcreate(
args = ['--autosign', '98831F3A:E:12,1.23456'],
token = 'MM1',
file_desc = 'Unsigned automount transaction')
t.read()
self.remove_device_online()
return t
def send_token_tx(self):
return self.send_tx(add_args=['--token=MM1'])

View file

@ -209,6 +209,16 @@ class CmdTestAutosignClean(CmdTestAutosignBase):
):
(self.asi.tx_dir / fn).touch()
for fn in (
'a.arawtx', 'a.asigtx', 'a.asubtx',
'b.arawtx', 'b.asigtx',
'c.asubtx',
'd.arawtx', 'd.asubtx',
'e.arawtx',
'f.asigtx', 'f.asubtx',
):
(self.asi.txauto_dir / fn).touch()
for fn in (
'a.rawmsg.json', 'a.sigmsg.json',
'b.rawmsg.json',
@ -270,6 +280,7 @@ class CmdTestAutosignClean(CmdTestAutosignBase):
chk_non_xmr = """
tx: a.sigtx b.sigtx c.rawtx d.sigtx
txauto: a.asubtx b.asigtx c.asubtx d.asubtx e.arawtx f.asubtx
msg: a.sigmsg.json b.rawmsg.json c.sigmsg.json d.sigmsg.json
"""
chk_xmr = """
@ -281,10 +292,10 @@ class CmdTestAutosignClean(CmdTestAutosignBase):
shred_count = 0
if not self.asi.xmr_only:
for k in ('tx_dir','msg_dir'):
for k in ('tx_dir', 'txauto_dir', 'msg_dir'):
shutil.rmtree(getattr(self.asi, k))
chk += chk_non_xmr.rstrip()
shred_count += 4
shred_count += 9
if self.asi.have_xmr:
shutil.rmtree(self.asi.xmr_dir)
@ -371,6 +382,21 @@ class CmdTestAutosignThreaded(CmdTestAutosignBase):
def do_umount_online(self, *args, **kwargs):
return self._mount_ops('asi_online', 'do_umount', *args, **kwargs)
async def txview(self):
self.spawn('', msg_only=True)
self.do_mount()
src = Path(self.asi.txauto_dir)
from mmgen.tx import CompletedTX
txs = sorted(
[await CompletedTX(cfg=cfg, filename=path, quiet_open=True) for path in sorted(src.iterdir())],
key = lambda x: x.timestamp)
for tx in txs:
imsg(blue(f'\nViewing ‘{tx.infile.name}’:'))
out = tx.info.format(terse=True)
imsg(indent(out, indent=' '))
self.do_umount()
return 'ok'
class CmdTestAutosign(CmdTestAutosignBase):
'autosigning transactions for all supported coins'
coins = ['btc','bch','ltc','eth']
@ -754,9 +780,9 @@ class CmdTestAutosignLive(CmdTestAutosignBTC):
def prompt_insert_sign(t):
omsg(orange(insert_msg))
t.expect(f'{self.tx_count} transactions signed')
t.expect(f'{self.tx_count} non-automount transactions signed')
if self.bad_tx_count:
t.expect(f'{self.bad_tx_count} transactions failed to sign')
t.expect(f'{self.bad_tx_count} non-automount transactions failed to sign')
t.expect('Waiting')
if led_opts:

View file

@ -153,8 +153,15 @@ init_tests() {
d_etc="operations for Ethereum Classic using devnet"
t_etc="parity $cmdtest_py --coin=etc ethdev"
d_autosign="transaction and message autosigning"
t_autosign="- $cmdtest_py autosign autosign_clean"
d_autosign="transaction autosigning with automount"
t_autosign="
- $cmdtest_py autosign autosign_clean autosign_automount
- $cmdtest_py --coin=bch autosign_automount
s $cmdtest_py --coin=ltc autosign_automount
- $cmdtest_py --coin=eth autosign_eth
s $cmdtest_py --coin=etc autosign_eth
"
[ "$FAST" ] && t_autosign_skip='s'
d_autosign_btc="transaction and message autosigning (Bitcoin only)"
t_autosign_btc="- $cmdtest_py autosign_btc"
@ -164,7 +171,7 @@ init_tests() {
d_btc="overall operations with emulated RPC data (Bitcoin)"
t_btc="
- $cmdtest_py --exclude regtest,autosign,autosign_clean,ref_altcoin
- $cmdtest_py --exclude regtest,autosign,autosign_clean,autosign_automount,ref_altcoin
- $cmdtest_py --segwit
- $cmdtest_py --segwit-random
- $cmdtest_py --bech32