Browse Source

tx.py,tw.py: cleanups, support tx inputs from cmdline

MMGen 6 years ago
parent
commit
fad573eccd

+ 14 - 8
mmgen/altcoins/eth/tw.py

@@ -140,10 +140,9 @@ class EthereumTrackingWallet(TrackingWallet):
 			m = "Address '{}' not found in '{}' section of tracking wallet"
 			return ('rpcfail',(None,2,m.format(coinaddr,self.data_root_desc())))
 
-# Use consistent naming, even though Ethereum doesn't have unspent outputs
 class EthereumTwUnspentOutputs(TwUnspentOutputs):
 
-	show_txid = False
+	disp_type = 'eth'
 	can_group = False
 	hdr_fmt = 'TRACKED ACCOUNTS (sort order: {})\nTotal {}: {}'
 	desc    = 'account balances'
@@ -155,32 +154,36 @@ Display options: show [D]ays, show [m]mgen addr, r[e]draw screen
 
 	def do_sort(self,key=None,reverse=False):
 		if key == 'txid': return
-		super(type(self),self).do_sort(key=key,reverse=reverse)
+		super(EthereumTwUnspentOutputs,self).do_sort(key=key,reverse=reverse)
+
+	def get_addr_bal(self,addr):
+		return ETHAmt(int(g.rpch.eth_getBalance('0x'+addr),16),'wei')
 
 	def get_unspent_rpc(self):
 		rpc_init()
 		return map(lambda d: {
 				'account': TwLabel(d['mmid']+' '+d['comment'],on_fail='raise'),
 				'address': d['addr'],
-				'amount': ETHAmt(int(g.rpch.eth_getBalance('0x'+d['addr']),16),'wei'),
+				'amount': self.get_addr_bal(d['addr']),
 				'confirmations': 0, # TODO
 				}, TrackingWallet().sorted_list())
 
 class EthereumTwAddrList(TwAddrList):
 
 	def __init__(self,usr_addr_list,minconf,showempty,showbtcaddrs,all_labels):
-		tw = TrackingWallet().mmid_ordered_dict()
-		self.total = g.proto.coin_amt('0')
 
 		rpc_init()
-#		cur_blk = int(g.rpch.eth_blockNumber(),16)
+		if g.token: self.token = Token(g.token)
+
+		tw = TrackingWallet().mmid_ordered_dict()
+		self.total = g.proto.coin_amt('0')
 
 		from mmgen.obj import CoinAddr
 		for mmid,d in tw.items():
 #			if d['confirmations'] < minconf: continue
 			label = TwLabel(mmid+' '+d['comment'],on_fail='raise')
 			if usr_addr_list and (label.mmid not in usr_addr_list): continue
-			bal = ETHAmt(int(g.rpch.eth_getBalance('0x'+d['addr']),16),'wei')
+			bal = self.get_addr_balance(d['addr'])
 			if bal == 0 and not showempty:
 				if not label.comment: continue
 				if not all_labels: continue
@@ -191,6 +194,9 @@ class EthereumTwAddrList(TwAddrList):
 			self[label.mmid]['amt'] += bal
 			self.total += bal
 
+	def get_addr_balance(self,addr):
+		return ETHAmt(int(g.rpch.eth_getBalance('0x'+addr),16),'wei')
+
 from mmgen.tw import TwGetBalance
 class EthereumTwGetBalance(TwGetBalance):
 

+ 148 - 68
mmgen/altcoins/eth/tx.py

@@ -28,20 +28,47 @@ from mmgen.obj import *
 from mmgen.tx import MMGenTX,MMGenBumpTX,MMGenSplitTX,DeserializedTX,mmaddr2coinaddr
 class EthereumMMGenTX(MMGenTX):
 	desc   = 'Ethereum transaction'
-	tx_gas = ETHAmt(21000,'wei') # tx_gas 21000 * gasPrice 50 Gwei = fee 0.00105
-	chg_msg_fs = 'Transaction leaves {} {} in the account'
+	tx_gas = ETHAmt(21000,'wei')    # an approximate number, used for fee estimation purposes
+	start_gas = ETHAmt(21000,'wei') # the actual startgas amt used in the transaction
+									# for simple sends with no data, tx_gas = start_gas = 21000
 	fee_fail_fs = 'Network fee estimation failed'
 	no_chg_msg = 'Warning: Transaction leaves account with zero balance'
 	rel_fee_desc = 'gas price'
 	rel_fee_disp = 'gas price in Gwei'
 	txview_hdr_fs = 'TRANSACTION DATA\n\nID={i} ({a} {c}) UTC={t} Sig={s} Locktime={l}\n'
 	txview_hdr_fs_short = 'TX {i} ({a} {c}) UTC={t} Sig={s} Locktime={l}\n'
+	txview_ftr_fs = 'Total in account: {i} {d}\nTotal to spend:   {o} {d}\nTX fee:           {a} {c}{r}\n'
+	txview_ftr_fs_short = 'In {i} {d} - Out {o} {d}\nFee {a} {c}{r}\n'
 	usr_fee_prompt = 'Enter transaction fee or gas price: '
-
+	fn_fee_unit = 'Mwei'
 	usr_rel_fee = None # not in MMGenTX
-	txobj_data  = None # ""
+	disable_fee_check = False
+	txobj  = None # ""
+	data = HexStr('')
+
+	def __init__(self,*args,**kwargs):
+		super(EthereumMMGenTX,self).__init__(*args,**kwargs)
+		if hasattr(opt,'tx_gas') and opt.tx_gas:
+			self.tx_gas = self.start_gas = ETHAmt(int(opt.tx_gas),'wei')
+		if hasattr(opt,'contract_data') and opt.contract_data:
+			self.data = HexStr(open(opt.contract_data).read().strip())
+			self.disable_fee_check = True
+
+	@classmethod
+	def get_receipt(cls,txid):
+		return g.rpch.eth_getTransactionReceipt('0x'+txid)
+
+	@classmethod
+	def get_exec_status(cls,txid):
+		return int(g.rpch.eth_getTransactionReceipt('0x'+txid)['status'],16)
+
+	def is_replaceable(self): return True
+
+	def get_fee_from_tx(self):
+		return self.fee
 
 	def check_fee(self):
+		if self.disable_fee_check: return
 		assert self.fee <= g.proto.max_tx_fee
 
 	def get_hex_locktime(self): return None # TODO
@@ -54,46 +81,65 @@ class EthereumMMGenTX(MMGenTX):
 			return True
 		return False
 
-	# hex data if signed, json if unsigned
-	def check_tx_hex_data(self):
+	# hex data if signed, json if unsigned: see create_raw()
+	def check_txfile_hex_data(self):
 		if self.check_sigs():
 			from ethereum.transactions import Transaction
 			import rlp
 			etx = rlp.decode(self.hex.decode('hex'),Transaction)
-			d = etx.to_dict()
-			self.txobj_data = {
-				'from':     CoinAddr(d['sender'][2:]),
-				'to':       CoinAddr(d['to'][2:]),
-				'amt':      ETHAmt(d['value'],'wei'),
-				'gasPrice': ETHAmt(d['gasprice'],'wei'),
-				'nonce':    ETHNonce(d['nonce'])
-			}
+			d = etx.to_dict() # ==> hex values have '0x' prefix, 0 is '0x'
+			for k in ('sender','to','data'):
+				if k in d: d[k] = d[k].replace('0x','',1)
+			o = {   'from':     CoinAddr(d['sender']),
+					'to':       CoinAddr(d['to']) if d['to'] else Str(''),
+					'amt':      ETHAmt(d['value'],'wei'),
+					'gasPrice': ETHAmt(d['gasprice'],'wei'),
+					'startGas': ETHAmt(d['startgas'],'wei'),
+					'nonce':    ETHNonce(d['nonce']),
+					'data':     HexStr(d['data']) }
+			if o['data'] and not o['to']:
+				self.token_addr = TokenAddr(etx.creates.encode('hex'))
 			txid = CoinTxID(etx.hash.encode('hex'))
-			assert txid == self.coin_txid,"txid in tx.hex doesn't match value in MMGen tx file"
+			assert txid == self.coin_txid,"txid in tx.hex doesn't match value in MMGen transaction file"
 		else:
 			d = json.loads(self.hex)
