new.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
  4. # Copyright (C)2013-2022 The MMGen Project <mmgen@tuta.io>
  5. # Licensed under the GNU General Public License, Version 3:
  6. # https://www.gnu.org/licenses
  7. # Public project repositories:
  8. # https://github.com/mmgen/mmgen
  9. # https://gitlab.com/mmgen/mmgen
  10. """
  11. proto.eth.tx.new: Ethereum new transaction class
  12. """
  13. import json
  14. import mmgen.tx.new as TxBase
  15. from .base import Base,TokenBase
  16. from ....opts import opt
  17. from ....obj import Int,ETHNonce,MMGenTxID,Str,HexStr
  18. from ....amt import ETHAmt
  19. from ....util import msg,is_int,is_hex_str,make_chksum_6
  20. from ....tw.ctl import TwCtl
  21. from ....addr import is_mmgen_id,is_coin_addr
  22. from ..contract import Token
  23. class New(Base,TxBase.New):
  24. desc = 'transaction'
  25. fee_fail_fs = 'Network fee estimation failed'
  26. no_chg_msg = 'Warning: Transaction leaves account with zero balance'
  27. usr_fee_prompt = 'Enter transaction fee or gas price: '
  28. hexdata_type = 'hex'
  29. def __init__(self,*args,**kwargs):
  30. super().__init__(*args,**kwargs)
  31. if opt.tx_gas:
  32. self.tx_gas = self.start_gas = ETHAmt(int(opt.tx_gas),'wei')
  33. if opt.contract_data:
  34. m = "'--contract-data' option may not be used with token transaction"
  35. assert not 'Token' in type(self).__name__, m
  36. with open(opt.contract_data) as fp:
  37. self.usr_contract_data = HexStr(fp.read().strip())
  38. self.disable_fee_check = True
  39. async def get_nonce(self):
  40. return ETHNonce(int(await self.rpc.call('eth_getTransactionCount','0x'+self.inputs[0].addr,'pending'),16))
  41. async def make_txobj(self): # called by create_serialized()
  42. self.txobj = {
  43. 'from': self.inputs[0].addr,
  44. 'to': self.outputs[0].addr if self.outputs else Str(''),
  45. 'amt': self.outputs[0].amt if self.outputs else ETHAmt('0'),
  46. 'gasPrice': self.fee_abs2rel(self.usr_fee,to_unit='eth'),
  47. 'startGas': self.start_gas,
  48. 'nonce': await self.get_nonce(),
  49. 'chainId': self.rpc.chainID,
  50. 'data': self.usr_contract_data,
  51. }
  52. # Instead of serializing tx data as with BTC, just create a JSON dump.
  53. # This complicates things but means we avoid using the rlp library to deserialize the data,
  54. # thus removing an attack vector
  55. async def create_serialized(self,locktime=None,bump=None):
  56. assert len(self.inputs) == 1,'Transaction has more than one input!'
  57. o_num = len(self.outputs)
  58. o_ok = 0 if self.usr_contract_data else 1
  59. assert o_num == o_ok, f'Transaction has {o_num} output{suf(o_num)} (should have {o_ok})'
  60. await self.make_txobj()
  61. odict = { k: str(v) for k,v in self.txobj.items() if k != 'token_to' }
  62. self.serialized = json.dumps(odict)
  63. self.update_txid()
  64. def update_txid(self):
  65. assert not is_hex_str(self.serialized), (
  66. 'update_txid() must be called only when self.serialized is not hex data' )
  67. self.txid = MMGenTxID(make_chksum_6(self.serialized).upper())
  68. async def process_cmd_args(self,cmd_args,ad_f,ad_w):
  69. lc = len(cmd_args)
  70. if lc == 0 and self.usr_contract_data and not 'Token' in type(self).__name__:
  71. return
  72. if lc != 1:
  73. die(1,f'{lc} output{suf(lc)} specified, but Ethereum transactions must have exactly one')
  74. for a in cmd_args:
  75. await self.process_cmd_arg(a,ad_f,ad_w)
  76. def select_unspent(self,unspent):
  77. from ....ui import line_input
  78. while True:
  79. reply = line_input('Enter an account to spend from: ').strip()
  80. if reply:
  81. if not is_int(reply):
  82. msg('Account number must be an integer')
  83. elif int(reply) < 1:
  84. msg('Account number must be >= 1')
  85. elif int(reply) > len(unspent):
  86. msg(f'Account number must be <= {len(unspent)}')
  87. else:
  88. return [int(reply)]
  89. # get rel_fee (gas price) from network, return in native wei
  90. async def get_rel_fee_from_network(self):
  91. return Int(await self.rpc.call('eth_gasPrice'),16),'eth_gasPrice' # ==> rel_fee,fe_type
  92. def check_fee(self):
  93. if not self.disable_fee_check:
  94. assert self.usr_fee <= self.proto.max_tx_fee
  95. # given rel fee and units, return absolute fee using self.tx_gas
  96. def fee_rel2abs(self,tx_size,units,amt,unit):
  97. return ETHAmt(
  98. ETHAmt(amt,units[unit]).toWei() * self.tx_gas.toWei(),
  99. from_unit='wei'
  100. )
  101. # given fee estimate (gas price) in wei, return absolute fee, adjusting by opt.tx_fee_adj
  102. def fee_est2abs(self,rel_fee,fe_type=None):
  103. ret = self.fee_gasPrice2abs(rel_fee) * opt.tx_fee_adj
  104. if opt.verbose:
  105. msg(f'Estimated fee: {ret} ETH')
  106. return ret
  107. def convert_and_check_fee(self,tx_fee,desc):
  108. abs_fee = self.feespec2abs(tx_fee,None)
  109. if abs_fee == False:
  110. return False
  111. elif not self.disable_fee_check and (abs_fee > self.proto.max_tx_fee):
  112. msg('{} {c}: {} fee too large (maximum fee: {} {c})'.format(
  113. abs_fee.hl(),
  114. desc,
  115. self.proto.max_tx_fee.hl(),
  116. c = self.proto.coin ))
  117. return False
  118. else:
  119. return abs_fee
  120. def update_change_output(self,funds_left):
  121. if self.outputs and self.outputs[0].is_chg:
  122. self.update_output_amt(0,ETHAmt(funds_left))
  123. async def get_input_addrs_from_cmdline(self):
  124. ret = []
  125. if opt.inputs:
  126. data_root = (await TwCtl(self.proto)).data_root # must create new instance here
  127. errmsg = 'Address {!r} not in tracking wallet'
  128. for addr in opt.inputs.split(','):
  129. if is_mmgen_id(self.proto,addr):
  130. for waddr in data_root:
  131. if data_root[waddr]['mmid'] == addr:
  132. ret.append(waddr)
  133. break
  134. else:
  135. die( 'UserAddressNotInWallet', errmsg.format(addr) )
  136. elif is_coin_addr(self.proto,addr):
  137. if not addr in data_root:
  138. die( 'UserAddressNotInWallet', errmsg.format(addr) )
  139. ret.append(addr)
  140. else:
  141. die(1,f'{addr!r}: not an MMGen ID or coin address')
  142. return ret
  143. def final_inputs_ok_msg(self,funds_left):
  144. chg = '0' if (self.outputs and self.outputs[0].is_chg) else funds_left
  145. return 'Transaction leaves {} {} in the sender’s account'.format(
  146. ETHAmt(chg).hl(),
  147. self.proto.coin
  148. )
  149. class TokenNew(TokenBase,New):
  150. desc = 'transaction'
  151. fee_is_approximate = True
  152. async def make_txobj(self): # called by create_serialized()
  153. await super().make_txobj()
  154. t = Token(self.proto,self.twctl.token,self.twctl.decimals)
  155. o = self.txobj
  156. o['token_addr'] = t.addr
  157. o['decimals'] = t.decimals
  158. o['token_to'] = o['to']
  159. o['data'] = t.create_data(o['token_to'],o['amt'])
  160. def update_change_output(self,funds_left):
  161. if self.outputs[0].is_chg:
  162. self.update_output_amt(0,self.inputs[0].amt)
  163. # token transaction, so check both eth and token balances
  164. # TODO: add test with insufficient funds
  165. async def precheck_sufficient_funds(self,inputs_sum,sel_unspent,outputs_sum):
  166. eth_bal = await self.twctl.get_eth_balance(sel_unspent[0].addr)
  167. if eth_bal == 0: # we don't know the fee yet
  168. msg('This account has no ether to pay for the transaction fee!')
  169. return False
  170. return await super().precheck_sufficient_funds(inputs_sum,sel_unspent,outputs_sum)
  171. async def get_funds_left(self,fee,outputs_sum):
  172. return ( await self.twctl.get_eth_balance(self.inputs[0].addr) ) - fee
  173. def final_inputs_ok_msg(self,funds_left):
  174. token_bal = (
  175. ETHAmt('0') if self.outputs[0].is_chg
  176. else self.inputs[0].amt - self.outputs[0].amt
  177. )
  178. return "Transaction leaves ≈{} {} and {} {} in the sender's account".format(
  179. funds_left.hl(),
  180. self.proto.coin,
  181. token_bal.hl(),
  182. self.proto.dcoin
  183. )