Browse Source

Ethereum transaction support (create, sign, send)

- Tested on testnet, but functionality should be considered experimental.  Many
  basic checks are still not performed.
- Uses Parity as the backend.
- No support yet for transaction bumping or autosigning.
- Tracking wallet is maintained by MMGen.  Any account on the blockchain can be
  tracked.  Parity and Geth accounts are ignored.
MMGen 7 years ago
parent
commit
adef0b38c5
3 changed files with 302 additions and 0 deletions
  1. 278 0
      mmgen/altcoins/eth/tx.py
  2. 18 0
      mmgen/obj.py
  3. 6 0
      mmgen/tx.py

+ 278 - 0
mmgen/altcoins/eth/tx.py

@@ -0,0 +1,278 @@
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+#
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
+# Copyright (C)2013-2018 The MMGen Project <mmgen@tuta.io>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""
+altcoins.eth.tx: Ethereum transaction routines for the MMGen suite
+"""
+
+import json
+from mmgen.common import *
+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'
+	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'
+	usr_fee_prompt = 'Enter transaction fee or gas price: '
+
+	usr_rel_fee = None # not in MMGenTX
+	txobj_data  = None # ""
+
+	def check_fee(self):
+		assert self.fee <= g.proto.max_tx_fee
+
+	def get_hex_locktime(self): return None # TODO
+
+	def check_sigs(self,deserial_tx=None):
+		if is_hex_str(self.hex):
+			self.mark_signed()
+			return True
+		return False
+
+	# hex data if signed, json if unsigned
+	def check_tx_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'])
+			}
+			txid = CoinTxID(etx.hash.encode('hex'))
+			assert txid == self.coin_txid,"txid in tx.hex doesn't match value in MMGen tx 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 = {
+			'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),
+			'nonce': ETHNonce(int(g.rpch.parity_nextNonce('0x'+self.inputs[0].addr),16)),
+			'chainId': g.rpch.parity_chainId()
+		}
+		self.hex = json.dumps(dict([(k,str(v))for k,v in self.txobj_data.items()]))
+		self.update_txid()
+
+	def del_output(self,idx): pass
+	def update_output_amt(self,idx,amt): pass
+	def get_chg_output_idx(self): return None
+
+	def update_txid(self):
+		assert not is_hex_str(self.hex),'update_txid() must be called only when self.hex is not hex data'
+		self.txid = MMGenTxID(make_chksum_6(self.hex).upper())
+
+	def get_blockcount(self):
+		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'
+			die(1,fs.format(lc,suf(lc)))
+
+		a = list(cmd_args)[0]
+		if ',' in a:
+			a1,a2 = a.split(',',1)
+			if is_mmgen_id(a1) or is_coin_addr(a1):
+				coin_addr = mmaddr2coinaddr(a1,ad_w,ad_f) if is_mmgen_id(a1) else CoinAddr(a1)
+				self.add_output(coin_addr,ETHAmt(a2))
+			else:
+				die(2,"{}: invalid subargument in command-line argument '{}'".format(a1,a))
+		else:
+			die(2,'{}: invalid command-line argument'.format(a))
+
+	def select_unspent(self,unspent):
+		prompt = 'Enter an account to spend from: '
+		while True:
+			reply = my_raw_input(prompt).strip()
+			if reply:
+				if not is_int(reply):
+					msg('Account number must be an integer')
+				elif int(reply) < 1:
+					msg('Account number must be >= 1')
+				elif int(reply) > len(unspent):
+					msg('Account number must be <= {}'.format(len(unspent)))
+				else:
+					return [int(reply)]
+
+	# coin-specific fee routines:
+	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
+		ret = ETHAmt(int(abs_fee.toWei() / self.tx_gas.toWei()),'wei')
+		return ret if in_eth else ret.toGwei()
+
+	# 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
+
+	# given rel fee and units, return absolute fee using tx_gas
+	def convert_fee_spec(self,foo,units,amt,unit):
+		self.usr_rel_fee = ETHAmt(int(amt),units[unit])
+		return ETHAmt(self.usr_rel_fee.toWei() * self.tx_gas.toWei(),'wei')
+
+	# 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)
+		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
+	def fee_est2abs(self,rel_fee,fe_type=None):
+		ret = self.fee_rel2abs(rel_fee) * opt.tx_fee_adj
+		if opt.verbose:
+			msg('Estimated fee: {} ETH'.format(ret))
+		return ret
+
+	def convert_and_check_fee(self,tx_fee,desc='Missing description'):
+		abs_fee = self.process_fee_spec(tx_fee,None,on_fail='return')
+		if abs_fee == False:
+			return False
+		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))
+			return False
+		else:
+			return abs_fee
+
+	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)
+		fs = """From:      {}{f_mmid}
+				To:        {}{t_mmid}
+				Amount:    {} ETH
+				Gas price: {g} Gwei
+				Nonce:     {}\n\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'],
+							f_mmid=m['in'])
+
+	def format_view_abs_fee(self):
+		return self.fee_rel2abs(self.txobj_data['gasPrice'].toWei()).hl()
+
+	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
+
+		if self.marked_signed():
+			msg('Transaction is already signed!')
+			return False
+
+		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(),
+				'gasprice': d['gasPrice'].toWei(),
+				'value':    d['amt'].toWei(),
+				'nonce':    d['nonce'],
+				'data':     ''}
+
+		msg_r('Signing transaction{}...'.format(tx_num_str))
+
+		try:
+			from ethereum.transactions import Transaction
+			etx = Transaction(**out)
+			etx.sign(wif,int(d['chainId'],16))
+			import rlp
+			self.hex = rlp.encode(etx).encode('hex')
+			self.coin_txid = CoinTxID(etx.hash.encode('hex'))
+			msg('OK')
+		except Exception as e:
+			m = "{!r}: transaction signing failed!"
+			msg(m.format(e[0]))
+			return False
+
+		return self.check_sigs()
+
+	def get_status(self,status=False): pass # TODO
+
+	def send(self,prompt_user=True,exit_on_fail=False):
+
+		if not self.marked_signed():
+			die(1,'Transaction is not signed!')
+
+		self.check_correct_chain(on_fail='die')
+
+		bogus_send = os.getenv('MMGEN_BOGUS_SEND')
+
+		fee = self.fee_rel2abs(self.txobj_data['gasPrice'].toWei())
+
+		if 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))
+
+		self.get_status()
+
+		if prompt_user: self.confirm_send()
+
+		ret = None if bogus_send else g.rpch.eth_sendRawTransaction('0x'+self.hex,on_fail='return')
+
+		from mmgen.rpc import rpc_error,rpc_errmsg
+		if rpc_error(ret):
+			msg(yellow(rpc_errmsg(ret)))
+			msg(red('Send of MMGen transaction {} failed'.format(self.txid)))
+			if exit_on_fail: sys.exit(1)
+			return False
+		else:
+			m = 'BOGUS transaction NOT sent: {}' if bogus_send else 'Transaction sent: {}'
+			if not bogus_send:
+				assert ret == '0x'+self.coin_txid,'txid mismatch (after sending)'
+			self.desc = 'sent transaction'
+			msg(m.format(self.coin_txid.hl()))
+			self.add_timestamp()
+			self.add_blockcount()
+			return True
+
+class EthereumMMGenBumpTX(MMGenBumpTX): pass
+class EthereumMMGenSplitTX(MMGenSplitTX): pass
+class EthereumDeserializedTX(DeserializedTX): pass