-			self.txobj_data = {
-				'from':     CoinAddr(d['from']),
-				'to':       CoinAddr(d['to']),
-				'amt':      ETHAmt(d['amt']),
-				'gasPrice': ETHAmt(d['gasPrice']),
-				'nonce':    ETHNonce(d['nonce']),
-				'chainId':  d['chainId']
-			}
-		self.gasPrice = self.txobj_data['gasPrice']
-
-	def create_raw(self):
-		for k in 'input','output':
-			assert len(getattr(self,k+'s')) == 1,'Transaction has more than one {}!'.format(k)
-		self.txobj_data = {
+			o = {   'from':     CoinAddr(d['from']),
+					'to':       CoinAddr(d['to']) if d['to'] else Str(''),
+					'amt':      ETHAmt(d['amt']),
+					'gasPrice': ETHAmt(d['gasPrice']),
+					'startGas': ETHAmt(d['startGas']),
+					'nonce':    ETHNonce(d['nonce']),
+					'chainId':  Int(d['chainId']),
+					'data':     HexStr(d['data']) }
+		self.tx_gas = o['startGas'] # approximate, but better than nothing
+		self.data = o['data']
+		if o['data'] and not o['to']: self.disable_fee_check = True
+		self.fee = self.fee_rel2abs(o['gasPrice'].toWei())
+		self.txobj = o
+		return d # 'token_addr','decimals' required by subclass
+
+	def make_txobj(self): # create_raw
+		self.txobj = {
 			'from': self.inputs[0].addr,
-			'to':   self.outputs[0].addr,
-			'amt':  self.outputs[0].amt,
-			'gasPrice': self.usr_rel_fee or self.fee_abs2rel(self.fee,in_eth=True),
+			'to':   self.outputs[0].addr if self.outputs else Str(''),
+			'amt':  self.outputs[0].amt if self.outputs else ETHAmt(0),
+			'gasPrice': self.usr_rel_fee or self.fee_abs2rel(self.fee,to_unit='eth'),
+			'startGas': self.start_gas,
 			'nonce': ETHNonce(int(g.rpch.parity_nextNonce('0x'+self.inputs[0].addr),16)),
-			'chainId': g.rpch.parity_chainId()
+			'chainId': Int(g.rpch.parity_chainId(),16),
+			'data':  self.data,
 		}
-		self.hex = json.dumps(dict([(k,str(v))for k,v in self.txobj_data.items()]))
+
+	# Instead of serializing tx data as with BTC, just create a JSON dump.
+	# This complicates things but means we avoid using the rlp library to deserialize the data,
+	# thus removing an attack vector
+	def create_raw(self):
+		assert len(self.inputs) == 1,'Transaction has more than one input!'
+		o_ok = (0,1) if self.data else (1,)
+		o_num = len(self.outputs)
+		assert o_num in o_ok,'Transaction has invalid number of outputs!'.format(o_num)
+		self.make_txobj()
+		self.hex = json.dumps(dict([(k,str(v))for k,v in self.txobj.items()]))
 		self.update_txid()
 
 	def del_output(self,idx): pass
@@ -105,12 +151,15 @@ class EthereumMMGenTX(MMGenTX):
 		self.txid = MMGenTxID(make_chksum_6(self.hex).upper())
 
 	def get_blockcount(self):
-		return int(g.rpch.eth_blockNumber(),16)
+		return Int(g.rpch.eth_blockNumber(),16)
 
 	def process_cmd_args(self,cmd_args,ad_f,ad_w):
 		lc = len(cmd_args)
-		if lc != 1:
-			fs = '{} output{} specified, but Ethereum transactions must have only one'
+
+		if lc == 0 and self.data:
+			return
+		elif lc != 1:
+			fs = '{} output{} specified, but Ethereum transactions must have exactly one'
 			die(1,fs.format(lc,suf(lc)))
 
 		a = list(cmd_args)[0]
@@ -124,6 +173,9 @@ class EthereumMMGenTX(MMGenTX):
 		else:
 			die(2,'{}: invalid command-line argument'.format(a))
 
+		if not self.outputs:
+			die(2,'At least one output must be specified on the command line')
+
 	def select_unspent(self,unspent):
 		prompt = 'Enter an account to spend from: '
 		while True:
@@ -142,13 +194,13 @@ class EthereumMMGenTX(MMGenTX):
 	def get_relay_fee(self): return ETHAmt(0) # TODO
 
 	# given absolute fee in ETH, return gas price in Gwei using tx_gas
-	def fee_abs2rel(self,abs_fee,in_eth=False): # in_eth not in MMGenTX
+	def fee_abs2rel(self,abs_fee,to_unit='Gwei'):
 		ret = ETHAmt(int(abs_fee.toWei() / self.tx_gas.toWei()),'wei')
-		return ret if in_eth else ret.toGwei()
+		return ret if to_unit == 'eth' else ret.to_unit(to_unit)
 
 	# get rel_fee (gas price) from network, return in native wei
 	def get_rel_fee_from_network(self):
-		return int(g.rpch.eth_gasPrice(),16),'eth_gasPrice' # ==> rel_fee,fe_type
+		return Int(g.rpch.eth_gasPrice(),16),'eth_gasPrice' # ==> rel_fee,fe_type
 
 	# given rel fee and units, return absolute fee using tx_gas
 	def convert_fee_spec(self,foo,units,amt,unit):
@@ -157,7 +209,7 @@ class EthereumMMGenTX(MMGenTX):
 
 	# given rel fee in wei, return absolute fee using tx_gas (not in MMGenTX)
 	def fee_rel2abs(self,rel_fee):
-		assert type(rel_fee) is int,"'{}': incorrect type for fee estimate (not an integer)".format(rel_fee)
+		assert type(rel_fee) in (int,Int),"'{}': incorrect type for fee estimate (not an integer)".format(rel_fee)
 		return ETHAmt(rel_fee * self.tx_gas.toWei(),'wei')
 
 	# given fee estimate (gas price) in wei, return absolute fee, adjusting by opt.tx_fee_adj
@@ -171,6 +223,8 @@ class EthereumMMGenTX(MMGenTX):
 		abs_fee = self.process_fee_spec(tx_fee,None,on_fail='return')
 		if abs_fee == False:
 			return False
+		elif self.disable_fee_check:
+			return abs_fee
 		elif abs_fee > g.proto.max_tx_fee:
 			m = '{} {c}: {} fee too large (maximum fee: {} {c})'
 			msg(m.format(abs_fee.hl(),desc,g.proto.max_tx_fee.hl(),c=g.coin))
@@ -181,54 +235,60 @@ class EthereumMMGenTX(MMGenTX):
 	def format_view_body(self,blockcount,nonmm_str,max_mmwid,enl,terse):
 		m = {}
 		for k in ('in','out'):
-			m[k] = getattr(self,k+'puts')[0].mmid
-			m[k] = ' ' + m[k].hl() if m[k] else ' ' + MMGenID.hlc(nonmm_str)
+			if len(getattr(self,k+'puts')):
+				m[k] = getattr(self,k+'puts')[0].mmid if len(getattr(self,k+'puts')) else ''
+				m[k] = ' ' + m[k].hl() if m[k] else ' ' + MMGenID.hlc(nonmm_str)
 		fs = """From:      {}{f_mmid}
 				To:        {}{t_mmid}
-				Amount:    {} ETH
+				Amount:    {} {c}
 				Gas price: {g} Gwei
-				Nonce:     {}\n\n""".replace('\t','')
+				Start gas: {G} Kwei
+				Nonce:     {}
+				Data:      {d}
+				\n""".replace('\t','')
 		keys = ('from','to','amt','nonce')
-		return fs.format(   *(self.txobj_data[k].hl() for k in keys),
-							g=yellow(str(self.txobj_data['gasPrice'].toGwei())),
-							t_mmid=m['out'],
+		ld = len(self.txobj['data'])
+		return fs.format(   *((self.txobj[k] if self.txobj[k] != '' else Str('None')).hl() for k in keys),
+							d='{}... ({} bytes)'.format(self.txobj['data'][:40],ld/2) if ld else Str('None'),
+							c=g.dcoin if len(self.outputs) else '',
+							g=yellow(str(self.txobj['gasPrice'].toGwei())),
+							G=yellow(str(self.txobj['startGas'].toKwei())),
+							t_mmid=m['out'] if len(self.outputs) else '',
 							f_mmid=m['in'])
 
 	def format_view_abs_fee(self):
-		return self.fee_rel2abs(self.txobj_data['gasPrice'].toWei()).hl()
+		fee = self.fee_rel2abs(self.txobj['gasPrice'].toWei())
+		note = ' (max)' if self.data else ''
+		return fee.hl() + note
 
 	def format_view_rel_fee(self,terse): return ''
 	def format_view_verbose_footer(self): return '' # TODO
 
-	def sign(self,tx_num_str,keys): # return true or false; don't exit
+	def final_inputs_ok_msg(self,change_amt):
+		m = "Transaction leaves {} {} in the sender's account"
+		return m.format(g.proto.coin_amt(change_amt).hl(),g.coin)
 
-		if self.marked_signed():
-			msg('Transaction is already signed!')
-			return False
+	def do_sign(self,d,wif,tx_num_str):
 
-		if not self.check_correct_chain(on_fail='return'):
-			return False
-
-		wif = keys[0].sec.wif
-		d = self.txobj_data
-
-		out = { 'to':       '0x'+d['to'],
-				'startgas': self.tx_gas.toWei(),
+		d_in = {'to':       d['to'].decode('hex'),
+				'startgas': d['startGas'].toWei(),
 				'gasprice': d['gasPrice'].toWei(),
-				'value':    d['amt'].toWei(),
+				'value':    d['amt'].toWei() if d['amt'] else 0,
 				'nonce':    d['nonce'],
-				'data':     ''}
+				'data':     d['data'].decode('hex')}
 
 		msg_r('Signing transaction{}...'.format(tx_num_str))
 
 		try:
 			from ethereum.transactions import Transaction
-			etx = Transaction(**out)
-			etx.sign(wif,int(d['chainId'],16))
+			etx = Transaction(**d_in)
+			etx.sign(wif,d['chainId'])
 			import rlp
 			self.hex = rlp.encode(etx).encode('hex')
 			self.coin_txid = CoinTxID(etx.hash.encode('hex'))
 			msg('OK')
+			if d['data']:
+				self.token_addr = TokenAddr(etx.creates.encode('hex'))
 		except Exception as e:
 			m = "{!r}: transaction signing failed!"
 			msg(m.format(e[0]))
@@ -236,6 +296,17 @@ class EthereumMMGenTX(MMGenTX):
 
 		return self.check_sigs()
 
+	def sign(self,tx_num_str,keys): # return true or false; don't exit
+
+		if self.marked_signed():
+			msg('Transaction is already signed!')
+			return False
+
+		if not self.check_correct_chain(on_fail='return'):
+			return False
+
+		return self.do_sign(self.txobj,keys[0].sec.wif,tx_num_str)
+
 	def get_status(self,status=False): pass # TODO
 
 	def send(self,prompt_user=True,exit_on_fail=False):
@@ -247,9 +318,9 @@ class EthereumMMGenTX(MMGenTX):
 
 		bogus_send = os.getenv('MMGEN_BOGUS_SEND')
 
-		fee = self.fee_rel2abs(self.txobj_data['gasPrice'].toWei())
+		fee = self.fee_rel2abs(self.txobj['gasPrice'].toWei())
 
-		if fee > g.proto.max_tx_fee:
+		if not self.disable_fee_check and fee > g.proto.max_tx_fee:
 			die(2,'Transaction fee ({}) greater than {} max_tx_fee ({} {})!'.format(
 				fee,g.proto.name.capitalize(),g.proto.max_tx_fee,g.coin))
 
@@ -275,6 +346,15 @@ class EthereumMMGenTX(MMGenTX):
 			self.add_blockcount()
 			return True
 
-class EthereumMMGenBumpTX(MMGenBumpTX): pass
+class EthereumMMGenBumpTX(EthereumMMGenTX,MMGenBumpTX):
+
+	def choose_output(self): pass
+
+	def set_min_fee(self):
+		self.min_fee = ETHAmt(self.fee * Decimal('1.101'))
+
+	def update_fee(self,foo,fee):
+		self.fee = fee
+
 class EthereumMMGenSplitTX(MMGenSplitTX): pass
 class EthereumDeserializedTX(DeserializedTX): pass

+ 3 - 5
mmgen/main_txbump.py

@@ -102,20 +102,18 @@ if not silent:
 
 tx.set_min_fee()
 
-if not [o.amt for o in tx.outputs if o.amt >= tx.min_fee]:
-	die(1,'Transaction cannot be bumped.' +
-	'\nAll outputs have less than the minimum fee ({} {})'.format(tx.min_fee,g.coin))
+tx.check_bumpable()
 
 msg('Creating new transaction')
 
 op_idx = tx.choose_output()
 
 if not silent:
-	msg('Minimum fee for new transaction: {} {}'.format(tx.min_fee,g.coin))
+	msg('Minimum fee for new transaction: {} {}'.format(tx.min_fee.hl(),g.coin))
 
 fee = tx.get_usr_fee_interactive(tx_fee=opt.tx_fee,desc='User-selected')
 
-tx.update_output_amt(op_idx,tx.sum_inputs()-tx.sum_outputs(exclude=op_idx)-fee)
+tx.update_fee(op_idx,fee)
 
 d = tx.get_fee_from_tx()
 assert d == fee and d <= g.proto.max_tx_fee

+ 24 - 20
mmgen/main_txcreate.py

@@ -28,26 +28,30 @@ opts_data = lambda: {
 	'usage':   '[opts]  <addr,amt> ... [change addr] [addr file] ...',
 	'sets': ( ('yes', True, 'quiet', True), ),
 	'options': """
--h, --help           Print this help message
---, --longhelp       Print help message for long options (common options)
--a, --tx-fee-adj=  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'
--C, --tx-confs=    c Desired number of confirmations (default: {g.tx_confs})
--d, --outdir=      d Specify an alternate directory 'd' for output
--f, --tx-fee=      f Transaction fee, as a decimal {cu} amount or as
-                     {fu} (an integer followed by {fl}).
-                     See FEE SPECIFICATION below.  If omitted, fee will be
-                     calculated using network fee estimation.
--i, --info           Display unspent outputs and exit
--L, --locktime=    t Lock time (block height or unix seconds) (default: 0)
--m, --minconf=     n Minimum number of confirmations required to spend
-                     outputs (default: 1)
--q, --quiet          Suppress warnings; overwrite files without prompting
--r, --rbf            Make transaction BIP 125 replaceable (replace-by-fee)
--v, --verbose        Produce more verbose output
--V, --vsize-adj=   f Adjust transaction's estimated vsize by factor 'f'
--y, --yes            Answer 'yes' to prompts, suppress non-essential output
+-h, --help            Print this help message
+--, --longhelp        Print help message for long options (common options)
+-a, --tx-fee-adj=  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'
+-C, --tx-confs=    c  Desired number of confirmations (default: {g.tx_confs})
+-d, --outdir=      d  Specify an alternate directory 'd' for output
+-f, --tx-fee=      f  Transaction fee, as a decimal {cu} amount or as
+                      {fu} (an integer followed by {fl}).
+                      See FEE SPECIFICATION below.  If omitted, fee will be
+                      calculated using network fee estimation.
+-g, --tx-gas=      g  Specify start gas amount in Wei (ETH only)
+-i, --info            Display unspent outputs and exit
+-I, --inputs=      i  Specify transaction inputs (comma-separated list of
+                      MMGen IDs or coin addresses).  Note that ALL unspent
+                      outputs associated with each address will be included.
+-L, --locktime=    t  Lock time (block height or unix seconds) (default: 0)
+-m, --minconf=     n  Minimum number of confirmations required to spend
+                      outputs (default: 1)
+-q, --quiet           Suppress warnings; overwrite files without prompting
+-r, --rbf             Make transaction BIP 125 replaceable (replace-by-fee)
+-v, --verbose         Produce more verbose output
+-V, --vsize-adj=   f  Adjust transaction's estimated vsize by factor 'f'
+-y, --yes             Answer 'yes' to prompts, suppress non-essential output
 """,
 	'options_fmt_args': lambda: dict(
 							g=g,cu=g.coin,

+ 8 - 0
mmgen/main_txdo.py

@@ -41,9 +41,13 @@ opts_data = lambda: {
                        {fu} (an integer followed by {fl}).
                        See FEE SPECIFICATION below.  If omitted, fee will be
                        calculated using network fee estimation.
+-g, --tx-gas=        g Specify start gas amount in Wei (ETH only)
 -H, --hidden-incog-input-params=f,o  Read hidden incognito data from file
                       'f' at offset 'o' (comma-separated)
 -i, --in-fmt=        f Input is from wallet format 'f' (see FMT CODES below)
+-I, --inputs=        i Specify transaction inputs (comma-separated list of
+                       MMGen IDs or coin addresses).  Note that ALL unspent
+                       outputs associated with each address will be included.
 -l, --seed-len=      l Specify wallet seed length of 'l' bits. This option
                        is required only for brainwallet and incognito inputs
                        with non-standard (< {g.seed_len}-bit) seed lengths.
@@ -94,9 +98,13 @@ kl = get_keylist(opt)
 if kl and kal: kl.remove_dup_keys(kal)
 
 tx = MMGenTX(caller='txdo')
+
 tx.create(cmd_args,int(opt.locktime or 0))
+
 txsign(tx,seed_files,kl,kal)
+
 tx.write_to_file(ask_write=False)
 
 tx.send(exit_on_fail=True)
+
 tx.write_to_file(ask_overwrite=False,ask_write=False)

+ 9 - 5
mmgen/obj.py

@@ -336,13 +336,14 @@ class BTCAmt(Decimal,Hilite,InitErrors):
 			m = "{!r}: value cannot be converted to {} ({})"
 			return cls.init_fail(m.format(num,cls.__name__,e[0]),on_fail)
 
-	def toSatoshi(self): return int(Decimal(self) / self.satoshi)
+	def toSatoshi(self):    return int(Decimal(self) / self.satoshi)
+	def to_unit(self,unit): return int(Decimal(self) / getattr(self,unit))
 
 	@classmethod
 	def fmtc(cls):
 		raise NotImplementedError
 
-	def fmt(self,fs=None,color=False,suf=''):
+	def fmt(self,fs=None,color=False,suf='',prec=1000):
 		if fs == None: fs = self.amt_fs
 		s = str(int(self)) if int(self) == self else self.normalize().__format__('f')
 		if '.' in fs:
@@ -350,9 +351,9 @@ class BTCAmt(Decimal,Hilite,InitErrors):
 			ss = s.split('.',1)
 			if len(ss) == 2:
 				a,b = ss
-				ret = a.rjust(p1) + '.' + (b+suf).ljust(p2+len(suf))
+				ret = a.rjust(p1) + '.' + ((b+suf).ljust(p2+len(suf)))[:prec]
 			else:
-				ret = s.rjust(p1) + suf + ' ' * (p2+1)
+				ret = s.rjust(p1) + suf + (' ' * (p2+1))[:prec+1-len(suf)]
 		else:
 			ret = s.ljust(int(fs))
 		return self.colorize(ret,color=color)
@@ -424,7 +425,7 @@ class CoinAddr(str,Hilite,InitErrors,MMGenObject):
 	def is_for_chain(self,chain):
 
 		from mmgen.globalvars import g
-		if g.coin in ('ETH','ETC'):
+		if g.proto.__name__[:8] == 'Ethereum':
 			return True
 
 		def pfx_ok(pfx):
@@ -562,6 +563,9 @@ class HexStr(str,Hilite,InitErrors):
 			m = "{!r}: value cannot be converted to {} (value is {})"
 			return cls.init_fail(m.format(s,cls.__name__,e[0]),on_fail)
 
+class Str(str,Hilite): pass
+class Int(int,Hilite): pass
+
 class HexStrWithWidth(HexStr):
 	color = 'nocolor'
 	trunc_ok = False

+ 9 - 2
mmgen/opts.py

@@ -86,6 +86,7 @@ def opt_postproc_initializations():
 	if g.platform == 'win': start_mscolor()
 
 	g.coin = g.coin.upper() # allow user to use lowercase
+	g.dcoin = g.coin
 
 def set_data_dir_root():
 	g.data_dir_root = os.path.normpath(os.path.expanduser(opt.data_dir)) if opt.data_dir else \
@@ -152,7 +153,11 @@ def override_from_cfg_file(cfg_data):
 		if name in g.cfg_file_opts:
 			pfx,cfg_var = name.split('_',1)
 			if pfx in CoinProtocol.coins:
-				cls,attr = CoinProtocol(pfx,False),cfg_var
+				tn = False
+				cv1,cv2 = cfg_var.split('_',1)
+				if cv1 in ('mainnet','testnet'):
+					tn,cfg_var = (cv1 == 'testnet'),cv2
+				cls,attr = CoinProtocol(pfx,tn),cfg_var
 			else:
 				cls,attr = g,name
 			setattr(cls,attr,set_for_type(val,getattr(cls,attr),attr,src=g.cfg_file))
@@ -339,6 +344,7 @@ def init(opts_f,add_opts=[],opt_filter=None):
 def opt_is_tx_fee(val,desc):
 	from mmgen.tx import MMGenTX
 	ret = MMGenTX().process_fee_spec(val,224,on_fail='return')
+	if opt.contract_data or opt.tx_gas: ret = None # Non-standard startgas: disable fee checking
 	if ret == False:
 		msg("'{}': invalid {}\n(not a {} amount or {} specification)".format(
 				val,desc,g.coin.upper(),MMGenTX().rel_fee_desc))
@@ -495,7 +501,8 @@ def check_opts(usr_opts):       # Returns false if any check fails
 			if not opt_is_in_list(val.lower(),CoinProtocol.coins.keys(),'coin'): return False
 		elif key == 'rbf':
 			if not g.proto.cap('rbf'):
-				die(1,'--rbf requested, but {} does not support replace-by-fee transactions'.format(g.coin))
+				msg('--rbf requested, but {} does not support replace-by-fee transactions'.format(g.coin))
+				return False
 		elif key in ('bob','alice'):
 			from mmgen.regtest import daemon_dir
 			m = "Regtest (Bob and Alice) mode not set up yet.  Run '{}-regtest setup' to initialize."

+ 2 - 0
mmgen/protocol.py

@@ -64,6 +64,7 @@ def _b58chk_decode(s):
 class BitcoinProtocol(MMGenObject):
 	name            = 'bitcoin'
 	daemon_name     = 'bitcoind'
+	daemon_family   = 'bitcoind'
 	addr_ver_num    = { 'p2pkh': ('00','1'), 'p2sh':  ('05','3') }
 	wif_ver_num     = { 'std': '80' }
 	mmtypes         = ('L','C','S','B')
@@ -301,6 +302,7 @@ class EthereumProtocol(DummyWIF,BitcoinProtocolAddrgen):
 
 	data_subdir = ''
 	daemon_name = 'parity'
+	daemon_family = 'parity'
 	rpc_port    = 8545
 	mmcaps      = ('key','addr','rpc')
 	coin_amt    = ETHAmt

+ 25 - 20
mmgen/tw.py

@@ -32,11 +32,12 @@ class TwUnspentOutputs(MMGenObject):
 		return MMGenObject.__new__(altcoin_subclass(cls,'tw','TwUnspentOutputs'),*args,**kwargs)
 
 	txid_w = 64
-	show_txid = True
+	disp_type = 'btc'
 	can_group = True
 	hdr_fmt = 'UNSPENT OUTPUTS (sort order: {}) Total {}: {}'
 	desc = 'unspent outputs'
 	dump_fn_pfx = 'listunspent'
+	prompt_fs = 'Total to spend, excluding fees: {} {}\n\n'
 	prompt = """
 Sort options: [t]xid, [a]mount, a[d]dress, [A]ge, [r]everse, [M]mgen addr
 Display options: show [D]ays, [g]roup, show [m]mgen addr, r[e]draw screen
@@ -49,6 +50,7 @@ Display options: show [D]ays, [g]roup, show [m]mgen addr, r[e]draw screen
 		txid     = MMGenListItemAttr('txid','CoinTxID')
 		vout     = MMGenListItemAttr('vout',int,typeconv=False)
 		amt      = MMGenImmutableAttr('amt',g.proto.coin_amt.__name__)
+		amt2     = MMGenListItemAttr('amt2',g.proto.coin_amt.__name__)
 		label    = MMGenListItemAttr('label','TwComment',reassign_ok=True)
 		twmmid   = MMGenImmutableAttr('twmmid','TwMMGenID')
 		addr     = MMGenImmutableAttr('addr','CoinAddr')
@@ -78,8 +80,10 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 		self.sort_key     = 'age'
 		self.do_sort()
 		self.total        = self.get_total_coin()
+		self.disp_prec    = self.get_display_precision()
 
-		g.dcoin = g.dcoin or g.coin
+	def get_display_precision(self):
+		return g.proto.coin_amt.max_prec
 
 	def get_total_coin(self):
 		return sum(i.amt for i in self.unspent)
@@ -157,7 +161,6 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 
 	def format_for_display(self):
 		unsp = self.unspent
-# 		unsp.pdie()
 		self.set_term_columns()
 
 		# allow for 7-digit confirmation nums
@@ -182,16 +185,16 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 
 		out  = [self.hdr_fmt.format(' '.join(self.sort_info()),g.dcoin,self.total.hl())]
 		if g.chain != 'mainnet': out += ['Chain: '+green(g.chain.upper())]
-		if self.show_txid:
-			fs = u' {n:%s} {t:%s} {v:2} {a} {A} {c:<}' % (col1_w,tx_w)
-		else:
-			fs = u' {n:%s} {a} {A} {c:<}' % col1_w
+		fs = {  'btc':   u' {n:%s} {t:%s} {v:2} {a} {A} {c:<}' % (col1_w,tx_w),
+				'eth':   u' {n:%s} {a} {A}' % col1_w }[self.disp_type]
 		out += [fs.format(  n='Num',
 							t='TXid'.ljust(tx_w - 5) + ' Vout',
 							v='',
 							a='Address'.ljust(addr_w),
-							A='Amt({})'.format(g.dcoin).ljust(g.proto.coin_amt.max_prec+4),
-							c=('Confs','Age(d)')[self.show_days])]
+							A='Amt({})'.format(g.dcoin).ljust(self.disp_prec+3),
+							A2=' Amt({})'.format(g.coin).ljust(self.disp_prec+4),
+							c=('Confs','Age(d)')[self.show_days]
+							).rstrip()]
 
 		for n,i in enumerate(unsp):
 			addr_dots = '|' + '.'*(addr_w-1)
