tx.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2019 The MMGen Project <mmgen@tuta.io>
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. """
  19. altcoins.eth.tx: Ethereum transaction classes for the MMGen suite
  20. """
  21. import json
  22. from mmgen.common import *
  23. from mmgen.obj import *
  24. from mmgen.tx import MMGenTX,MMGenBumpTX,MMGenSplitTX,DeserializedTX,mmaddr2coinaddr
  25. class EthereumMMGenTX(MMGenTX):
  26. desc = 'Ethereum transaction'
  27. tx_gas = ETHAmt(21000,'wei') # an approximate number, used for fee estimation purposes
  28. start_gas = ETHAmt(21000,'wei') # the actual startgas amt used in the transaction
  29. # for simple sends with no data, tx_gas = start_gas = 21000
  30. fee_fail_fs = 'Network fee estimation failed'
  31. no_chg_msg = 'Warning: Transaction leaves account with zero balance'
  32. rel_fee_desc = 'gas price'
  33. rel_fee_disp = 'gas price in Gwei'
  34. txview_hdr_fs = 'TRANSACTION DATA\n\nID={i} ({a} {c}) UTC={t} Sig={s} Locktime={l}\n'
  35. txview_hdr_fs_short = 'TX {i} ({a} {c}) UTC={t} Sig={s} Locktime={l}\n'
  36. txview_ftr_fs = 'Total in account: {i} {d}\nTotal to spend: {o} {d}\nTX fee: {a} {c}{r}\n'
  37. txview_ftr_fs_short = 'In {i} {d} - Out {o} {d}\nFee {a} {c}{r}\n'
  38. usr_fee_prompt = 'Enter transaction fee or gas price: '
  39. fn_fee_unit = 'Mwei'
  40. usr_rel_fee = None # not in MMGenTX
  41. disable_fee_check = False
  42. txobj = None # ""
  43. data = HexBytes('')
  44. def __init__(self,*args,**kwargs):
  45. super(EthereumMMGenTX,self).__init__(*args,**kwargs)
  46. if hasattr(opt,'tx_gas') and opt.tx_gas:
  47. self.tx_gas = self.start_gas = ETHAmt(int(opt.tx_gas),'wei')
  48. if hasattr(opt,'contract_data') and opt.contract_data:
  49. m = "'--contract-data' option may not be used with token transaction"
  50. assert not 'Token' in type(self).__name__, m
  51. self.data = HexBytes(open(opt.contract_data).read().strip())
  52. self.disable_fee_check = True
  53. @classmethod
  54. def get_receipt(cls,txid):
  55. return g.rpch.eth_getTransactionReceipt('0x'+txid.decode())
  56. @classmethod
  57. def get_exec_status(cls,txid,silent=False):
  58. d = g.rpch.eth_getTransactionReceipt('0x'+txid.decode())
  59. if not silent:
  60. if 'contractAddress' in d and d['contractAddress']:
  61. msg('Contract address: {}'.format(d['contractAddress'].replace('0x','')))
  62. return int(d['status'],16)
  63. def is_replaceable(self): return True
  64. def get_fee_from_tx(self):
  65. return self.fee
  66. def check_fee(self):
  67. if self.disable_fee_check: return
  68. assert self.fee <= g.proto.max_tx_fee
  69. def get_hex_locktime(self): return None # TODO
  70. def check_pubkey_scripts(self): pass
  71. def check_sigs(self,deserial_tx=None):
  72. if is_hex_bytes(self.hex):
  73. self.mark_signed()
  74. return True
  75. return False
  76. # hex data if signed, json if unsigned: see create_raw()
  77. def check_txfile_hex_data(self):
  78. if type(self.hex) == str: self.hex = self.hex.encode()
  79. if self.check_sigs():
  80. from ethereum.transactions import Transaction
  81. import rlp
  82. etx = rlp.decode(unhexlify(self.hex),Transaction)
  83. d = etx.to_dict() # ==> hex values have '0x' prefix, 0 is '0x'
  84. for k in ('sender','to','data'):
  85. if k in d: d[k] = d[k].replace('0x','',1)
  86. o = { 'from': CoinAddr(d['sender']),
  87. 'to': CoinAddr(d['to']) if d['to'] else Str(''),
  88. 'amt': ETHAmt(d['value'],'wei'),
  89. 'gasPrice': ETHAmt(d['gasprice'],'wei'),
  90. 'startGas': ETHAmt(d['startgas'],'wei'),
  91. 'nonce': ETHNonce(d['nonce']),
  92. 'data': HexBytes(d['data']) }
  93. if o['data'] and not o['to']:
  94. self.token_addr = TokenAddr(hexlify(etx.creates).decode())
  95. txid = CoinTxID(hexlify(etx.hash))
  96. assert txid == self.coin_txid,"txid in tx.hex doesn't match value in MMGen transaction file"
  97. else:
  98. d = json.loads(self.hex.decode())
  99. o = { 'from': CoinAddr(d['from']),
  100. 'to': CoinAddr(d['to']) if d['to'] else Str(''),
  101. 'amt': ETHAmt(d['amt']),
  102. 'gasPrice': ETHAmt(d['gasPrice']),
  103. 'startGas': ETHAmt(d['startGas']),
  104. 'nonce': ETHNonce(d['nonce']),
  105. 'chainId': Int(d['chainId']),
  106. 'data': HexBytes(d['data']) }
  107. self.tx_gas = o['startGas'] # approximate, but better than nothing
  108. self.data = o['data']
  109. if o['data'] and not o['to']: self.disable_fee_check = True
  110. self.fee = self.fee_rel2abs(o['gasPrice'].toWei())
  111. self.txobj = o
  112. return d # 'token_addr','decimals' required by subclass
  113. def get_nonce(self):
  114. return ETHNonce(int(g.rpch.parity_nextNonce('0x'+self.inputs[0].addr),16))
  115. def make_txobj(self): # create_raw
  116. chain_id_method = ('parity_chainId','eth_chainId')['eth_chainId' in g.rpch.caps]
  117. self.txobj = {
  118. 'from': self.inputs[0].addr,
  119. 'to': self.outputs[0].addr if self.outputs else Str(''),
  120. 'amt': self.outputs[0].amt if self.outputs else ETHAmt('0'),
  121. 'gasPrice': self.usr_rel_fee or self.fee_abs2rel(self.fee,to_unit='eth'),
  122. 'startGas': self.start_gas,
  123. 'nonce': self.get_nonce(),
  124. 'chainId': Int(g.rpch.request(chain_id_method),16),
  125. 'data': self.data,
  126. }
  127. # Instead of serializing tx data as with BTC, just create a JSON dump.
  128. # This complicates things but means we avoid using the rlp library to deserialize the data,
  129. # thus removing an attack vector
  130. def create_raw(self):
  131. assert len(self.inputs) == 1,'Transaction has more than one input!'
  132. o_ok = (0,1) if self.data else (1,)
  133. o_num = len(self.outputs)
  134. assert o_num in o_ok,'Transaction has invalid number of outputs!'.format(o_num)
  135. self.make_txobj()
  136. ol = [(k,v.decode() if issubclass(type(v),bytes) else str(v)) for k,v in self.txobj.items()]
  137. self.hex = json.dumps(dict(ol)).encode()
  138. self.update_txid()
  139. def del_output(self,idx): pass
  140. def update_txid(self):
  141. assert not is_hex_str(self.hex),'update_txid() must be called only when self.hex is not hex data'
  142. self.txid = MMGenTxID(make_chksum_6(self.hex).upper())
  143. def get_blockcount(self):
  144. return Int(g.rpch.eth_blockNumber(),16)
  145. def process_cmd_args(self,cmd_args,ad_f,ad_w):
  146. lc = len(cmd_args)
  147. if lc == 0 and self.data and not 'Token' in type(self).__name__: return
  148. if lc != 1:
  149. fs = '{} output{} specified, but Ethereum transactions must have exactly one'
  150. die(1,fs.format(lc,suf(lc)))
  151. for a in cmd_args: self.process_cmd_arg(a,ad_f,ad_w)
  152. def select_unspent(self,unspent):
  153. prompt = 'Enter an account to spend from: '
  154. while True:
  155. reply = my_raw_input(prompt).strip()
  156. if reply:
  157. if not is_int(reply):
  158. msg('Account number must be an integer')
  159. elif int(reply) < 1:
  160. msg('Account number must be >= 1')
  161. elif int(reply) > len(unspent):
  162. msg('Account number must be <= {}'.format(len(unspent)))
  163. else:
  164. return [int(reply)]
  165. # coin-specific fee routines:
  166. def get_relay_fee(self): return ETHAmt('0') # TODO
  167. # given absolute fee in ETH, return gas price in Gwei using tx_gas
  168. def fee_abs2rel(self,abs_fee,to_unit='Gwei'):
  169. ret = ETHAmt(int(abs_fee.toWei() // self.tx_gas.toWei()),'wei')
  170. dmsg('fee_abs2rel() ==> {} ETH'.format(ret))
  171. return ret if to_unit == 'eth' else ret.to_unit(to_unit,show_decimal=True)
  172. # get rel_fee (gas price) from network, return in native wei
  173. def get_rel_fee_from_network(self):
  174. return Int(g.rpch.eth_gasPrice(),16),'eth_gasPrice' # ==> rel_fee,fe_type
  175. # given rel fee and units, return absolute fee using tx_gas
  176. def convert_fee_spec(self,foo,units,amt,unit):
  177. self.usr_rel_fee = ETHAmt(int(amt),units[unit])
  178. return ETHAmt(self.usr_rel_fee.toWei() * self.tx_gas.toWei(),'wei')
  179. # given rel fee in wei, return absolute fee using tx_gas (not in MMGenTX)
  180. def fee_rel2abs(self,rel_fee):
  181. assert type(rel_fee) in (int,Int),"'{}': incorrect type for fee estimate (not an integer)".format(rel_fee)
  182. return ETHAmt(rel_fee * self.tx_gas.toWei(),'wei')
  183. # given fee estimate (gas price) in wei, return absolute fee, adjusting by opt.tx_fee_adj
  184. def fee_est2abs(self,rel_fee,fe_type=None):
  185. ret = self.fee_rel2abs(rel_fee) * opt.tx_fee_adj
  186. if opt.verbose:
  187. msg('Estimated fee: {} ETH'.format(ret))
  188. return ret
  189. def convert_and_check_fee(self,tx_fee,desc='Missing description'):
  190. abs_fee = self.process_fee_spec(tx_fee,None,on_fail='return')
  191. if abs_fee == False:
  192. return False
  193. elif self.disable_fee_check:
  194. return abs_fee
  195. elif abs_fee > g.proto.max_tx_fee:
  196. m = '{} {c}: {} fee too large (maximum fee: {} {c})'
  197. msg(m.format(abs_fee.hl(),desc,g.proto.max_tx_fee.hl(),c=g.coin))
  198. return False
  199. else:
  200. return abs_fee
  201. def update_change_output(self,change_amt):
  202. if self.outputs and self.outputs[0].is_chg:
  203. self.update_output_amt(0,ETHAmt(change_amt))
  204. def update_send_amt(self,foo):
  205. if self.outputs:
  206. self.send_amt = self.outputs[0].amt
  207. def format_view_body(self,blockcount,nonmm_str,max_mmwid,enl,terse):
  208. m = {}
  209. for k in ('in','out'):
  210. if len(getattr(self,k+'puts')):
  211. m[k] = getattr(self,k+'puts')[0].mmid if len(getattr(self,k+'puts')) else ''
  212. m[k] = ' ' + m[k].hl() if m[k] else ' ' + MMGenID.hlc(nonmm_str)
  213. fs = """From: {}{f_mmid}
  214. To: {}{t_mmid}
  215. Amount: {} {c}
  216. Gas price: {g} Gwei
  217. Start gas: {G} Kwei
  218. Nonce: {}
  219. Data: {d}
  220. \n""".replace('\t','')
  221. keys = ('from','to','amt','nonce')
  222. ld = len(self.txobj['data'])
  223. return fs.format( *((self.txobj[k] if self.txobj[k] != '' else Str('None')).hl() for k in keys),
  224. d='{}... ({} bytes)'.format(self.txobj['data'][:40],ld//2) if ld else Str('None'),
  225. c=g.dcoin if len(self.outputs) else '',
  226. g=yellow(str(self.txobj['gasPrice'].to_unit('Gwei',show_decimal=True))),
  227. G=yellow(str(self.txobj['startGas'].toKwei())),
  228. t_mmid=m['out'] if len(self.outputs) else '',
  229. f_mmid=m['in'])
  230. def format_view_abs_fee(self):
  231. fee = self.fee_rel2abs(self.txobj['gasPrice'].toWei())
  232. note = ' (max)' if self.data else ''
  233. return fee.hl() + note
  234. def format_view_rel_fee(self,terse): return ''
  235. def format_view_verbose_footer(self): return '' # TODO
  236. def set_g_token(self):
  237. die(2,"Transaction object mismatch. Have you forgotten to include the '--token' option?")
  238. def final_inputs_ok_msg(self,change_amt):
  239. m = "Transaction leaves {} {} in the sender's account"
  240. chg = '0' if (self.outputs and self.outputs[0].is_chg) else change_amt
  241. return m.format(ETHAmt(chg).hl(),g.coin)
  242. def do_sign(self,d,wif,tx_num_str):
  243. d_in = {'to': unhexlify(d['to']),
  244. 'startgas': d['startGas'].toWei(),
  245. 'gasprice': d['gasPrice'].toWei(),
  246. 'value': d['amt'].toWei() if d['amt'] else 0,
  247. 'nonce': d['nonce'],
  248. 'data': unhexlify(d['data'])}
  249. from ethereum.transactions import Transaction
  250. etx = Transaction(**d_in).sign(wif,d['chainId'])
  251. assert hexlify(etx.sender).decode() == d['from'],(
  252. 'Sender address recovered from signature does not match true sender')
  253. import rlp
  254. self.hex = hexlify(rlp.encode(etx))
  255. self.coin_txid = CoinTxID(hexlify(etx.hash))
  256. if d['data']:
  257. self.token_addr = TokenAddr(hexlify(etx.creates).decode())
  258. assert self.check_sigs(),'Signature check failed'
  259. def sign(self,tx_num_str,keys): # return True or False; don't exit or raise exception
  260. if self.marked_signed():
  261. msg('Transaction is already signed!')
  262. return False
  263. if not self.check_correct_chain(on_fail='return'):
  264. return False
  265. msg_r('Signing transaction{}...'.format(tx_num_str))
  266. try:
  267. self.do_sign(self.txobj,keys[0].sec.wif,tx_num_str)
  268. msg('OK')
  269. return True
  270. except Exception as e:
  271. if os.getenv('MMGEN_TRACEBACK'):
  272. import traceback
  273. ymsg('\n'+''.join(traceback.format_exception(*sys.exc_info())))
  274. m = "{!r}: transaction signing failed!"
  275. msg(m.format(e.args[0]))
  276. return False
  277. def is_in_mempool(self):
  278. return '0x'+self.coin_txid.decode() in [x['hash'] for x in g.rpch.parity_pendingTransactions()]
  279. def is_in_wallet(self):
  280. d = g.rpch.eth_getTransactionReceipt('0x'+self.coin_txid.decode())
  281. if d and 'blockNumber' in d and d['blockNumber'] is not None:
  282. return 1 + int(g.rpch.eth_blockNumber(),16) - int(d['blockNumber'],16)
  283. return False
  284. def get_status(self,status=False):
  285. if self.is_in_mempool():
  286. msg('Transaction is in mempool' if status else 'Warning: transaction is in mempool!')
  287. return
  288. confs = self.is_in_wallet()
  289. if confs is not False:
  290. if self.data:
  291. exec_status = type(self).get_exec_status(self.coin_txid)
  292. if exec_status == 0:
  293. msg('Contract failed to execute!')
  294. else:
  295. msg('Contract successfully executed with status {}'.format(exec_status))
  296. die(0,'Transaction has {} confirmation{}'.format(confs,suf(confs,'s')))
  297. if status:
  298. die(1,'Transaction is neither in mempool nor blockchain!')
  299. def send(self,prompt_user=True,exit_on_fail=False):
  300. if not self.marked_signed():
  301. die(1,'Transaction is not signed!')
  302. self.check_correct_chain(on_fail='die')
  303. bogus_send = os.getenv('MMGEN_BOGUS_SEND')
  304. fee = self.fee_rel2abs(self.txobj['gasPrice'].toWei())
  305. if not self.disable_fee_check and fee > g.proto.max_tx_fee:
  306. die(2,'Transaction fee ({}) greater than {} max_tx_fee ({} {})!'.format(
  307. fee,g.proto.name.capitalize(),g.proto.max_tx_fee,g.coin))
  308. self.get_status()
  309. if prompt_user: self.confirm_send()
  310. ret = None if bogus_send else g.rpch.eth_sendRawTransaction('0x'+self.hex.decode(),on_fail='return')
  311. from mmgen.rpc import rpc_error,rpc_errmsg
  312. if rpc_error(ret):
  313. msg(yellow(rpc_errmsg(ret)))
  314. msg(red('Send of MMGen transaction {} failed'.format(self.txid)))
  315. if exit_on_fail: sys.exit(1)
  316. return False
  317. else:
  318. m = 'BOGUS transaction NOT sent: {}' if bogus_send else 'Transaction sent: {}'
  319. if not bogus_send:
  320. assert ret == '0x'+self.coin_txid.decode(),'txid mismatch (after sending)'
  321. self.desc = 'sent transaction'
  322. msg(m.format(self.coin_txid.hl()))
  323. self.add_timestamp()
  324. self.add_blockcount()
  325. return True
  326. class EthereumTokenMMGenTX(EthereumMMGenTX):
  327. desc = 'Ethereum token transaction'
  328. tx_gas = ETHAmt(52000,'wei')
  329. start_gas = ETHAmt(60000,'wei')
  330. fee_is_approximate = True
  331. def update_change_output(self,change_amt):
  332. if self.outputs[0].is_chg:
  333. self.update_output_amt(0,self.inputs[0].amt)
  334. def check_sufficient_funds(self,inputs_sum,sel_unspent):
  335. eth_bal = ETHAmt(int(g.rpch.eth_getBalance('0x'+sel_unspent[0].addr),16),'wei')
  336. if eth_bal == 0: # we don't know the fee yet
  337. msg('This account has no ether to pay for the transaction fee!')
  338. return False
  339. if self.send_amt > inputs_sum:
  340. msg(self.msg_low_coin.format(self.send_amt-inputs_sum,g.dcoin))
  341. return False
  342. return True
  343. def final_inputs_ok_msg(self,change_amt):
  344. m = "Transaction leaves ≈{} {} and {} {} in the sender's account"
  345. if self.outputs[0].is_chg:
  346. send_acct_tbal = '0'
  347. else:
  348. from mmgen.altcoins.eth.contract import Token
  349. send_acct_tbal = Token(g.token).balance(self.inputs[0].addr) - self.outputs[0].amt
  350. return m.format(ETHAmt(change_amt).hl(),g.coin,ETHAmt(send_acct_tbal).hl(),g.dcoin)
  351. def get_change_amt(self): # here we know the fee
  352. eth_bal = ETHAmt(int(g.rpch.eth_getBalance('0x'+self.inputs[0].addr),16),'wei')
  353. return eth_bal - self.fee
  354. def set_g_token(self):
  355. g.dcoin = self.dcoin
  356. if is_hex_bytes(self.hex): return # for txsend we can leave g.token uninitialized
  357. d = json.loads(self.hex.decode())
  358. if g.token.upper() == self.dcoin:
  359. g.token = d['token_addr']
  360. elif g.token != d['token_addr']:
  361. m1 = "'{p}': invalid --token parameter for {t} Ethereum token transaction file\n"
  362. m2 = "Please use '--token={t}'"
  363. die(1,(m1+m2).format(p=g.token,t=self.dcoin))
  364. def make_txobj(self):
  365. super(EthereumTokenMMGenTX,self).make_txobj()
  366. from mmgen.altcoins.eth.contract import Token
  367. t = Token(g.token)
  368. o = t.txcreate( self.inputs[0].addr,
  369. self.outputs[0].addr,
  370. (self.inputs[0].amt if self.outputs[0].is_chg else self.outputs[0].amt),
  371. self.start_gas,
  372. self.usr_rel_fee or self.fee_abs2rel(self.fee,to_unit='eth'))
  373. self.txobj['token_addr'] = self.token_addr = t.addr
  374. self.txobj['decimals'] = t.decimals()
  375. def check_txfile_hex_data(self):
  376. d = super(EthereumTokenMMGenTX,self).check_txfile_hex_data()
  377. o = self.txobj
  378. from mmgen.altcoins.eth.contract import Token
  379. if self.check_sigs(): # online, from rlp
  380. rpc_init()
  381. o['token_addr'] = TokenAddr(o['to'])
  382. o['amt'] = Token(o['token_addr']).transferdata2amt(o['data'])
  383. else: # offline, from json
  384. o['token_addr'] = TokenAddr(d['token_addr'])
  385. o['decimals'] = Int(d['decimals'])
  386. t = Token(o['token_addr'],o['decimals'])
  387. self.data = o['data'] = t.create_data(o['to'],o['amt'])
  388. def format_view_body(self,*args,**kwargs):
  389. return 'Token: {d} {c}\n{r}'.format(
  390. d=self.txobj['token_addr'].hl(),
  391. c=blue('(' + g.dcoin + ')'),
  392. r=super(EthereumTokenMMGenTX,self).format_view_body(*args,**kwargs))
  393. def do_sign(self,d,wif,tx_num_str):
  394. from mmgen.altcoins.eth.contract import Token
  395. d = self.txobj
  396. t = Token(d['token_addr'],decimals=d['decimals'])
  397. tx_in = t.txcreate(d['from'],d['to'],d['amt'],self.start_gas,d['gasPrice'],nonce=d['nonce'])
  398. (self.hex,self.coin_txid) = t.txsign(tx_in,wif,d['from'],chain_id=d['chainId'])
  399. assert self.check_sigs(),'Signature check failed'
  400. class EthereumMMGenBumpTX(EthereumMMGenTX,MMGenBumpTX):
  401. def choose_output(self): pass
  402. def set_min_fee(self):
  403. self.min_fee = ETHAmt(self.fee * Decimal('1.101'))
  404. def update_fee(self,foo,fee):
  405. self.fee = fee
  406. def get_nonce(self):
  407. return self.txobj['nonce']
  408. class EthereumTokenMMGenBumpTX(EthereumTokenMMGenTX,EthereumMMGenBumpTX): pass
  409. class EthereumMMGenSplitTX(MMGenSplitTX): pass
  410. class EthereumDeserializedTX(DeserializedTX): pass