+ 18 - 0
mmgen/obj.py

@@ -408,6 +408,24 @@ class ETHAmt(BTCAmt):
 	def toSzabo(self):  return int(Decimal(self) / self.szabo)
 	def toFinney(self): return int(Decimal(self) / self.finney)
 
+class ETHNonce(int,Hilite,InitErrors): # WIP
+	def __new__(cls,n,on_fail='die'):
+		if type(n) == cls: return n
+		cls.arg_chk(cls,on_fail)
+		from mmgen.util import is_int
+		try:
+			assert is_int(n),"'{}': value is not an integer".format(n)
+			me = int.__new__(cls,n)
+			return me
+		except Exception as e:
+			m = "{!r}: value cannot be converted to ETH nonce ({})"
+			return cls.init_fail(m.format(n,e[0]),on_fail)
+
+	@classmethod
+	def colorize(cls,s,color=True):
+		k = color if type(color) is str else cls.color # hack: override color with str value
+		return globals()[k](str(s)) if (color or cls.color_always) else str(s)
+
 class CoinAddr(str,Hilite,InitErrors,MMGenObject):
 	color = 'cyan'
 	hex_width = 40

+ 6 - 0
mmgen/tx.py

@@ -269,6 +269,12 @@ class MMGenTX(MMGenObject):
 		desc = 'transaction outputs'
 		member_type = 'MMGenTxOutput'
 
+	def __new__(cls,*args,**kwargs):
+		if g.coin == 'ETH':
+			from mmgen.altcoins.eth.tx import EthereumMMGenTX
+			cls = EthereumMMGenTX
+		return MMGenObject.__new__(cls,*args,**kwargs)
+
 	def __init__(self,filename=None,md_only=False,caller=None,silent_open=False):
 		self.inputs      = self.MMGenTxInputList()
 		self.outputs     = self.MMGenTxOutputList()