@@ -214,26 +217,28 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 											else i.txid[:tx_w-len(txdots)]+txdots,
 									v=i.vout,
 									a=addr_out,
-									A=i.amt.fmt(color=True),
-									c=i.days if self.show_days else i.confs))
+									A=i.amt.fmt(color=True,prec=self.disp_prec),
+									A2=(i.amt2.fmt(color=True,prec=self.disp_prec) if i.amt2 is not None else ''),
+									c=i.days if self.show_days else i.confs
+									).rstrip())
 
 		self.fmt_display = '\n'.join(out) + '\n'
-#		unsp.pdie()
 		return self.fmt_display
 
 	def format_for_printing(self,color=False):
 
 		addr_w = max(len(i.addr) for i in self.unspent)
 		mmid_w = max(len(('',i.twmmid)[i.twmmid.type=='mmgen']) for i in self.unspent) or 12 # DEADBEEF:S:1
-		if self.show_txid:
-			fs  = ' {n:4} {t:%s} {a} {m} {A:%s} {c:<8} {g:<6} {l}' % (self.txid_w+3,g.proto.coin_amt.max_prec+4)
-		else:
-			fs  = ' {n:4} {a} {m} {A:%s} {c:<8} {g:<6} {l}' % (g.proto.coin_amt.max_prec+4)
+		amt_w = g.proto.coin_amt.max_prec + 4
+		fs = {  'btc':   u' {n:4} {t:%s} {a} {m} {A:%s} {c:<8} {g:<6} {l}' % (self.txid_w+3,amt_w),
+				'eth':   u' {n:4} {a} {m} {A:%s} {c:<8} {g:<6} {l}' % amt_w
+				}[self.disp_type]
 		out = [fs.format(   n='Num',
 							t='Tx ID,Vout',
 							a='Address'.ljust(addr_w),
 							m='MMGen ID'.ljust(mmid_w+1),
-							A='Amount({})'.format(g.dcoin),
+							A='Amount({})'.format(g.dcoin).ljust(amt_w+1),
+							A2='Amount({})'.format(g.coin),
 							c='Confs',
 							g='Age(d)',
 							l='Label')]
