From adef0b38c555762719219cf198c888d56756629f Mon Sep 17 00:00:00 2001 From: MMGen Date: Wed, 30 May 2018 15:35:43 +0000 Subject: [PATCH] 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/altcoins/eth/tx.py | 278 +++++++++++++++++++++++++++++++++++++++ mmgen/obj.py | 18 +++ mmgen/tx.py | 6 + 3 files changed, 302 insertions(+) create mode 100755 mmgen/altcoins/eth/tx.py diff --git a/mmgen/altcoins/eth/tx.py b/mmgen/altcoins/eth/tx.py new file mode 100755 index 00000000..b51d83f7 --- /dev/null +++ b/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 +# +# 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 . + +""" +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 diff --git a/mmgen/obj.py b/mmgen/obj.py index 8b64a624..db0fb757 100755 --- a/mmgen/obj.py +++ b/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 diff --git a/mmgen/tx.py b/mmgen/tx.py index 12f6f366..54f536c9 100755 --- a/mmgen/tx.py +++ b/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()