tx.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2022 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 collections import namedtuple
  23. from decimal import Decimal
  24. from mmgen.globalvars import g
  25. from mmgen.color import red,yellow,blue,pink
  26. from mmgen.opts import opt
  27. from mmgen.util import msg,msg_r,ymsg,dmsg,fmt,line_input,is_int,is_hex_str,make_chksum_6,die,suf,capfirst,pp_fmt
  28. from mmgen.exception import TransactionChainMismatch
  29. from mmgen.obj import Int,Str,HexStr,CoinTxID,MMGenTxID
  30. from mmgen.addr import MMGenID,CoinAddr,TokenAddr,is_mmgen_id,is_coin_addr
  31. from mmgen.tx import MMGenTX
  32. from mmgen.tw import TrackingWallet
  33. from .contract import Token
  34. from .obj import ETHAmt,ETHNonce
  35. class EthereumMMGenTX:
  36. class Base(MMGenTX.Base):
  37. rel_fee_desc = 'gas price'
  38. rel_fee_disp = 'gas price in Gwei'
  39. txobj = None # ""
  40. tx_gas = ETHAmt(21000,'wei') # an approximate number, used for fee estimation purposes
  41. start_gas = ETHAmt(21000,'wei') # the actual startgas amt used in the transaction
  42. # for simple sends with no data, tx_gas = start_gas = 21000
  43. contract_desc = 'contract'
  44. usr_contract_data = HexStr('')
  45. disable_fee_check = False
  46. # given absolute fee in ETH, return gas price in Gwei using tx_gas
  47. def fee_abs2rel(self,abs_fee,to_unit='Gwei'):
  48. ret = ETHAmt(int(abs_fee.toWei() // self.tx_gas.toWei()),'wei')
  49. dmsg(f'fee_abs2rel() ==> {ret} ETH')
  50. return ret if to_unit == 'eth' else ret.to_unit(to_unit,show_decimal=True)
  51. def get_hex_locktime(self):
  52. return None # TODO
  53. # given rel fee (gasPrice) in wei, return absolute fee using tx_gas (not in MMGenTX)
  54. def fee_gasPrice2abs(self,rel_fee):
  55. assert isinstance(rel_fee,int), f'{rel_fee!r}: incorrect type for fee estimate (not an integer)'
  56. return ETHAmt(rel_fee * self.tx_gas.toWei(),'wei')
  57. def is_replaceable(self):
  58. return True
  59. async def get_receipt(self,txid,silent=False):
  60. rx = await self.rpc.call('eth_getTransactionReceipt','0x'+txid) # -> null if pending
  61. if not rx:
  62. return None
  63. tx = await self.rpc.call('eth_getTransactionByHash','0x'+txid)
  64. return namedtuple('exec_status',['status','gas_sent','gas_used','gas_price','contract_addr','tx','rx'])(
  65. status = Int(rx['status'],16), # zero is failure, non-zero success
  66. gas_sent = Int(tx['gas'],16),
  67. gas_used = Int(rx['gasUsed'],16),
  68. gas_price = ETHAmt(int(tx['gasPrice'],16),from_unit='wei'),
  69. contract_addr = self.proto.coin_addr(rx['contractAddress'][2:]) if rx['contractAddress'] else None,
  70. tx = tx,
  71. rx = rx,
  72. )
  73. class New(Base,MMGenTX.New):
  74. hexdata_type = 'hex'
  75. desc = 'transaction'
  76. fee_fail_fs = 'Network fee estimation failed'
  77. no_chg_msg = 'Warning: Transaction leaves account with zero balance'
  78. usr_fee_prompt = 'Enter transaction fee or gas price: '
  79. def __init__(self,*args,**kwargs):
  80. MMGenTX.New.__init__(self,*args,**kwargs)
  81. if getattr(opt,'tx_gas',None):
  82. self.tx_gas = self.start_gas = ETHAmt(int(opt.tx_gas),'wei')
  83. if getattr(opt,'contract_data',None):
  84. m = "'--contract-data' option may not be used with token transaction"
  85. assert not 'Token' in type(self).__name__, m
  86. with open(opt.contract_data) as fp:
  87. self.usr_contract_data = HexStr(fp.read().strip())
  88. self.disable_fee_check = True
  89. async def get_nonce(self):
  90. return ETHNonce(int(await self.rpc.call('eth_getTransactionCount','0x'+self.inputs[0].addr,'pending'),16))
  91. async def make_txobj(self): # called by create_raw()
  92. self.txobj = {
  93. 'from': self.inputs[0].addr,
  94. 'to': self.outputs[0].addr if self.outputs else Str(''),
  95. 'amt': self.outputs[0].amt if self.outputs else ETHAmt('0'),
  96. 'gasPrice': self.fee_abs2rel(self.usr_fee,to_unit='eth'),
  97. 'startGas': self.start_gas,
  98. 'nonce': await self.get_nonce(),
  99. 'chainId': self.rpc.chainID,
  100. 'data': self.usr_contract_data,
  101. }
  102. # Instead of serializing tx data as with BTC, just create a JSON dump.
  103. # This complicates things but means we avoid using the rlp library to deserialize the data,
  104. # thus removing an attack vector
  105. async def create_raw(self):
  106. assert len(self.inputs) == 1,'Transaction has more than one input!'
  107. o_num = len(self.outputs)
  108. o_ok = 0 if self.usr_contract_data else 1
  109. assert o_num == o_ok, f'Transaction has {o_num} output{suf(o_num)} (should have {o_ok})'
  110. await self.make_txobj()
  111. odict = { k: str(v) for k,v in self.txobj.items() if k != 'token_to' }
  112. self.hex = json.dumps(odict)
  113. self.update_txid()
  114. def update_txid(self):
  115. assert not is_hex_str(self.hex),'update_txid() must be called only when self.hex is not hex data'
  116. self.txid = MMGenTxID(make_chksum_6(self.hex).upper())
  117. def del_output(self,idx):
  118. pass
  119. def process_cmd_args(self,cmd_args,ad_f,ad_w):
  120. lc = len(cmd_args)
  121. if lc == 0 and self.usr_contract_data and not 'Token' in type(self).__name__:
  122. return
  123. if lc != 1:
  124. die(1,f'{lc} output{suf(lc)} specified, but Ethereum transactions must have exactly one')
  125. for a in cmd_args:
  126. self.process_cmd_arg(a,ad_f,ad_w)
  127. def select_unspent(self,unspent):
  128. while True:
  129. reply = line_input('Enter an account to spend from: ').strip()
  130. if reply:
  131. if not is_int(reply):
  132. msg('Account number must be an integer')
  133. elif int(reply) < 1:
  134. msg('Account number must be >= 1')
  135. elif int(reply) > len(unspent):
  136. msg(f'Account number must be <= {len(unspent)}')
  137. else:
  138. return [int(reply)]
  139. # coin-specific fee routines:
  140. @property
  141. def relay_fee(self):
  142. return ETHAmt('0') # TODO
  143. # get rel_fee (gas price) from network, return in native wei
  144. async def get_rel_fee_from_network(self):
  145. return Int(await self.rpc.call('eth_gasPrice'),16),'eth_gasPrice' # ==> rel_fee,fe_type
  146. def check_fee(self):
  147. if not self.disable_fee_check:
  148. assert self.usr_fee <= self.proto.max_tx_fee
  149. # given rel fee and units, return absolute fee using tx_gas
  150. def fee_rel2abs(self,tx_size,units,amt,unit):
  151. return ETHAmt(
  152. ETHAmt(amt,units[unit]).toWei() * self.tx_gas.toWei(),
  153. from_unit='wei'
  154. )
  155. # given fee estimate (gas price) in wei, return absolute fee, adjusting by opt.tx_fee_adj
  156. def fee_est2abs(self,rel_fee,fe_type=None):
  157. ret = self.fee_gasPrice2abs(rel_fee) * opt.tx_fee_adj
  158. if opt.verbose:
  159. msg(f'Estimated fee: {ret} ETH')
  160. return ret
  161. def convert_and_check_fee(self,tx_fee,desc='Missing description'):
  162. abs_fee = self.feespec2abs(tx_fee,None)
  163. if abs_fee == False:
  164. return False
  165. elif not self.disable_fee_check and (abs_fee > self.proto.max_tx_fee):
  166. msg('{} {c}: {} fee too large (maximum fee: {} {c})'.format(
  167. abs_fee.hl(),
  168. desc,
  169. self.proto.max_tx_fee.hl(),
  170. c = self.proto.coin ))
  171. return False
  172. else:
  173. return abs_fee
  174. def update_change_output(self,funds_left):
  175. if self.outputs and self.outputs[0].is_chg:
  176. self.update_output_amt(0,ETHAmt(funds_left))
  177. async def get_cmdline_input_addrs(self):
  178. ret = []
  179. if opt.inputs:
  180. r = (await TrackingWallet(self.proto)).data_root # must create new instance here
  181. m = 'Address {!r} not in tracking wallet'
  182. for i in opt.inputs.split(','):
  183. if is_mmgen_id(self.proto,i):
  184. for addr in r:
  185. if r[addr]['mmid'] == i:
  186. ret.append(addr)
  187. break
  188. else:
  189. raise UserAddressNotInWallet(m.format(i))
  190. elif is_coin_addr(self.proto,i):
  191. if not i in r:
  192. raise UserAddressNotInWallet(m.format(i))
  193. ret.append(i)
  194. else:
  195. die(1,f'{i!r}: not an MMGen ID or coin address')
  196. return ret
  197. def final_inputs_ok_msg(self,funds_left):
  198. chg = '0' if (self.outputs and self.outputs[0].is_chg) else funds_left
  199. return 'Transaction leaves {} {} in the sender’s account'.format(
  200. ETHAmt(chg).hl(),
  201. self.proto.coin
  202. )
  203. class Completed(Base,MMGenTX.Completed):
  204. fn_fee_unit = 'Mwei'
  205. txview_hdr_fs = 'TRANSACTION DATA\n\nID={i} ({a} {c}) UTC={t} Sig={s} Locktime={l}\n'
  206. txview_hdr_fs_short = 'TX {i} ({a} {c}) UTC={t} Sig={s} Locktime={l}\n'
  207. txview_ftr_fs = fmt("""
  208. Total in account: {i} {d}
  209. Total to spend: {o} {d}
  210. Remaining balance: {C} {d}
  211. TX fee: {a} {c}{r}
  212. """)
  213. fmt_keys = ('from','to','amt','nonce')
  214. @property
  215. def send_amt(self):
  216. return self.outputs[0].amt if self.outputs else self.proto.coin_amt('0')
  217. @property
  218. def fee(self):
  219. return self.fee_gasPrice2abs(self.txobj['gasPrice'].toWei())
  220. @property
  221. def change(self):
  222. return self.sum_inputs() - self.send_amt - self.fee
  223. def check_txfile_hex_data(self):
  224. pass
  225. def format_view_body(self,blockcount,nonmm_str,max_mmwid,enl,terse,sort):
  226. m = {}
  227. for k in ('inputs','outputs'):
  228. if len(getattr(self,k)):
  229. m[k] = getattr(self,k)[0].mmid if len(getattr(self,k)) else ''
  230. m[k] = ' ' + m[k].hl() if m[k] else ' ' + MMGenID.hlc(nonmm_str)
  231. fs = """From: {}{f_mmid}
  232. To: {}{t_mmid}
  233. Amount: {} {c}
  234. Gas price: {g} Gwei
  235. Start gas: {G} Kwei
  236. Nonce: {}
  237. Data: {d}
  238. \n""".replace('\t','')
  239. t = self.txobj
  240. td = t['data']
  241. from mmgen.color import yellow
  242. return fs.format(
  243. *((t[k] if t[k] != '' else Str('None')).hl() for k in self.fmt_keys),
  244. d = '{}... ({} bytes)'.format(td[:40],len(td)//2) if len(td) else Str('None'),
  245. c = self.proto.dcoin if len(self.outputs) else '',
  246. g = yellow(str(t['gasPrice'].to_unit('Gwei',show_decimal=True))),
  247. G = yellow(str(t['startGas'].to_unit('Kwei'))),
  248. t_mmid = m['outputs'] if len(self.outputs) else '',
  249. f_mmid = m['inputs'] )
  250. def format_view_abs_fee(self):
  251. return self.fee.hl() + (' (max)' if self.txobj['data'] else '')
  252. def format_view_rel_fee(self,terse):
  253. return ' ({} of spend amount)'.format(
  254. pink('{:0.6f}%'.format( self.fee / self.send_amt * 100 ))
  255. )
  256. def format_view_verbose_footer(self):
  257. if self.txobj['data']:
  258. from .contract import parse_abi
  259. return '\nParsed contract data: ' + pp_fmt(parse_abi(self.txobj['data']))
  260. else:
  261. return ''
  262. def check_sigs(self,deserial_tx=None): # TODO
  263. if is_hex_str(self.hex):
  264. return True
  265. return False
  266. def check_pubkey_scripts(self):
  267. pass
  268. class Unsigned(Completed,MMGenTX.Unsigned):
  269. hexdata_type = 'json'
  270. desc = 'unsigned transaction'
  271. def parse_txfile_hex_data(self):
  272. d = json.loads(self.hex)
  273. o = {
  274. 'from': CoinAddr(self.proto,d['from']),
  275. # NB: for token, 'to' is sendto address
  276. 'to': CoinAddr(self.proto,d['to']) if d['to'] else Str(''),
  277. 'amt': ETHAmt(d['amt']),
  278. 'gasPrice': ETHAmt(d['gasPrice']),
  279. 'startGas': ETHAmt(d['startGas']),
  280. 'nonce': ETHNonce(d['nonce']),
  281. 'chainId': None if d['chainId'] == 'None' else Int(d['chainId']),
  282. 'data': HexStr(d['data']) }
  283. self.tx_gas = o['startGas'] # approximate, but better than nothing
  284. self.txobj = o
  285. return d # 'token_addr','decimals' required by Token subclass
  286. async def do_sign(self,wif,tx_num_str):
  287. o = self.txobj
  288. o_conv = {
  289. 'to': bytes.fromhex(o['to']),
  290. 'startgas': o['startGas'].toWei(),
  291. 'gasprice': o['gasPrice'].toWei(),
  292. 'value': o['amt'].toWei() if o['amt'] else 0,
  293. 'nonce': o['nonce'],
  294. 'data': bytes.fromhex(o['data']) }
  295. from .pyethereum.transactions import Transaction
  296. etx = Transaction(**o_conv).sign(wif,o['chainId'])
  297. assert etx.sender.hex() == o['from'],(
  298. 'Sender address recovered from signature does not match true sender')
  299. from . import rlp
  300. self.hex = rlp.encode(etx).hex()
  301. self.coin_txid = CoinTxID(etx.hash.hex())
  302. if o['data']:
  303. if o['to']:
  304. assert self.txobj['token_addr'] == TokenAddr(etx.creates.hex()),'Token address mismatch'
  305. else: # token- or contract-creating transaction
  306. self.txobj['token_addr'] = TokenAddr(self.proto,etx.creates.hex())
  307. assert self.check_sigs(),'Signature check failed'
  308. async def sign(self,tx_num_str,keys): # return TX object or False; don't exit or raise exception
  309. try:
  310. self.check_correct_chain()
  311. except TransactionChainMismatch:
  312. return False
  313. msg_r(f'Signing transaction{tx_num_str}...')
  314. try:
  315. await self.do_sign(keys[0].sec.wif,tx_num_str)
  316. msg('OK')
  317. return MMGenTX.Signed(data=self.__dict__)
  318. except Exception as e:
  319. msg("{e!s}: transaction signing failed!")
  320. if g.traceback:
  321. import traceback
  322. ymsg('\n'+''.join(traceback.format_exception(*sys.exc_info())))
  323. return False
  324. class Signed(Completed,MMGenTX.Signed):
  325. desc = 'signed transaction'
  326. def parse_txfile_hex_data(self):
  327. from .pyethereum.transactions import Transaction
  328. from . import rlp
  329. etx = rlp.decode(bytes.fromhex(self.hex),Transaction)
  330. d = etx.to_dict() # ==> hex values have '0x' prefix, 0 is '0x'
  331. for k in ('sender','to','data'):
  332. if k in d:
  333. d[k] = d[k].replace('0x','',1)
  334. o = {
  335. 'from': CoinAddr(self.proto,d['sender']),
  336. # NB: for token, 'to' is token address
  337. 'to': CoinAddr(self.proto,d['to']) if d['to'] else Str(''),
  338. 'amt': ETHAmt(d['value'],'wei'),
  339. 'gasPrice': ETHAmt(d['gasprice'],'wei'),
  340. 'startGas': ETHAmt(d['startgas'],'wei'),
  341. 'nonce': ETHNonce(d['nonce']),
  342. 'data': HexStr(d['data']) }
  343. if o['data'] and not o['to']: # token- or contract-creating transaction
  344. # NB: could be a non-token contract address:
  345. o['token_addr'] = TokenAddr(self.proto,etx.creates.hex())
  346. self.disable_fee_check = True
  347. txid = CoinTxID(etx.hash.hex())
  348. assert txid == self.coin_txid,"txid in tx.hex doesn't match value in MMGen transaction file"
  349. self.tx_gas = o['startGas'] # approximate, but better than nothing
  350. self.txobj = o
  351. return d # 'token_addr','decimals' required by Token subclass
  352. async def get_status(self,status=False):
  353. class r(object):
  354. pass
  355. async def is_in_mempool():
  356. if not 'full_node' in self.rpc.caps:
  357. return False
  358. if self.rpc.daemon.id in ('parity','openethereum'):
  359. pool = [x['hash'] for x in await self.rpc.call('parity_pendingTransactions')]
  360. elif self.rpc.daemon.id in ('geth','erigon'):
  361. res = await self.rpc.call('txpool_content')
  362. pool = list(res['pending']) + list(res['queued'])
  363. return '0x'+self.coin_txid in pool
  364. async def is_in_wallet():
  365. d = await self.rpc.call('eth_getTransactionReceipt','0x'+self.coin_txid)
  366. if d and 'blockNumber' in d and d['blockNumber'] is not None:
  367. r.confs = 1 + int(await self.rpc.call('eth_blockNumber'),16) - int(d['blockNumber'],16)
  368. r.exec_status = int(d['status'],16)
  369. return True
  370. return False
  371. if await is_in_mempool():
  372. msg('Transaction is in mempool' if status else 'Warning: transaction is in mempool!')
  373. return
  374. if status:
  375. if await is_in_wallet():
  376. if self.txobj['data']:
  377. cd = capfirst(self.contract_desc)
  378. if r.exec_status == 0:
  379. msg(f'{cd} failed to execute!')
  380. else:
  381. msg(f'{cd} successfully executed with status {r.exec_status}')
  382. die(0,f'Transaction has {r.confs} confirmation{suf(r.confs)}')
  383. die(1,'Transaction is neither in mempool nor blockchain!')
  384. async def send(self,prompt_user=True,exit_on_fail=False):
  385. self.check_correct_chain()
  386. if not self.disable_fee_check and (self.fee > self.proto.max_tx_fee):
  387. die(2,'Transaction fee ({}) greater than {} max_tx_fee ({} {})!'.format(
  388. self.fee,
  389. self.proto.name,
  390. self.proto.max_tx_fee,
  391. self.proto.coin ))
  392. await self.get_status()
  393. if prompt_user:
  394. self.confirm_send()
  395. if g.bogus_send:
  396. ret = None
  397. else:
  398. try:
  399. ret = await self.rpc.call('eth_sendRawTransaction','0x'+self.hex)
  400. except:
  401. raise
  402. ret = False
  403. if ret == False:
  404. msg(red(f'Send of MMGen transaction {self.txid} failed'))
  405. if exit_on_fail:
  406. sys.exit(1)
  407. return False
  408. else:
  409. if g.bogus_send:
  410. m = 'BOGUS transaction NOT sent: {}'
  411. else:
  412. m = 'Transaction sent: {}'
  413. assert ret == '0x'+self.coin_txid,'txid mismatch (after sending)'
  414. if self.proto.network == 'regtest' and g.daemon_id == 'erigon': # ERIGON
  415. import asyncio
  416. await asyncio.sleep(5)
  417. self.desc = 'sent transaction'
  418. msg(m.format(self.coin_txid.hl()))
  419. self.add_timestamp()
  420. self.add_blockcount()
  421. return True
  422. def print_contract_addr(self):
  423. if 'token_addr' in self.txobj:
  424. msg('Contract address: {}'.format(self.txobj['token_addr'].hl()))
  425. class Bump(MMGenTX.Bump,Completed,New):
  426. @property
  427. def min_fee(self):
  428. return ETHAmt(self.fee * Decimal('1.101'))
  429. def bump_fee(self,idx,fee):
  430. self.txobj['gasPrice'] = self.fee_abs2rel(fee,to_unit='eth')
  431. async def get_nonce(self):
  432. return self.txobj['nonce']
  433. class EthereumTokenMMGenTX:
  434. class Base(EthereumMMGenTX.Base):
  435. tx_gas = ETHAmt(52000,'wei')
  436. start_gas = ETHAmt(60000,'wei')
  437. contract_desc = 'token contract'
  438. class New(Base,EthereumMMGenTX.New):
  439. desc = 'transaction'
  440. fee_is_approximate = True
  441. async def make_txobj(self): # called by create_raw()
  442. await super().make_txobj()
  443. t = Token(self.proto,self.tw.token,self.tw.decimals)
  444. o = self.txobj
  445. o['token_addr'] = t.addr
  446. o['decimals'] = t.decimals
  447. o['token_to'] = o['to']
  448. o['data'] = t.create_data(o['token_to'],o['amt'])
  449. def update_change_output(self,funds_left):
  450. if self.outputs[0].is_chg:
  451. self.update_output_amt(0,self.inputs[0].amt)
  452. # token transaction, so check both eth and token balances
  453. # TODO: add test with insufficient funds
  454. async def precheck_sufficient_funds(self,inputs_sum,sel_unspent,outputs_sum):
  455. eth_bal = await self.tw.get_eth_balance(sel_unspent[0].addr)
  456. if eth_bal == 0: # we don't know the fee yet
  457. msg('This account has no ether to pay for the transaction fee!')
  458. return False
  459. return await super().precheck_sufficient_funds(inputs_sum,sel_unspent,outputs_sum)
  460. async def get_funds_left(self,fee,outputs_sum):
  461. return ( await self.tw.get_eth_balance(self.inputs[0].addr) ) - fee
  462. def final_inputs_ok_msg(self,funds_left):
  463. token_bal = (
  464. ETHAmt('0') if self.outputs[0].is_chg
  465. else self.inputs[0].amt - self.outputs[0].amt
  466. )
  467. return "Transaction leaves ≈{} {} and {} {} in the sender's account".format(
  468. funds_left.hl(),
  469. self.proto.coin,
  470. token_bal.hl(),
  471. self.proto.dcoin
  472. )
  473. class Completed(Base,EthereumMMGenTX.Completed):
  474. fmt_keys = ('from','token_to','amt','nonce')
  475. @property
  476. def change(self):
  477. return self.sum_inputs() - self.send_amt
  478. def format_view_rel_fee(self,terse):
  479. return ''
  480. def format_view_body(self,*args,**kwargs):
  481. return 'Token: {d} {c}\n{r}'.format(
  482. d = self.txobj['token_addr'].hl(),
  483. c = blue('(' + self.proto.dcoin + ')'),
  484. r = super().format_view_body(*args,**kwargs ))
  485. class Unsigned(Completed,EthereumMMGenTX.Unsigned):
  486. desc = 'unsigned transaction'
  487. def parse_txfile_hex_data(self):
  488. d = EthereumMMGenTX.Unsigned.parse_txfile_hex_data(self)
  489. o = self.txobj
  490. o['token_addr'] = TokenAddr(self.proto,d['token_addr'])
  491. o['decimals'] = Int(d['decimals'])
  492. t = Token(self.proto,o['token_addr'],o['decimals'])
  493. o['data'] = t.create_data(o['to'],o['amt'])
  494. o['token_to'] = t.transferdata2sendaddr(o['data'])
  495. async def do_sign(self,wif,tx_num_str):
  496. o = self.txobj
  497. t = Token(self.proto,o['token_addr'],o['decimals'])
  498. tx_in = t.make_tx_in(o['from'],o['to'],o['amt'],self.start_gas,o['gasPrice'],nonce=o['nonce'])
  499. (self.hex,self.coin_txid) = await t.txsign(tx_in,wif,o['from'],chain_id=o['chainId'])
  500. assert self.check_sigs(),'Signature check failed'
  501. class Signed(Completed,EthereumMMGenTX.Signed):
  502. desc = 'signed transaction'
  503. def parse_txfile_hex_data(self):
  504. d = EthereumMMGenTX.Signed.parse_txfile_hex_data(self)
  505. o = self.txobj
  506. assert self.tw.token == o['to']
  507. o['token_addr'] = TokenAddr(self.proto,o['to'])
  508. o['decimals'] = self.tw.decimals
  509. t = Token(self.proto,o['token_addr'],o['decimals'])
  510. o['amt'] = t.transferdata2amt(o['data'])
  511. o['token_to'] = t.transferdata2sendaddr(o['data'])
  512. class Bump(EthereumMMGenTX.Bump,Completed,New):
  513. pass