@@ -248,6 +253,7 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 						m=MMGenID.fmtc(i.twmmid if i.twmmid.type=='mmgen'
 							else 'Non-{}'.format(g.proj_name),width=mmid_w,color=color),
 						A=i.amt.fmt(color=color),
+						A2=(i.amt2.fmt(color=color) if i.amt2 is not None else ''),
 						c=i.confs,
 						g=i.days,
 						l=i.label.hl(color=color) if i.label else
@@ -291,8 +297,7 @@ watch-only wallet using '{}-addrimport' and then re-run this program.
 							return n,s
 
 	def view_and_sort(self,tx):
-		fs = 'Total to spend, excluding fees: {} {}\n\n'
-		txos = fs.format(tx.sum_outputs().hl(),g.dcoin) if tx.outputs else ''
+		txos = self.prompt_fs.format(tx.sum_outputs().hl(),g.dcoin) if tx.outputs else ''
 		prompt = txos + self.prompt.strip()
 		self.display()
 		msg(prompt)
@@ -465,7 +470,7 @@ class TwAddrList(MMGenDict):
 				age=mmid.confs / (1,confs_per_day)[show_days] if hasattr(mmid,'confs') else '-'
 				))
 
-		return '\n'.join(out + ['\nTOTAL: {} {}'.format(self.total.hl(color=True),g.coin)])
+		return '\n'.join(out + ['\nTOTAL: {} {}'.format(self.total.hl(color=True),g.dcoin)])
 
 class TrackingWallet(MMGenObject):
 

+ 89 - 36
mmgen/tx.py

@@ -74,7 +74,7 @@ def mmaddr2coinaddr(mmaddr,ad_w,ad_f):
 			coin_addr = ad_f.mmaddr2coinaddr(mmaddr)
 			if coin_addr:
 				msg(wmsg('addr_in_addrfile_only').format(mmaddr))
-				if not keypress_confirm('Continue anyway?'):
+				if not (opt.yes or keypress_confirm('Continue anyway?')):
 					sys.exit(1)
 			else:
 				die(2,wmsg('addr_not_found').format(mmaddr))
@@ -212,7 +212,6 @@ class MMGenTX(MMGenObject):
 	sig_ext  = 'sigtx'
 	txid_ext = 'txid'
 	desc     = 'transaction'
-	chg_msg_fs = 'Transaction produces {} {} in change'
 	fee_fail_fs = 'Network fee estimation for {c} confirmations failed ({t})'
 	no_chg_msg = 'Warning: Change address will be deleted as transaction produces no change'
 	rel_fee_desc = 'satoshis per byte'
@@ -222,6 +221,8 @@ class MMGenTX(MMGenObject):
 	txview_ftr_fs = 'Total input:  {i} {d}\nTotal output: {o} {d}\nTX fee:       {a} {c}{r}\n'
 	txview_ftr_fs_short = 'In {i} {d} - Out {o} {d}\nFee {a} {c}{r}\n'
 	usr_fee_prompt = 'Enter transaction fee: '
