mmgen-wallet/mmgen/altcoins/eth/tx.py

595 lines
20 KiB
Python
Executable file

#!/usr/bin/env python3
#
# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
# Copyright (C)2013-2022 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 classes for the MMGen suite
"""
import json
from mmgen.common import *
from mmgen.exception import TransactionChainMismatch
from mmgen.obj import *
from mmgen.tx import MMGenTX
from mmgen.tw import TrackingWallet
from .contract import Token
class EthereumMMGenTX:
class Base(MMGenTX.Base):
rel_fee_desc = 'gas price'
rel_fee_disp = 'gas price in Gwei'
txobj = None # ""
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
contract_desc = 'contract'
usr_contract_data = HexStr('')
disable_fee_check = False
# given absolute fee in ETH, return gas price in Gwei using tx_gas
def fee_abs2rel(self,abs_fee,to_unit='Gwei'):
ret = ETHAmt(int(abs_fee.toWei() // self.tx_gas.toWei()),'wei')
dmsg(f'fee_abs2rel() ==> {ret} ETH')
return ret if to_unit == 'eth' else ret.to_unit(to_unit,show_decimal=True)
def get_hex_locktime(self):
return None # TODO
# given rel fee (gasPrice) in wei, return absolute fee using tx_gas (not in MMGenTX)
def fee_gasPrice2abs(self,rel_fee):
assert isinstance(rel_fee,int), f'{rel_fee!r}: incorrect type for fee estimate (not an integer)'
return ETHAmt(rel_fee * self.tx_gas.toWei(),'wei')
def is_replaceable(self):
return True
async def get_receipt(self,txid,silent=False):
rx = await self.rpc.call('eth_getTransactionReceipt','0x'+txid) # -> null if pending
if not rx:
return None
tx = await self.rpc.call('eth_getTransactionByHash','0x'+txid)
return namedtuple('exec_status',['status','gas_sent','gas_used','gas_price','contract_addr','tx','rx'])(
status = Int(rx['status'],16), # zero is failure, non-zero success
gas_sent = Int(tx['gas'],16),
gas_used = Int(rx['gasUsed'],16),
gas_price = ETHAmt(int(tx['gasPrice'],16),from_unit='wei'),
contract_addr = self.proto.coin_addr(rx['contractAddress'][2:]) if rx['contractAddress'] else None,
tx = tx,
rx = rx,
)
class New(Base,MMGenTX.New):
hexdata_type = 'hex'
desc = 'transaction'
fee_fail_fs = 'Network fee estimation failed'
no_chg_msg = 'Warning: Transaction leaves account with zero balance'
usr_fee_prompt = 'Enter transaction fee or gas price: '
def __init__(self,*args,**kwargs):
MMGenTX.New.__init__(self,*args,**kwargs)
if getattr(opt,'tx_gas',None):
self.tx_gas = self.start_gas = ETHAmt(int(opt.tx_gas),'wei')
if getattr(opt,'contract_data',None):
m = "'--contract-data' option may not be used with token transaction"
assert not 'Token' in type(self).__name__, m
self.usr_contract_data = HexStr(open(opt.contract_data).read().strip())
self.disable_fee_check = True
async def get_nonce(self):
return ETHNonce(int(await self.rpc.call('eth_getTransactionCount','0x'+self.inputs[0].addr,'pending'),16))
async def make_txobj(self): # called by create_raw()
self.txobj = {
'from': self.inputs[0].addr,
'to': self.outputs[0].addr if self.outputs else Str(''),
'amt': self.outputs[0].amt if self.outputs else ETHAmt('0'),
'gasPrice': self.fee_abs2rel(self.usr_fee,to_unit='eth'),
'startGas': self.start_gas,
'nonce': await self.get_nonce(),
'chainId': self.rpc.chainID,
'data': self.usr_contract_data,
}
# 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
async def create_raw(self):
assert len(self.inputs) == 1,'Transaction has more than one input!'
o_num = len(self.outputs)
o_ok = 0 if self.usr_contract_data else 1
assert o_num == o_ok, f'Transaction has {o_num} output{suf(o_num)} (should have {o_ok})'
await self.make_txobj()
odict = { k: str(v) for k,v in self.txobj.items() if k != 'token_to' }
self.hex = json.dumps(odict)
self.update_txid()
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 del_output(self,idx):
pass
def process_cmd_args(self,cmd_args,ad_f,ad_w):
lc = len(cmd_args)
if lc == 0 and self.usr_contract_data and not 'Token' in type(self).__name__:
return
if lc != 1:
die(1,f'{lc} output{suf(lc)} specified, but Ethereum transactions must have exactly one')
for a in cmd_args:
self.process_cmd_arg(a,ad_f,ad_w)
def select_unspent(self,unspent):
while True:
reply = line_input('Enter an account to spend from: ').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(f'Account number must be <= {len(unspent)}')
else:
return [int(reply)]
# coin-specific fee routines:
@property
def relay_fee(self):
return ETHAmt('0') # TODO
# get rel_fee (gas price) from network, return in native wei
async def get_rel_fee_from_network(self):
return Int(await self.rpc.call('eth_gasPrice'),16),'eth_gasPrice' # ==> rel_fee,fe_type
def check_fee(self):
if not self.disable_fee_check:
assert self.usr_fee <= self.proto.max_tx_fee
# given rel fee and units, return absolute fee using tx_gas
def fee_rel2abs(self,tx_size,units,amt,unit):
return ETHAmt(
ETHAmt(amt,units[unit]).toWei() * self.tx_gas.toWei(),
from_unit='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_gasPrice2abs(rel_fee) * opt.tx_fee_adj
if opt.verbose:
msg(f'Estimated fee: {ret} ETH')
return ret
def convert_and_check_fee(self,tx_fee,desc='Missing description'):
abs_fee = self.feespec2abs(tx_fee,None)
if abs_fee == False:
return False
elif not self.disable_fee_check and (abs_fee > self.proto.max_tx_fee):
msg('{} {c}: {} fee too large (maximum fee: {} {c})'.format(
abs_fee.hl(),
desc,
self.proto.max_tx_fee.hl(),
c = self.proto.coin ))
return False
else:
return abs_fee
def update_change_output(self,funds_left):
if self.outputs and self.outputs[0].is_chg:
self.update_output_amt(0,ETHAmt(funds_left))
async def get_cmdline_input_addrs(self):
ret = []
if opt.inputs:
r = (await TrackingWallet(self.proto)).data_root # must create new instance here
m = 'Address {!r} not in tracking wallet'
for i in opt.inputs.split(','):
if is_mmgen_id(self.proto,i):
for addr in r:
if r[addr]['mmid'] == i:
ret.append(addr)
break
else:
raise UserAddressNotInWallet(m.format(i))
elif is_coin_addr(self.proto,i):
if not i in r:
raise UserAddressNotInWallet(m.format(i))
ret.append(i)
else:
die(1,f'{i!r}: not an MMGen ID or coin address')
return ret
def final_inputs_ok_msg(self,funds_left):
chg = '0' if (self.outputs and self.outputs[0].is_chg) else funds_left
return 'Transaction leaves {} {} in the sender’s account'.format(
ETHAmt(chg).hl(),
self.proto.coin
)
class Completed(Base,MMGenTX.Completed):
fn_fee_unit = 'Mwei'
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 = fmt("""
Total in account: {i} {d}
Total to spend: {o} {d}
Remaining balance: {C} {d}
TX fee: {a} {c}{r}
""")
fmt_keys = ('from','to','amt','nonce')
@property
def send_amt(self):
return self.outputs[0].amt if self.outputs else self.proto.coin_amt('0')
@property
def fee(self):
return self.fee_gasPrice2abs(self.txobj['gasPrice'].toWei())
@property
def change(self):
return self.sum_inputs() - self.send_amt - self.fee
def check_txfile_hex_data(self):
pass
def format_view_body(self,blockcount,nonmm_str,max_mmwid,enl,terse,sort):
m = {}
for k in ('inputs','outputs'):
if len(getattr(self,k)):
m[k] = getattr(self,k)[0].mmid if len(getattr(self,k)) else ''
m[k] = ' ' + m[k].hl() if m[k] else ' ' + MMGenID.hlc(nonmm_str)
fs = """From: {}{f_mmid}
To: {}{t_mmid}
Amount: {} {c}
Gas price: {g} Gwei
Start gas: {G} Kwei
Nonce: {}
Data: {d}
\n""".replace('\t','')
t = self.txobj
td = t['data']
return fs.format(
*((t[k] if t[k] != '' else Str('None')).hl() for k in self.fmt_keys),
d = '{}... ({} bytes)'.format(td[:40],len(td)//2) if len(td) else Str('None'),
c = self.proto.dcoin if len(self.outputs) else '',
g = yellow(str(t['gasPrice'].to_unit('Gwei',show_decimal=True))),
G = yellow(str(t['startGas'].to_unit('Kwei'))),
t_mmid = m['outputs'] if len(self.outputs) else '',
f_mmid = m['inputs'] )
def format_view_abs_fee(self):
return self.fee.hl() + (' (max)' if self.txobj['data'] else '')
def format_view_rel_fee(self,terse):
return ' ({} of spend amount)'.format(
pink('{:0.6f}%'.format( self.fee / self.send_amt * 100 ))
)
def format_view_verbose_footer(self):
if self.txobj['data']:
from .contract import parse_abi
return '\nParsed contract data: ' + pp_fmt(parse_abi(self.txobj['data']))
else:
return ''
def check_sigs(self,deserial_tx=None): # TODO
if is_hex_str(self.hex):
return True
return False
def check_pubkey_scripts(self):
pass
class Unsigned(Completed,MMGenTX.Unsigned):
hexdata_type = 'json'
desc = 'unsigned transaction'
def parse_txfile_hex_data(self):
d = json.loads(self.hex)
o = {
'from': CoinAddr(self.proto,d['from']),
# NB: for token, 'to' is sendto address
'to': CoinAddr(self.proto,d['to']) if d['to'] else Str(''),
'amt': ETHAmt(d['amt']),
'gasPrice': ETHAmt(d['gasPrice']),
'startGas': ETHAmt(d['startGas']),
'nonce': ETHNonce(d['nonce']),
'chainId': None if d['chainId'] == 'None' else Int(d['chainId']),
'data': HexStr(d['data']) }
self.tx_gas = o['startGas'] # approximate, but better than nothing
self.txobj = o
return d # 'token_addr','decimals' required by Token subclass
async def do_sign(self,wif,tx_num_str):
o = self.txobj
o_conv = {
'to': bytes.fromhex(o['to']),
'startgas': o['startGas'].toWei(),
'gasprice': o['gasPrice'].toWei(),
'value': o['amt'].toWei() if o['amt'] else 0,
'nonce': o['nonce'],
'data': bytes.fromhex(o['data']) }
from .pyethereum.transactions import Transaction
etx = Transaction(**o_conv).sign(wif,o['chainId'])
assert etx.sender.hex() == o['from'],(
'Sender address recovered from signature does not match true sender')
from . import rlp
self.hex = rlp.encode(etx).hex()
self.coin_txid = CoinTxID(etx.hash.hex())
if o['data']:
if o['to']:
assert self.txobj['token_addr'] == TokenAddr(etx.creates.hex()),'Token address mismatch'
else: # token- or contract-creating transaction
self.txobj['token_addr'] = TokenAddr(self.proto,etx.creates.hex())
assert self.check_sigs(),'Signature check failed'
async def sign(self,tx_num_str,keys): # return TX object or False; don't exit or raise exception
try:
self.check_correct_chain()
except TransactionChainMismatch:
return False
msg_r(f'Signing transaction{tx_num_str}...')
try:
await self.do_sign(keys[0].sec.wif,tx_num_str)
msg('OK')
return MMGenTX.Signed(data=self.__dict__)
except Exception as e:
msg("{e!s}: transaction signing failed!")
if g.traceback:
import traceback
ymsg('\n'+''.join(traceback.format_exception(*sys.exc_info())))
return False
class Signed(Completed,MMGenTX.Signed):
desc = 'signed transaction'
def parse_txfile_hex_data(self):
from .pyethereum.transactions import Transaction
from . import rlp
etx = rlp.decode(bytes.fromhex(self.hex),Transaction)
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(self.proto,d['sender']),
# NB: for token, 'to' is token address
'to': CoinAddr(self.proto,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']: # token- or contract-creating transaction
# NB: could be a non-token contract address:
o['token_addr'] = TokenAddr(self.proto,etx.creates.hex())
self.disable_fee_check = True
txid = CoinTxID(etx.hash.hex())
assert txid == self.coin_txid,"txid in tx.hex doesn't match value in MMGen transaction file"
self.tx_gas = o['startGas'] # approximate, but better than nothing
self.txobj = o
return d # 'token_addr','decimals' required by Token subclass
async def get_status(self,status=False):
class r(object):
pass
async def is_in_mempool():
if not 'full_node' in self.rpc.caps:
return False
if self.rpc.daemon.id in ('parity','openethereum'):
pool = [x['hash'] for x in await self.rpc.call('parity_pendingTransactions')]
elif self.rpc.daemon.id in ('geth','erigon'):
res = await self.rpc.call('txpool_content')
pool = list(res['pending']) + list(res['queued'])
return '0x'+self.coin_txid in pool
async def is_in_wallet():
d = await self.rpc.call('eth_getTransactionReceipt','0x'+self.coin_txid)
if d and 'blockNumber' in d and d['blockNumber'] is not None:
r.confs = 1 + int(await self.rpc.call('eth_blockNumber'),16) - int(d['blockNumber'],16)
r.exec_status = int(d['status'],16)
return True
return False
if await is_in_mempool():
msg('Transaction is in mempool' if status else 'Warning: transaction is in mempool!')
return
if status:
if await is_in_wallet():
if self.txobj['data']:
cd = capfirst(self.contract_desc)
if r.exec_status == 0:
msg(f'{cd} failed to execute!')
else:
msg(f'{cd} successfully executed with status {r.exec_status}')
die(0,f'Transaction has {r.confs} confirmation{suf(r.confs)}')
die(1,'Transaction is neither in mempool nor blockchain!')
async def send(self,prompt_user=True,exit_on_fail=False):
self.check_correct_chain()
if not self.disable_fee_check and (self.fee > self.proto.max_tx_fee):
die(2,'Transaction fee ({}) greater than {} max_tx_fee ({} {})!'.format(
self.fee,
self.proto.name,
self.proto.max_tx_fee,
self.proto.coin ))
await self.get_status()
if prompt_user:
self.confirm_send()
if g.bogus_send:
ret = None
else:
try:
ret = await self.rpc.call('eth_sendRawTransaction','0x'+self.hex)
except:
raise
ret = False
if ret == False:
msg(red(f'Send of MMGen transaction {self.txid} failed'))
if exit_on_fail:
sys.exit(1)
return False
else:
if g.bogus_send:
m = 'BOGUS transaction NOT sent: {}'
else:
m = 'Transaction sent: {}'
assert ret == '0x'+self.coin_txid,'txid mismatch (after sending)'
if self.proto.network == 'regtest' and g.daemon_id == 'erigon': # ERIGON
import asyncio
await asyncio.sleep(5)
self.desc = 'sent transaction'
msg(m.format(self.coin_txid.hl()))
self.add_timestamp()
self.add_blockcount()
return True
def print_contract_addr(self):
if 'token_addr' in self.txobj:
msg('Contract address: {}'.format(self.txobj['token_addr'].hl()))
class Bump(MMGenTX.Bump,Completed,New):
@property
def min_fee(self):
return ETHAmt(self.fee * Decimal('1.101'))
def bump_fee(self,idx,fee):
self.txobj['gasPrice'] = self.fee_abs2rel(fee,to_unit='eth')
async def get_nonce(self):
return self.txobj['nonce']
class EthereumTokenMMGenTX:
class Base(EthereumMMGenTX.Base):
tx_gas = ETHAmt(52000,'wei')
start_gas = ETHAmt(60000,'wei')
contract_desc = 'token contract'
class New(Base,EthereumMMGenTX.New):
desc = 'transaction'
fee_is_approximate = True
async def make_txobj(self): # called by create_raw()
await super().make_txobj()
t = Token(self.proto,self.tw.token,self.tw.decimals)
o = self.txobj
o['token_addr'] = t.addr
o['decimals'] = t.decimals
o['token_to'] = o['to']
o['data'] = t.create_data(o['token_to'],o['amt'])
def update_change_output(self,funds_left):
if self.outputs[0].is_chg:
self.update_output_amt(0,self.inputs[0].amt)
# token transaction, so check both eth and token balances
# TODO: add test with insufficient funds
async def precheck_sufficient_funds(self,inputs_sum,sel_unspent,outputs_sum):
eth_bal = await self.tw.get_eth_balance(sel_unspent[0].addr)
if eth_bal == 0: # we don't know the fee yet
msg('This account has no ether to pay for the transaction fee!')
return False
return await super().precheck_sufficient_funds(inputs_sum,sel_unspent,outputs_sum)
async def get_funds_left(self,fee,outputs_sum):
return ( await self.tw.get_eth_balance(self.inputs[0].addr) ) - fee
def final_inputs_ok_msg(self,funds_left):
token_bal = (
ETHAmt('0') if self.outputs[0].is_chg
else self.inputs[0].amt - self.outputs[0].amt
)
return "Transaction leaves ≈{} {} and {} {} in the sender's account".format(
funds_left.hl(),
self.proto.coin,
token_bal.hl(),
self.proto.dcoin
)
class Completed(Base,EthereumMMGenTX.Completed):
fmt_keys = ('from','token_to','amt','nonce')
@property
def change(self):
return self.sum_inputs() - self.send_amt
def format_view_rel_fee(self,terse):
return ''
def format_view_body(self,*args,**kwargs):
return 'Token: {d} {c}\n{r}'.format(
d = self.txobj['token_addr'].hl(),
c = blue('(' + self.proto.dcoin + ')'),
r = super().format_view_body(*args,**kwargs ))
class Unsigned(Completed,EthereumMMGenTX.Unsigned):
desc = 'unsigned transaction'
def parse_txfile_hex_data(self):
d = EthereumMMGenTX.Unsigned.parse_txfile_hex_data(self)
o = self.txobj
o['token_addr'] = TokenAddr(self.proto,d['token_addr'])
o['decimals'] = Int(d['decimals'])
t = Token(self.proto,o['token_addr'],o['decimals'])
o['data'] = t.create_data(o['to'],o['amt'])
o['token_to'] = t.transferdata2sendaddr(o['data'])
async def do_sign(self,wif,tx_num_str):
o = self.txobj
t = Token(self.proto,o['token_addr'],o['decimals'])
tx_in = t.make_tx_in(o['from'],o['to'],o['amt'],self.start_gas,o['gasPrice'],nonce=o['nonce'])
(self.hex,self.coin_txid) = await t.txsign(tx_in,wif,o['from'],chain_id=o['chainId'])
assert self.check_sigs(),'Signature check failed'
class Signed(Completed,EthereumMMGenTX.Signed):
desc = 'signed transaction'
def parse_txfile_hex_data(self):
d = EthereumMMGenTX.Signed.parse_txfile_hex_data(self)
o = self.txobj
assert self.tw.token == o['to']
o['token_addr'] = TokenAddr(self.proto,o['to'])
o['decimals'] = self.tw.decimals
t = Token(self.proto,o['token_addr'],o['decimals'])
o['amt'] = t.transferdata2amt(o['data'])
o['token_to'] = t.transferdata2sendaddr(o['data'])
class Bump(EthereumMMGenTX.Bump,Completed,New):
pass