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.
This commit is contained in:
The MMGen Project 2018-05-30 15:35:43 +00:00
commit adef0b38c5
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
3 changed files with 302 additions and 0 deletions

278
mmgen/altcoins/eth/tx.py Executable file
View file

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

View file

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

View file

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