+	fee_is_approximate = False
+	fn_fee_unit = 'satoshi'
 
 	msg_low_coin = 'Selected outputs insufficient to fund this transaction ({} {} needed)'
 	msg_no_change_output = """
@@ -291,8 +292,6 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 		self.caller      = caller
 		self.locktime    = None
 
-		g.dcoin = g.dcoin or g.coin
-
 		if filename:
 			self.parse_tx_file(filename,coin_sym_only=coin_sym_only,silent_open=silent_open)
 			if coin_sym_only: return
@@ -330,6 +329,7 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 		self.outputs.pop(idx)
 
 	def sum_outputs(self,exclude=None):
+		if not len(self.outputs): return g.proto.coin_amt(0)
 		olist = self.outputs if exclude == None else \
 			self.outputs[:exclude] + self.outputs[exclude+1:]
 		return g.proto.coin_amt(sum(e.amt for e in olist))
@@ -484,8 +484,9 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 		return ret
 
 	# convert absolute BTC fee to satoshis-per-byte using estimated size
-	def fee_abs2rel(self,abs_fee):
-		return int(abs_fee/g.proto.coin_amt.min_coin_unit/self.estimate_size())
+	def fee_abs2rel(self,abs_fee,to_unit=None):
+		unit = getattr(g.proto.coin_amt,to_unit or 'min_coin_unit')
+		return int(abs_fee / unit / self.estimate_size())
 
 	def get_rel_fee_from_network(self): # rel_fee is in BTC/kB
 		try:
@@ -559,9 +560,10 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 				abs_fee = self.convert_and_check_fee(tx_fee,desc)
 			if abs_fee:
 				m = ('',' (after {}x adjustment)'.format(opt.tx_fee_adj))[opt.tx_fee_adj != 1]
-				p = u'{} TX fee{}: {} {} ({} {})\n'.format(
+				p = u'{} TX fee{}: {}{} {} ({} {})\n'.format(
 						desc,
 						m,
+						('',u'≈')[self.fee_is_approximate],
 						abs_fee.hl(),
 						g.coin,
 						pink(str(self.fee_abs2rel(abs_fee))),
@@ -953,7 +955,9 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 			self.txid,
 			('-'+g.dcoin,'')[g.coin=='BTC'],
 			self.send_amt,
-			('',',{}'.format(self.fee_abs2rel(self.get_fee_from_tx())))[self.is_rbf()],
+			('',',{}'.format(self.fee_abs2rel(
+								self.get_fee_from_tx(),to_unit=self.fn_fee_unit))
+							)[self.is_replaceable()],
 			('',',tl={}'.format(tl))[bool(tl)],
 			tn,self.ext,
 			x=u'-α' if g.debug_utf8 else '')
@@ -991,11 +995,11 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 				get_char('Press any key to continue: ')
 				msg('')
 
-# 	def is_rbf_from_rpc(self):
+# 	def is_replaceable_from_rpc(self):
 # 		dec_tx = g.rpch.decoderawtransaction(self.hex)
 # 		return None < dec_tx['vin'][0]['sequence'] <= g.max_int - 2
 
-	def is_rbf(self):
+	def is_replaceable(self):
 		return self.inputs[0].sequence == g.max_int - 2
 
 	def format_view_body(self,blockcount,nonmm_str,max_mmwid,enl,terse):
@@ -1075,14 +1079,14 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 			a=self.send_amt.hl(),
 			c=g.dcoin,
 			t=self.timestamp,
-			r=(red('False'),green('True'))[self.is_rbf()],
+			r=(red('False'),green('True'))[self.is_replaceable()],
 			s=self.marked_signed(color=True),
 			l=(green('None'),orange(strfmt_locktime(self.locktime,terse=True)))[bool(self.locktime)])
 
 		if self.chain != 'mainnet':
 			out += green('Chain: {}\n'.format(self.chain.upper()))
 		if self.coin_txid:
-			out += '{} TxID: {}\n'.format(g.dcoin,self.coin_txid.hl())
+			out += '{} TxID: {}\n'.format(g.coin,self.coin_txid.hl())
 		enl = ('\n','')[bool(terse)]
 		out += enl
 		if self.label:
@@ -1101,7 +1105,7 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 
 		return out # TX label might contain non-ascii chars
 
-	def check_tx_hex_data(self):
+	def check_txfile_hex_data(self):
 		self.hex = HexStr(self.hex,on_fail='raise')
 
 	def parse_tx_file(self,infile,coin_sym_only=False,silent_open=False):
@@ -1116,7 +1120,8 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 				import re
 				d = literal_eval(re.sub(r"[A-Za-z]+?\(('.+?')\)",r'\1',raw_data))
 			assert type(d) == list,'{} data not a list!'.format(desc)
-			assert len(d),'no {}!'.format(desc)
+			if not (desc == 'outputs' and g.coin == 'ETH'): # ETH txs can have no outputs
+				assert len(d),'no {}!'.format(desc)
 			for e in d: e['amt'] = g.proto.coin_amt(e['amt'])
 			io,io_list = (
 				(MMGenTX.MMGenTxOutput,MMGenTX.MMGenTxOutputList),
@@ -1179,7 +1184,7 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 			desc = 'block count in metadata'
 			self.blockcount = int(blockcount)
 			desc = 'transaction hex data'
-			self.check_tx_hex_data()
+			self.check_txfile_hex_data()
 			# the following ops will all fail if g.coin doesn't match self.coin
 			desc = 'coin type in metadata'
 			assert self.coin == g.coin,self.coin
@@ -1194,6 +1199,8 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 		if not self.chain and not self.inputs[0].addr.is_for_chain('testnet'):
 			self.chain = 'mainnet'
 
+		if self.dcoin: self.set_g_token()
+
 	def process_cmd_args(self,cmd_args,ad_f,ad_w):
 		for a in cmd_args:
 			if ',' in a:
@@ -1219,6 +1226,9 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 			fs = '{} Segwit address requested on the command line, but Segwit is not active on this chain'
 			rdie(2,fs.format(g.proj_name))
 
+		if not self.outputs:
+			die(2,'At least one output must be specified on the command line')
+
 	def get_outputs_from_cmdline(self,cmd_args):
 		from mmgen.addr import AddrList,AddrData
 		addrfiles = [a for a in cmd_args if get_extension(a) == AddrList.ext]
@@ -1233,9 +1243,6 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 
 		self.process_cmd_args(cmd_args,ad_f,ad_w)
 
-		if not self.outputs:
-			die(2,'At least one output must be specified on the command line')
-
 		self.add_mmaddrs_to_outputs(ad_w,ad_f)
 		self.check_dup_addrs('outputs')
 
@@ -1250,9 +1257,9 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 						return selected
 					msg('Unspent output number must be <= {}'.format(len(unspent)))
 
-	def check_sufficient_funds(self,inputs,foo):
-		if self.send_amt > inputs:
-			msg(self.msg_low_coin.format(self.send_amt-inputs,g.coin))
+	def check_sufficient_funds(self,inputs_sum,foo):
+		if self.send_amt > inputs_sum:
+			msg(self.msg_low_coin.format(self.send_amt-inputs_sum,g.coin))
 			return False
 		return True
 
@@ -1262,23 +1269,55 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 	def warn_insufficient_chg(self,change_amt):
 		msg(self.msg_low_coin.format(g.proto.coin_amt(-change_amt).hl(),g.coin))
 
+	def final_inputs_ok_msg(self,change_amt):
+		m = 'Transaction produces {} {} in change'
+		return m.format(g.proto.coin_amt(change_amt).hl(),g.coin)
+
+	def select_unspent_cmdline(self,unspent):
+		sel_nums = []
+		for i in opt.inputs.split(','):
+			ls = len(sel_nums)
+			if is_mmgen_id(i):
+				for j in range(len(unspent)):
+					if unspent[j].twmmid == i:
+						sel_nums.append(j+1)
+			elif is_coin_addr(i):
+				for j in range(len(unspent)):
+					if unspent[j].addr == i:
+						sel_nums.append(j+1)
+			else:
+				die(1,"'{}': not an MMGen ID or coin address".format(i))
+
+			ldiff = len(sel_nums) - ls
+			if ldiff:
+				sel_inputs = ','.join([str(i) for i in sel_nums[-ldiff:]])
+				ul = unspent[sel_nums[-1]-1]
+				mmid_disp = ' (' + ul.twmmid + ')' if ul.twmmid.type == 'mmgen' else ''
+				msg('Adding input{}: {} {}{}'.format(suf(ldiff),sel_inputs,ul.addr,mmid_disp))
+			else:
+				die(1,"'{}': address not found in tracking wallet".format(i))
+
+		return set(sel_nums) # silently discard duplicates
+
 	def get_inputs_from_user(self,tw):
 
 		while True:
-			sel_nums = self.select_unspent(tw.unspent)
+			us_f = ('select_unspent','select_unspent_cmdline')[bool(opt.inputs)]
+			sel_nums = getattr(self,us_f)(tw.unspent)
+
 			msg('Selected output{}: {}'.format(suf(sel_nums,'s'),' '.join(map(str,sel_nums))))
 
 			sel_unspent = tw.MMGenTwOutputList([tw.unspent[i-1] for i in sel_nums])
 
-			t_inputs = sum(s.amt for s in sel_unspent)
-			if not self.check_sufficient_funds(t_inputs,sel_unspent):
+			inputs_sum = sum(s.amt for s in sel_unspent)
+			if not self.check_sufficient_funds(inputs_sum,sel_unspent):
 				continue
 
 			non_mmaddrs = [i for i in sel_unspent if i.twmmid.type == 'non-mmgen']
 			if non_mmaddrs and self.caller != 'txdo':
 				msg(self.msg_non_mmgen_inputs.format(
 					', '.join(set(sorted([a.addr.hl() for a in non_mmaddrs])))))
-				if not keypress_confirm('Accept?'):
+				if not (opt.yes or keypress_confirm('Accept?')):
 					continue
 
 			self.copy_inputs_from_tw(sel_unspent)  # makes self.inputs
@@ -1287,9 +1326,9 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 
 			change_amt = self.get_change_amt()
 
-			if change_amt >= 0:
-				p = self.chg_msg_fs.format(change_amt.hl(),g.coin)
-				if opt.yes or keypress_confirm(p+'.  OK?',default_yes=True):
+			if change_amt >= 0: # TODO: show both ETH and token amts remaining
+				p = self.final_inputs_ok_msg(change_amt)
+				if opt.yes or keypress_confirm(p+'. OK?',default_yes=True):
 					if opt.yes: msg(p)
 					return change_amt
 			else:
@@ -1309,7 +1348,10 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 
 		from mmgen.tw import TwUnspentOutputs
 		tw = TwUnspentOutputs(minconf=opt.minconf)
-		tw.view_and_sort(self)
+
+		if not opt.inputs:
+			tw.view_and_sort(self)
+
 		tw.display_total()
 
 		if do_info: sys.exit(0)
@@ -1334,7 +1376,7 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 		else:
 			self.update_output_amt(chg_idx,g.proto.coin_amt(change_amt))
 
-		if not self.send_amt:
+		if not self.send_amt and len(self.outputs):
 			self.send_amt = change_amt
 
 		if not opt.yes:
@@ -1360,15 +1402,18 @@ Selected non-{pnm} inputs: {{}}""".strip().format(pnm=g.proj_name,pnl=g.proj_nam
 
 class MMGenBumpTX(MMGenTX):
 
+	def __new__(cls,*args,**kwargs):
+		return MMGenTX.__new__(altcoin_subclass(cls,'tx','MMGenBumpTX'),*args,**kwargs)
+
 	min_fee = None
 	bump_output_idx = None
 
 	def __init__(self,filename,send=False):
 
-		super(type(self),self).__init__(filename)
+		super(MMGenBumpTX,self).__init__(filename)
 
-		if not self.is_rbf():
-			die(1,"Transaction '{}' is not replaceable (RBF)".format(self.txid))
+		if not self.is_replaceable():
+			die(1,"Transaction '{}' is not replaceable".format(self.txid))
 
 		# If sending, require tx to have been signed
 		if send:
@@ -1380,6 +1425,11 @@ class MMGenBumpTX(MMGenTX):
 		self.coin_txid = ''
 		self.mark_raw()
 
+	def check_bumpable(self):
+		if not [o.amt for o in self.outputs if o.amt >= self.min_fee]:
+			die(1,'Transaction cannot be bumped.' +
+			'\nAll outputs have less than the minimum fee ({} {})'.format(self.min_fee,g.coin))
+
 	def choose_output(self):
 		chg_idx = self.get_chg_output_idx()
 		init_reply = opt.output_to_reduce
@@ -1412,15 +1462,18 @@ class MMGenBumpTX(MMGenTX):
 	def set_min_fee(self):
 		self.min_fee = self.sum_inputs() - self.sum_outputs() + self.get_relay_fee()
 
+	def update_fee(self,op_idx,fee):
+		self.update_output_amt(op_idx,self.sum_inputs()-self.sum_outputs(exclude=op_idx)-fee)
+
 	def convert_and_check_fee(self,tx_fee,desc):
-		ret = super(type(self),self).convert_and_check_fee(tx_fee,desc)
+		ret = super(MMGenBumpTX,self).convert_and_check_fee(tx_fee,desc)
 		if ret < self.min_fee:
 			msg('{} {c}: {} fee too small. Minimum fee: {} {c} ({} {})'.format(
-				ret,desc,self.min_fee,self.fee_abs2rel(self.min_fee),self.rel_fee_desc,c=g.coin))
+				ret,desc,self.min_fee,self.fee_abs2rel(self.min_fee.hl()),self.rel_fee_desc,c=g.coin))
 			return False
 		output_amt = self.outputs[self.bump_output_idx].amt
 		if ret >= output_amt:
-			msg('{} {c}: {} fee too large. Maximum fee: <{} {c}'.format(ret,desc,output_amt,c=g.coin))
+			msg('{} {c}: {} fee too large. Maximum fee: <{} {c}'.format(ret.hl(),desc,output_amt.hl(),c=g.coin))
 			return False
 		return ret
 

+ 1 - 0
scripts/test-release.sh

@@ -211,6 +211,7 @@ i_eth='Ethereum'
 s_eth='Testing transaction and tracking wallet operations for Ethereum'
 t_eth=(
 	"$test_py -On --coin=eth ref_tx_chk"
+	"$test_py -On --coin=eth --testnet=1 ref_tx_chk"
 	"$test_py -On ethdev"
 )
 f_eth='Ethereum tests completed'

+ 1 - 1
setup.py

@@ -111,7 +111,6 @@ setup(
 			'mmgen.addr',
 			'mmgen.altcoin',
 			'mmgen.bech32',
-			'mmgen.protocol',
 			'mmgen.color',
 			'mmgen.common',
 			'mmgen.crypto',
@@ -123,6 +122,7 @@ setup(
 			'mmgen.mn_tirosh',
 			'mmgen.obj',
 			'mmgen.opts',
+			'mmgen.protocol',
 			'mmgen.regtest',
 			'mmgen.rpc',
 			'mmgen.seed',

+ 1 - 0
test/mmgen_pexpect.py

@@ -50,6 +50,7 @@ def my_send(p,t,delay=send_delay,s=False):
 	return ret
 
 def my_expect(p,s,t='',delay=send_delay,regex=False,nonl=False,silent=False):
+
 	quo = ('',"'")[type(s) == str]
 
 	if not silent:

+ 3 - 3
test/ref/ethereum/BC79AB-ETH[0.123].rawtx → test/ref/ethereum/4ED554-ETH[0.123].rawtx

@@ -1,6 +1,6 @@
-5eb350
-ETH FOUNDATION BC79AB 0.123 20180530_125230 7513928
-{"nonce": "0", "chainId": "0x1", "from": "e704b6cfd9f0edb2e6cfbd0c913438d37ede7b35", "to": "62ff8e4dbd251b98102e3fb5e4b14119e24cadde", "amt": "0.123", "gasPrice": "0.000000050"}
+0a7b6f
+ETH FOUNDATION 4ED554 0.123 20180530_125230 7513928
+{"nonce": "0", "chainId": "1", "from": "e704b6cfd9f0edb2e6cfbd0c913438d37ede7b35", "to": "62ff8e4dbd251b98102e3fb5e4b14119e24cadde", "amt": "0.123", "gasPrice": "0.000000050"}
 [{'confs': 0, 'addr': 'e704b6cfd9f0edb2e6cfbd0c913438d37ede7b35', 'vout': 0, 'txid': '0000000000000000000000000000000000000000000000000000000000000000', 'label': u'', 'amt': '1.234567', 'mmid': '98831F3A:E:1'}]
 [{'mmid': '98831F3A:E:31', 'amt': '0.123', 'addr': '62ff8e4dbd251b98102e3fb5e4b14119e24cadde'}]
 qRHzrPVpZFYxnQvk3atLzUtp41bZupJ2UQNnKe3ZnmqFsEngS6vaCCvesKKy9khzVq6y2RqarVBcZLnjtXxMpbAcdEtyBWiBYmZdoU8SN4uAbroHT1c7gEbmUNVKKdqHD86ZRRqDNpdh1ztmLiMAy3ibM83puwJHNpGGHgUGjZ1RSEgyVKCs2rZ9wXN8rBMibDDPYo1LgtAst2FkB36Mgf4Vf7ekoRAdiRNGd5YZ3RXAVsSdnZcyn4rdeQDMDkCq7JJDoB25eNEuXQutZFUcf2fEfxkMbW1sXJDNFQq

+ 3 - 3
test/ref/ethereum/F04889-ETH[0.123].testnet.rawtx → test/ref/ethereum/7EE763-ETH[0.123].testnet.rawtx

@@ -1,6 +1,6 @@
-8f7b85
-ETH KOVAN F04889 0.123 20180530_125230 7513928
-{"nonce": "0", "chainId": "0x2a", "from": "97ccc3a117b3696340c42561361054b1c9c793d5", "to": "07f575951e67f855ceffe512ee33a362e177924f", "amt": "0.123", "gasPrice": "0.000000008"}
+bc835b
+ETH KOVAN 7EE763 0.123 20180530_125230 7513928
+{"nonce": "0", "chainId": "42", "from": "97ccc3a117b3696340c42561361054b1c9c793d5", "to": "07f575951e67f855ceffe512ee33a362e177924f", "amt": "0.123", "gasPrice": "0.000000008"}
 [{'confs': 0, 'addr': '97ccc3a117b3696340c42561361054b1c9c793d5', 'label': u'', 'amt': '1.234567', 'mmid': '98831F3A:E:1'}]
 [{'mmid': '98831F3A:E:31', 'amt': '0.123', 'addr': '07f575951e67f855ceffe512ee33a362e177924f'}]
 qRHzrPVpZFYxnQvk3atLzUtp41bZupJ2UQNnKe3ZnmqFsEngS6vaCCvesKKy9khzVq6y2RqarVBcZLnjtXxMpbAcdEtyBWiBYmZdoU8SN4uAbroHT1c7gEbmUNVKKdqHD86ZRRqDNpdh1ztmLiMAy3ibM83puwJHNpGGHgUGjZ1RSEgyVKCs2rZ9wXN8rBMibDDPYo1LgtAst2FkB36Mgf4Vf7ekoRAdiRNGd5YZ3RXAVsSdnZcyn4rdeQDMDkCq7JJDoB25eNEuXQutZFUcf2fEfxkMbW1sXJDNFQq

+ 197 - 76
test/test.py

@@ -20,7 +20,8 @@
 test/test.py:  Test suite for the MMGen suite
 """
 
-import sys,os,subprocess,shutil,time,re
+import sys,os,subprocess,shutil,time,re,json
+from decimal import Decimal
 
 repo_root = os.path.normpath(os.path.abspath(os.path.join(os.path.dirname(sys.argv[0]),os.pardir)))
 os.chdir(repo_root)
@@ -29,7 +30,7 @@ sys.path.__setitem__(0,repo_root)
 # Import these _after_ local path's been added to sys.path
 from mmgen.common import *
 from mmgen.test import *
-from mmgen.protocol import CoinProtocol
+from mmgen.protocol import CoinProtocol,init_coin
 
 set_debug_all()
 
@@ -54,7 +55,7 @@ ref_wallet_brainpass = 'abc'
 ref_wallet_hash_preset = '1'
 ref_wallet_incog_offset = 123
 
-from mmgen.obj import MMGenTXLabel,PrivKey
+from mmgen.obj import MMGenTXLabel,PrivKey,ETHAmt
 from mmgen.addr import AddrGenerator,KeyGenerator,AddrList,AddrData,AddrIdxList
 
 ref_tx_label_jp = u'必要なのは、信用ではなく暗号化された証明に基づく電子取引システムであり、これにより希望する二者が信用できる第三者機関を介さずに直接取引できるよう' # 72 chars ('W'ide)
@@ -160,6 +161,8 @@ sys.argv = [sys.argv[0]] + ['--data-dir',data_dir] + sys.argv[1:]
 cmd_args = opts.init(opts_data)
 opt.popen_spawn = True # popen has issues, so use popen_spawn always
 
+if not opt.system: os.environ['PYTHONPATH'] = repo_root
+
 ref_subdir = '' if g.proto.base_coin == 'BTC' else g.proto.name
 altcoin_pfx = '' if g.proto.base_coin == 'BTC' else '-'+g.proto.base_coin
 tn_ext = ('','.testnet')[g.testnet]
@@ -183,6 +186,11 @@ rtBals = {
 	'bch': ('499.9999484','399.9999194','399.9998972','399.9997692','6.79000000','993.20966920','999.99966920'),
 	'ltc': ('5499.99744','5399.994425','5399.993885','5399.987535','13.00000000','10986.93753500','10999.93753500'),
 }[coin_sel]
+rtBals_gb = {
+	'btc': ('116.77629233','283.22339537'),
+	'bch': ('WIP'),
+	'ltc': ('WIP'),
+}[coin_sel]
 rtBobOp3 = {'btc':'S:2','bch':'L:3','ltc':'S:2'}[coin_sel]
 
 if opt.segwit and 'S' not in g.proto.mmtypes:
@@ -571,8 +579,8 @@ cfgs = {
 					'359FD5-BCH[6.68868,tl=1320969600].testnet.rawtx'),
 			'ltc': ('AF3CDF-LTC[620.76194,1453,tl=1320969600].rawtx',
 					'A5A1E0-LTC[1454.64322,1453,tl=1320969600].testnet.rawtx'),
-			'eth': ('BC79AB-ETH[0.123].rawtx',
-					'F04889-ETH[0.123].testnet.rawtx'),
+			'eth': ('4ED554-ETH[0.123].rawtx',
+					'7EE763-ETH[0.123].testnet.rawtx'),
 		},
 		'ic_wallet':       u'98831F3A-5482381C-18460FB1[256,1].mmincog',
 		'ic_wallet_hex':   u'98831F3A-1630A9F2-870376A9[256,1].mmincox',
@@ -613,6 +621,9 @@ dfl_words = os.path.join(ref_dir,cfgs['8']['seed_id']+'.mmwords')
 eth_addr = '00a329c0648769a73afac7f9381e08fb43dbea72'
 eth_key = '4d5db4107d237df6a3d58ee5f70ae63d73d7658d4026f2eefd2f204c81682cb7'
 eth_args = [u'--outdir={}'.format(cfgs['22']['tmpdir']),'--coin=eth','--rpc-port=8549','--quiet']
+eth_burn_addr = 'deadbeef'*5
+eth_amt1 = '999999.12345689012345678'
+eth_amt2 = '888.111122223333444455'
 
 from copy import deepcopy
 for a,b in (('6','11'),('7','12'),('8','13')):
@@ -651,7 +662,6 @@ cmd_group['main'] = OrderedDict([
 	['passchg_usrlabel',(5,'password, label and hash preset change (interactive label)',[[['mmdat',pwfile],1]])],
 	['walletchk_newpass',(5,'wallet check with new pw, label and hash preset',[[['mmdat',pwfile],5]])],
 	['addrgen',         (1,'address generation',       [[['mmdat',pwfile],1]])],
-	['addrimport',      (1,'address import',           [[['addrs'],1]])],
 	['txcreate',        (1,'transaction creation',     [[['addrs'],1]])],
 	['txbump',          (1,'transaction fee bumping (no send)',[[['rawtx'],1]])],
 	['txsign',          (1,'transaction signing',      [[['mmdat','rawtx',pwfile,'txbump'],1]])],
@@ -676,6 +686,8 @@ cmd_group['main'] = OrderedDict([
 	['keyaddrgen',    (1,'key-address file generation', [[['mmdat',pwfile],1]])],
 	['txsign_keyaddr',(1,'transaction signing with key-address file', [[['akeys.mmenc','rawtx'],1]])],
 
+	['txcreate_ni',   (1,'transaction creation (non-interactive)',     [[['addrs'],1]])],
+
 	['walletgen2',(2,'wallet generation (2), 128-bit seed',     [[['del_dw_run'],15]])],
 	['addrgen2',  (2,'address generation (2)',    [[['mmdat'],2]])],
 	['txcreate2', (2,'transaction creation (2)',  [[['addrs'],2]])],
@@ -801,6 +813,7 @@ cmd_group['regtest'] = (
 	('regtest_bob_split2',         "splitting Bob's funds"),
 	('regtest_generate',           'mining a block'),
 	('regtest_bob_bal5',           "Bob's balance"),
+	('regtest_bob_bal5_getbalance',"Bob's balance"),
 	('regtest_bob_send_non_mmgen', 'sending funds to Alice (from non-MMGen addrs)'),
 	('regtest_generate',           'mining a block'),
 	('regtest_bob_alice_bal',      "Bob and Alice's balances"),
@@ -852,18 +865,38 @@ cmd_group['ethdev'] = (
 	('ethdev_addrgen',             'generating addresses'),
 	('ethdev_addrimport',          'importing addresses'),
 	('ethdev_addrimport_dev_addr', "importing Parity dev address 'Ox00a329c..'"),
-	('ethdev_txcreate',            'creating a transaction (spend from dev address)'),
-	('ethdev_txsign',              'signing the transaction'),
-	('ethdev_txsign_ni',           'signing the transaction (non-interactive)'),
-	('ethdev_txsend',              'sending the transaction'),
-	('ethdev_bal',                 'the balance'),
-	('ethdev_txcreate2',           'creating a transaction (spend from MMGen address)'),
+
+	('ethdev_txcreate1',           'creating a transaction (spend from dev address)'),
+	('ethdev_txsign1',             'signing the transaction'),
+	('ethdev_txsign1_ni',          'signing the transaction (non-interactive)'),
+	('ethdev_txsend1',             'sending the transaction'),
+
+	('ethdev_txcreate2',           'creating a transaction (spend to address 11)'),
 	('ethdev_txsign2',             'signing the transaction'),
 	('ethdev_txsend2',             'sending the transaction'),
-	('ethdev_bal2',                'the balance'),
+
+	('ethdev_txcreate3',           'creating a transaction (spend to address 21)'),
+	('ethdev_txsign3',             'signing the transaction'),
+	('ethdev_txsend3',             'sending the transaction'),
+
+	('ethdev_txcreate4',           'creating a transaction (spend from MMGen address, low TX fee)'),
+	('ethdev_txbump',              'bumping the transaction fee'),
+
+	('ethdev_txsign4',             'signing the transaction'),
+	('ethdev_txsend4',             'sending the transaction'),
+
+	('ethdev_txcreate5',           'creating a transaction (fund burn address)'),
+	('ethdev_txsign5',             'signing the transaction'),
+	('ethdev_txsend5',             'sending the transaction'),
+
+	('ethdev_addrimport_burn_addr',"importing burn address"),
+
+	('ethdev_bal1',                'the balance'),
+
 	('ethdev_add_label',           'adding a UTF-8 label'),
 	('ethdev_chk_label',           'the label'),
 	('ethdev_remove_label',        'removing the label'),
+
 	('ethdev_stop',                'stopping parity'),
 )
 
@@ -993,7 +1026,7 @@ addrs_per_wallet = 8
 meta_cmds = OrderedDict([
 	['gen',  ('walletgen','addrgen')],
 	['pass', ('passchg','walletchk_newpass')],
-	['tx',   ('addrimport','txcreate','txsign','txsend')],
+	['tx',   ('txcreate','txsign','txsend')],
 	['export', [k for k in cmd_data if k[:7] == 'export_' and cmd_data[k][0] == 1]],
 	['gen_sp', [k for k in cmd_data if k[:8] == 'addrgen_' and cmd_data[k][0] == 1]],
 	['online', ('keyaddrgen','txsign_keyaddr')],
@@ -1040,6 +1073,8 @@ def get_segwit_arg(cfg):
 # Tell spawned programs they're running in the test suite
 os.environ['MMGEN_TEST_SUITE'] = '1'
 
+def imsg(s): sys.stderr.write(s+'\n') # never gets redefined
+
 if opt.exact_output:
 	def msg(s): pass
 	vmsg = vmsg_r = msg_r = msg
@@ -1145,7 +1180,6 @@ class MMGenExpect(MMGenPexpect):
 		passthru_args = ['testnet','rpc_host','rpc_port','regtest','coin']
 
 		if not opt.system:
-			os.environ['PYTHONPATH'] = repo_root
 			mmgen_cmd = os.path.relpath(os.path.join(repo_root,'cmds',mmgen_cmd))
 		elif g.platform == 'win':
 			mmgen_cmd = os.path.join('/mingw64','opt','bin',mmgen_cmd)
@@ -1279,7 +1313,7 @@ def make_txcreate_cmdline(tx_data):
 		for idx,mod in enumerate(mods):
 			cfgs[k]['amts'][idx] = '{}.{}'.format(getrandnum(4) % mod, str(getrandnum(4))[:5])
 
-	cmd_args = ['-d',cfg['tmpdir']]
+	cmd_args = ['--outdir='+cfg['tmpdir']]
 	for num in tx_data:
 		s = tx_data[num]
 		cmd_args += [
@@ -1667,46 +1701,45 @@ class MMGenTestSuite(object):
 			msg('Skipping non-Segwit address generation'); return True
 		self.addrgen(name,wf,pf=pf,check_ref=True,mmtype='compressed')
 
-	def addrimport(self,name,addrfile):
-		outfile = os.path.join(cfg['tmpdir'],u'addrfile_w_comments')
-		add_comments_to_addr_file(addrfile,outfile)
-		t = MMGenExpect(name,'mmgen-addrimport', [outfile])
-		t.expect_getend(r'Checksum for address data .*\[.*\]: ',regex=True)
-		t.expect("Type uppercase 'YES' to confirm: ",'\n')
-		vmsg('This is a simulation, so no addresses were actually imported into the tracking\nwallet')
-		t.ok(exit_val=1)
-
 	def txcreate_ui_common(self,t,name,
 							menu=[],inputs='1',
 							file_desc='Transaction',
 							input_sels_prompt='to spend',
 							bad_input_sels=False,non_mmgen_inputs=0,
-							fee_desc='transaction fee',fee='',fee_res=None,
-							add_comment='',view='t',save=True):
+							interactive_fee='',
+							fee_desc='transaction fee',fee_res=None,
+							add_comment='',view='t',save=True,no_ok=False):
 		for choice in menu + ['q']:
 			t.expect(r"'q'=quit view, .*?:.",choice,regex=True)
 		if bad_input_sels:
 			for r in ('x','3-1','9999'):
 				t.expect(input_sels_prompt+': ',r+'\n')
 		t.expect(input_sels_prompt+': ',inputs+'\n')
-		for i in range(non_mmgen_inputs):
-			t.expect('Accept? (y/N): ','y')
 
-		if fee:
-			t.expect(fee_desc+': ',fee+'\n')
+		if not name[:4] == 'txdo':
+			for i in range(non_mmgen_inputs):
+				t.expect('Accept? (y/N): ','y')
+
+		have_est_fee = t.expect([fee_desc+': ','OK? (Y/n): ']) == 1
+		if have_est_fee and not interactive_fee:
+			t.send('y')
+		else:
+			if have_est_fee: t.send('n')
+			t.send(interactive_fee+'\n')
 			if fee_res: t.expect(fee_res)
-		t.expect('OK? (Y/n): ','y')  # fee OK?
+			t.expect('OK? (Y/n): ','y')
+
 		t.expect('(Y/n): ','\n')     # chg amt OK?
 		t.do_comment(add_comment)
 		t.view_tx(view)
 		if not name[:4] == 'txdo':
 			t.expect('(y/N): ',('n','y')[save])
 			t.written_to_file(file_desc)
-			t.ok()
+			if not no_ok: t.ok()
 
 	def txsign_ui_common(self,t,name,   view='t',add_comment='',
 										ni=False,save=True,do_passwd=False,
-										file_desc='Signed transaction'):
+										file_desc='Signed transaction',no_ok=False):
 		txdo = name[:4] == 'txdo'
 
 		if do_passwd:
@@ -1719,7 +1752,7 @@ class MMGenTestSuite(object):
 
 		t.written_to_file(file_desc)
 
-		if not txdo: t.ok()
+		if not txdo and not no_ok: t.ok()
 
 	def do_confirm_send(self,t,quiet=False,confirm_send=True):
 		t.expect('Are you sure you want to broadcast this')
@@ -1728,7 +1761,7 @@ class MMGenTestSuite(object):
 
 	def txsend_ui_common(self,t,name,   view='n',add_comment='',
 										confirm_send=True,bogus_send=True,quiet=False,
-										file_desc='Sent transaction'):
+										file_desc='Sent transaction',no_ok=False):
 
 		txdo = name[:4] == 'txdo'
 		if not txdo:
@@ -1739,13 +1772,16 @@ class MMGenTestSuite(object):
 		self.do_confirm_send(t,quiet=quiet,confirm_send=confirm_send)
 
 		if bogus_send:
+			txid = ''
 			t.expect('BOGUS transaction NOT sent')
 		else:
 			txid = t.expect_getend('Transaction sent: ')
 			assert len(txid) == 64,"'{}': Incorrect txid length!".format(txid)
 
 		t.written_to_file(file_desc)
-		if not txdo: t.ok()
+		if not txdo and not no_ok: t.ok()
+
+		return txid
 
 	def txcreate_common(self,name,
 						sources=['1'],
@@ -1755,7 +1791,8 @@ class MMGenTestSuite(object):
 						add_args=[],
 						view='n',
 						addrs_per_wallet=addrs_per_wallet,
-						non_mmgen_input_compressed=True):
+						non_mmgen_input_compressed=True,
+						cmdline_inputs=False):
 
 		if opt.verbose or opt.exact_output:
 			sys.stderr.write(green('Generating fake tracking wallet info\n'))
@@ -1765,6 +1802,13 @@ class MMGenTestSuite(object):
 		dfake = create_fake_unspent_data(ad,tx_data,non_mmgen_input,non_mmgen_input_compressed)
 		write_fake_data_to_file(repr(dfake))
 		cmd_args = make_txcreate_cmdline(tx_data)
+		if cmdline_inputs:
+			from mmgen.tx import TwLabel
+			cmd_args = ['--inputs={},{},{},{},{},{}'.format(
+				TwLabel(dfake[0]['account']).mmid,dfake[1]['address'],
+				TwLabel(dfake[2]['account']).mmid,dfake[3]['address'],
+				TwLabel(dfake[4]['account']).mmid,dfake[5]['address']
+				),'--outdir='+trash_dir] + cmd_args[1:]
 		end_silence()
 
 		if opt.verbose or opt.exact_output: sys.stderr.write('\n')
@@ -1773,6 +1817,12 @@ class MMGenTestSuite(object):
 			'mmgen-'+('txcreate','txdo')[bool(txdo_args)],
 			([],['--rbf'])[g.proto.cap('rbf')] +
 			['-f',tx_fee,'-B'] + add_args + cmd_args + txdo_args)
+
+		if cmdline_inputs:
+			t.written_to_file('Transaction')
+			t.ok()
+			return
+
 		t.license()
 
 		if txdo_args and add_args: # txdo4
@@ -1809,6 +1859,9 @@ class MMGenTestSuite(object):
 	def txcreate(self,name,addrfile):
 		self.txcreate_common(name,sources=['1'],add_args=['--vsize-adj=1.01'])
 
+	def txcreate_ni(self,name,addrfile):
+		self.txcreate_common(name,sources=['1'],cmdline_inputs=True,add_args=['--yes'])
+
 	def txbump(self,name,txfile,prepend_args=[],seed_args=[]):
 		if not g.proto.cap('rbf'):
 			msg('Skipping RBF'); return True
@@ -2656,6 +2709,16 @@ class MMGenTestSuite(object):
 	def regtest_bob_bal5(self,name):
 		return self.regtest_user_bal(name,'bob',rtBals[3])
 
+	def regtest_bob_bal5_getbalance(self,name):
+		t_ext,t_mmgen = rtBals_gb[0],rtBals_gb[1]
+		assert Decimal(t_ext) + Decimal(t_mmgen) == Decimal(rtBals[3])
+		t = MMGenExpect(name,'mmgen-tool',['--bob','getbalance'])
+		t.expect(r'\n[0-9A-F]{8}: .* '+t_mmgen,regex=True)
+		t.expect(r'\nNon-MMGen: .* '+t_ext,regex=True)
+		t.expect(r'\nTOTAL: .* '+rtBals[3],regex=True)
+		t.read()
+		t.ok()
+
 	def regtest_bob_alice_bal(self,name):
 		t = MMGenExpect(name,'mmgen-regtest',['get_balances'])
 		t.expect('Switching')
@@ -2686,7 +2749,7 @@ class MMGenTestSuite(object):
 		self.txcreate_ui_common(t,'txdo',
 								menu=['M'],inputs=outputs_list,
 								file_desc='Signed transaction',
-								fee=(tx_fee,'')[bool(fee)],
+								interactive_fee=(tx_fee,'')[bool(fee)],
 								add_comment=ref_tx_label_jp,
 								view='t',save=True)
 
@@ -3046,89 +3109,144 @@ class MMGenTestSuite(object):
 		pid = read_from_tmpfile(cfg,cfg['parity_pidfile'])
 		ok()
 
-	def ethdev_addrgen(self,name):
+	def ethdev_addrgen(self,name,addrs='1-3,11-13,21-23'):
 		from mmgen.addr import MMGenAddrType
-		t = MMGenExpect(name,'mmgen-addrgen', eth_args + [dfl_words,'1-10'])
+		t = MMGenExpect(name,'mmgen-addrgen', eth_args + [dfl_words,addrs])
 		t.written_to_file('Addresses')
+		t.read()
 		t.ok()
 
-	def ethdev_addrimport(self,name):
-		fn = get_file_with_ext('addrs',cfg['tmpdir'])
-		t = MMGenExpect(name,'mmgen-addrimport', eth_args[1:] + [fn])
+	def ethdev_addrimport(self,name,ext='21-23].addrs',expect='9/9',add_args=[]):
+		fn = get_file_with_ext(ext,cfg['tmpdir'],no_dot=True,delete=False)
+		t = MMGenExpect(name,'mmgen-addrimport', eth_args[1:] + add_args + [fn])
 		if g.debug: t.expect("Type uppercase 'YES' to confirm: ",'YES\n')
 		t.expect('Importing')
-		t.expect('10/10')
+		t.expect(expect)
 		t.read()
 		t.ok()
 
-	def ethdev_addrimport_dev_addr(self,name):
-		t = MMGenExpect(name,'mmgen-addrimport', eth_args[1:] + ['--address='+eth_addr])
+	def ethdev_addrimport_one_addr(self,name,addr=None,extra_args=[]):
+		t = MMGenExpect(name,'mmgen-addrimport', eth_args[1:] + extra_args + ['--address='+addr])
 		t.expect('OK')
 		t.ok()
 
-	def ethdev_txcreate(self,name,arg='98831F3A:E:1,123.456',acct='1',non_mmgen_inputs=1):
-		t = MMGenExpect(name,'mmgen-txcreate', eth_args + ['-B',arg])
+	def ethdev_addrimport_dev_addr(self,name):
+		self.ethdev_addrimport_one_addr(name,addr=eth_addr)
+
+	def ethdev_addrimport_burn_addr(self,name):
+		self.ethdev_addrimport_one_addr(name,addr=eth_burn_addr)
+
+	def ethdev_txcreate(self,name,args=[],menu=[],acct='1',non_mmgen_inputs=0,
+						interactive_fee='50G',
+						fee_res='0.00105 ETH (50 gas price in Gwei)',
+						fee_desc = 'gas price'):
+		t = MMGenExpect(name,'mmgen-txcreate', eth_args + ['-B'] + args)
 		t.expect(r"'q'=quit view, .*?:.",'p', regex=True)
 		t.written_to_file('Account balances listing')
 		self.txcreate_ui_common(t,name,
-								menu=['a','d','A','r','M','D','e','m','m'],
+								menu=menu,
 								input_sels_prompt='to spend from',
 								inputs=acct,file_desc='Ethereum transaction',
 								bad_input_sels=True,non_mmgen_inputs=non_mmgen_inputs,
-								fee_desc='gas price',fee='50G',fee_res='0.00105 ETH (50 gas price in Gwei)')
+								interactive_fee=interactive_fee,fee_res=fee_res,fee_desc=fee_desc)
 
-	def ethdev_txsign(self,name,ni=False,ext='.rawtx'):
+	def ethdev_txsign(self,name,ni=False,ext='.rawtx',add_args=[]):
 		key_fn = get_tmpfile_fn(cfg,cfg['parity_keyfile'])
 		write_to_tmpfile(cfg,cfg['parity_keyfile'],eth_key+'\n')
 		tx_fn = get_file_with_ext(ext,cfg['tmpdir'],no_dot=True)
-		t = MMGenExpect(name,'mmgen-txsign',eth_args + ([],['--yes'])[ni] + ['-k',key_fn,tx_fn,dfl_words])
+		t = MMGenExpect(name,'mmgen-txsign',eth_args+add_args + ([],['--yes'])[ni] + ['-k',key_fn,tx_fn,dfl_words])
 		self.txsign_ui_common(t,name,ni=ni)
 
-	def ethdev_txsign_ni(self,name):
-		self.ethdev_txsign(name,ni=True)
-
-	def ethdev_txsend(self,name,ni=False,bogus_send=False,ext='.sigtx'):
+	def ethdev_txsend(self,name,ni=False,bogus_send=False,ext='.sigtx',add_args=[]):
 		tx_fn = get_file_with_ext(ext,cfg['tmpdir'],no_dot=True)
 		if not bogus_send: os.environ['MMGEN_BOGUS_SEND'] = ''
-		t = MMGenExpect(name,'mmgen-txsend', eth_args + [tx_fn])
+		t = MMGenExpect(name,'mmgen-txsend', eth_args+add_args + [tx_fn])
 		if not bogus_send: os.environ['MMGEN_BOGUS_SEND'] = '1'
 		self.txsend_ui_common(t,name,quiet=True,bogus_send=bogus_send)
 
-	def ethdev_bal(self,name):
-		t = MMGenExpect(name,'mmgen-tool', eth_args + ['twview'])
-		t.expect(r'98831F3A:E:1\s+123\.456\s+',regex=True)
-		t.ok()
+	def ethdev_txcreate1(self,name):
+		menu = ['a','d','A','r','M','D','e','m','m']
+		args = ['98831F3A:E:1,123.456']
+		return self.ethdev_txcreate(name,args=args,menu=menu,acct='1',non_mmgen_inputs=1)
+
+	def ethdev_txsign1(self,name): self.ethdev_txsign(name)
+	def ethdev_txsign1_ni(self,name): self.ethdev_txsign(name,ni=True)
+	def ethdev_txsend1(self,name): self.ethdev_txsend(name)
 
 	def ethdev_txcreate2(self,name):
-		return self.ethdev_txcreate(name,arg='98831F3A:E:2,23.45495',acct='11',non_mmgen_inputs=0)
+		args = ['98831F3A:E:11,1.234']
+		return self.ethdev_txcreate(name,args=args,acct='10',non_mmgen_inputs=1)
+	def ethdev_txsign2(self,name): self.ethdev_txsign(name,ni=True,ext='1.234,50000].rawtx')
+	def ethdev_txsend2(self,name): self.ethdev_txsend(name,ext='1.234,50000].sigtx')
+
+	def ethdev_txcreate3(self,name):
+		args = ['98831F3A:E:21,2.345']
+		return self.ethdev_txcreate(name,args=args,acct='10',non_mmgen_inputs=1)
+	def ethdev_txsign3(self,name): self.ethdev_txsign(name,ni=True,ext='2.345,50000].rawtx')
+	def ethdev_txsend3(self,name): self.ethdev_txsend(name,ext='2.345,50000].sigtx')
+
+	def ethdev_txcreate4(self,name):
+		args = ['98831F3A:E:2,23.45495']
+		interactive_fee='40G'
+		fee_res='0.00084 ETH (40 gas price in Gwei)'
+		return self.ethdev_txcreate(name,args=args,acct='1',non_mmgen_inputs=0,
+					interactive_fee=interactive_fee,fee_res=fee_res)
+
+	def ethdev_txbump(self,name,ext=',40000].rawtx',fee='50G',add_args=[]):
+		tx_fn = get_file_with_ext(ext,cfg['tmpdir'],no_dot=True)
+		t = MMGenExpect(name,'mmgen-txbump', eth_args + add_args + ['--yes',tx_fn])
+		t.expect('or gas price: ',fee+'\n')
+		t.read()
+		t.ok()
 
-	def ethdev_txsign2(self,name):
-		self.ethdev_txsign(name,ext='.45495].rawtx',ni=True)
+	def ethdev_txsign4(self,name): self.ethdev_txsign(name,ni=True,ext='.45495,50000].rawtx')
+	def ethdev_txsend4(self,name): self.ethdev_txsend(name,ext='.45495,50000].sigtx')
 
-	def ethdev_txsend2(self,name):
-		self.ethdev_txsend(name,ni=True,ext='.45495].sigtx')
+	def ethdev_txcreate5(self,name):
+		args = [eth_burn_addr + ','+eth_amt1]
+		return self.ethdev_txcreate(name,args=args,acct='10',non_mmgen_inputs=1)
+	def ethdev_txsign5(self,name): self.ethdev_txsign(name,ni=True,ext=eth_amt1+',50000].rawtx')
+	def ethdev_txsend5(self,name): self.ethdev_txsend(name,ext=eth_amt1+',50000].sigtx')
 
-	def ethdev_bal2(self,name):
+	def ethdev_bal(self,name,expect_str=''):
 		t = MMGenExpect(name,'mmgen-tool', eth_args + ['twview'])
-		t.expect(r'98831F3A:E:1\s+100\s+',regex=True)
-		t.expect(r'98831F3A:E:2\s+23\.45495\s+',regex=True)
+		t.expect(expect_str,regex=True)
+		t.read()
 		t.ok()
 
-	def ethdev_add_label(self,name,addr='98831F3A:E:10',lbl=utf8_label):
+	def ethdev_bal_getbalance(self,name,t_non_mmgen='',t_mmgen='',extra_args=[]):
+		t = MMGenExpect(name,'mmgen-tool', eth_args + extra_args + ['getbalance'])
+		t.expect(r'\n[0-9A-F]{8}: .* '+t_mmgen,regex=True)
+		t.expect(r'\nNon-MMGen: .* '+t_non_mmgen,regex=True)
+		total = t.expect_getend(r'\nTOTAL:\s+',regex=True).split()[0]
+		t.read()
+		assert Decimal(t_non_mmgen) + Decimal(t_mmgen) == Decimal(total)
+		t.ok()
+
+	def ethdev_bal1(self,name,expect_str=''):
+		self.ethdev_bal(name,expect_str=r'98831F3A:E:2\s+23\.45495\s+')
+
+	def ethdev_add_label(self,name,addr='98831F3A:E:3',lbl=utf8_label):
 		t = MMGenExpect(name,'mmgen-tool', eth_args + ['add_label',addr,lbl])
 		t.expect('Added label.*in tracking wallet',regex=True)
 		t.ok()
 
-	def ethdev_chk_label(self,name,addr='98831F3A:E:10',label_pat=utf8_label_pat):
+	def ethdev_chk_label(self,name,addr='98831F3A:E:3',label_pat=utf8_label_pat):
 		t = MMGenExpect(name,'mmgen-tool', eth_args + ['listaddresses','all_labels=1'])
 		t.expect(r'{}\s+\S{{30}}\S+\s+{}\s+'.format(addr,(label_pat or label).encode('utf8')),regex=True)
 		t.ok()
 
-	def ethdev_remove_label(self,name,addr='98831F3A:E:10'):
+	def ethdev_remove_label(self,name,addr='98831F3A:E:3'):
 		t = MMGenExpect(name,'mmgen-tool', eth_args + ['remove_label',addr])
 		t.expect('Removed label.*in tracking wallet',regex=True)
 		t.ok()
 
+	def init_ethdev_common(self):
+		g.testnet = True
+		init_coin('eth')
+		g.proto.rpc_port = 8549
+		rpc_init()
+
 	def ethdev_stop(self,name):
 		MMGenExpect(name,'',msg_only=True)
 		pid = read_from_tmpfile(cfg,cfg['parity_pidfile'])
@@ -3265,9 +3383,12 @@ try:
 except KeyboardInterrupt:
 	die(1,'\nExiting at user request')
 except opt.traceback and Exception:
-	with open('my.err') as f:
-		t = f.readlines()
-		if t: msg_r('\n'+yellow(''.join(t[:-1]))+red(t[-1]))
+	try:
+		os.stat('my.err')
+		with open('my.err') as f:
+			t = f.readlines()
+			if t: msg_r('\n'+yellow(''.join(t[:-1]))+red(t[-1]))
+	except: pass
 	die(1,blue('Test script exited with error'))
 except:
 	sys.stderr = stderr_save