new.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
  4. # Copyright (C)2013-2024 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-wallet
  9. # https://gitlab.com/mmgen/mmgen-wallet
  10. """
  11. proto.eth.tx.new: Ethereum new transaction class
  12. """
  13. import json
  14. from ....tx import new as TxBase
  15. from ....obj import Int,ETHNonce,MMGenTxID,Str,HexStr
  16. from ....util import msg,is_int,is_hex_str,make_chksum_6,suf,die
  17. from ....tw.ctl import TwCtl
  18. from ....addr import is_mmgen_id,is_coin_addr
  19. from ..contract import Token
  20. from .base import Base,TokenBase
  21. class New(Base,TxBase.New):
  22. desc = 'transaction'
  23. fee_fail_fs = 'Network fee estimation failed'
  24. no_chg_msg = 'Warning: Transaction leaves account with zero balance'
  25. usr_fee_prompt = 'Enter transaction fee or gas price: '
  26. def __init__(self,*args,**kwargs):
  27. super().__init__(*args,**kwargs)
  28. if self.cfg.gas:
  29. self.gas = self.start_gas = self.proto.coin_amt(int(self.cfg.gas),'wei')
  30. else:
  31. self.gas = self.proto.coin_amt(self.dfl_gas,'wei')
  32. self.start_gas = self.proto.coin_amt(self.dfl_start_gas,'wei')
  33. if self.cfg.contract_data:
  34. m = "'--contract-data' option may not be used with token transaction"
  35. assert 'Token' not in self.name, m
  36. with open(self.cfg.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 self.proto.coin_amt('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 'Token' not in 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. arg = self.parse_cmd_arg(cmd_args[0], ad_f, ad_w)
  75. self.add_output(
  76. coinaddr = arg.coin_addr,
  77. amt = self.proto.coin_amt(arg.amt or '0'),
  78. is_chg = not arg.amt)
  79. def select_unspent(self,unspent):
  80. from ....ui import line_input
  81. while True:
  82. reply = line_input( self.cfg, 'Enter an account to spend from: ' ).strip()
  83. if reply:
  84. if not is_int(reply):
  85. msg('Account number must be an integer')
  86. elif int(reply) < 1:
  87. msg('Account number must be >= 1')
  88. elif int(reply) > len(unspent):
  89. msg(f'Account number must be <= {len(unspent)}')
  90. else:
  91. return [int(reply)]
  92. # get rel_fee (gas price) from network, return in native wei
  93. async def get_rel_fee_from_network(self):
  94. return Int(await self.rpc.call('eth_gasPrice'),16),'eth_gasPrice' # ==> rel_fee,fe_type
  95. def check_fee(self):
  96. if not self.disable_fee_check:
  97. assert self.usr_fee <= self.proto.max_tx_fee
  98. # given rel fee and units, return absolute fee using self.gas
  99. def fee_rel2abs(self,tx_size,units,amt,unit):
  100. return self.proto.coin_amt(
  101. self.proto.coin_amt(amt,units[unit]).toWei() * self.gas.toWei(),
  102. from_unit='wei'
  103. )
  104. # given fee estimate (gas price) in wei, return absolute fee, adjusting by self.cfg.fee_adjust
  105. def fee_est2abs(self,rel_fee,fe_type=None):
  106. ret = self.fee_gasPrice2abs(rel_fee) * self.cfg.fee_adjust
  107. if self.cfg.verbose:
  108. msg(f'Estimated fee: {ret} ETH')
  109. return ret
  110. def convert_and_check_fee(self,fee,desc):
  111. abs_fee = self.feespec2abs(fee,None)
  112. if abs_fee is False:
  113. return False
  114. elif not self.disable_fee_check and (abs_fee > self.proto.max_tx_fee):
  115. msg('{} {c}: {} fee too large (maximum fee: {} {c})'.format(
  116. abs_fee.hl(),
  117. desc,
  118. self.proto.max_tx_fee.hl(),
  119. c = self.proto.coin ))
  120. return False
  121. else:
  122. return abs_fee
  123. def update_change_output(self,funds_left):
  124. if self.outputs and self.outputs[0].is_chg:
  125. self.update_output_amt(0,self.proto.coin_amt(funds_left))
  126. async def get_input_addrs_from_cmdline(self):
  127. ret = []
  128. if self.cfg.inputs:
  129. data_root = (await TwCtl(self.cfg,self.proto)).data_root # must create new instance here
  130. errmsg = 'Address {!r} not in tracking wallet'
  131. for addr in self.cfg.inputs.split(','):
  132. if is_mmgen_id(self.proto,addr):
  133. for waddr in data_root:
  134. if data_root[waddr]['mmid'] == addr:
  135. ret.append(waddr)
  136. break
  137. else:
  138. die( 'UserAddressNotInWallet', errmsg.format(addr) )
  139. elif is_coin_addr(self.proto,addr):
  140. if not addr in data_root:
  141. die( 'UserAddressNotInWallet', errmsg.format(addr) )
  142. ret.append(addr)
  143. else:
  144. die(1,f'{addr!r}: not an MMGen ID or coin address')
  145. return ret
  146. def final_inputs_ok_msg(self,funds_left):
  147. chg = '0' if (self.outputs and self.outputs[0].is_chg) else funds_left
  148. return 'Transaction leaves {} {} in the sender’s account'.format(
  149. self.proto.coin_amt(chg).hl(),
  150. self.proto.coin
  151. )
  152. class TokenNew(TokenBase,New):
  153. desc = 'transaction'
  154. fee_is_approximate = True
  155. async def make_txobj(self): # called by create_serialized()
  156. await super().make_txobj()
  157. t = Token(self.cfg,self.proto,self.twctl.token,self.twctl.decimals)
  158. o = self.txobj
  159. o['token_addr'] = t.addr
  160. o['decimals'] = t.decimals
  161. o['token_to'] = o['to']
  162. o['data'] = t.create_data(o['token_to'],o['amt'])
  163. def update_change_output(self,funds_left):
  164. if self.outputs[0].is_chg:
  165. self.update_output_amt(0,self.inputs[0].amt)
  166. # token transaction, so check both eth and token balances
  167. # TODO: add test with insufficient funds
  168. async def precheck_sufficient_funds(self,inputs_sum,sel_unspent,outputs_sum):
  169. eth_bal = await self.twctl.get_eth_balance(sel_unspent[0].addr)
  170. if eth_bal == 0: # we don't know the fee yet
  171. msg('This account has no ether to pay for the transaction fee!')
  172. return False
  173. return await super().precheck_sufficient_funds(inputs_sum,sel_unspent,outputs_sum)
  174. async def get_funds_left(self,fee,outputs_sum):
  175. return ( await self.twctl.get_eth_balance(self.inputs[0].addr) ) - fee
  176. def final_inputs_ok_msg(self,funds_left):
  177. token_bal = (
  178. self.proto.coin_amt('0') if self.outputs[0].is_chg
  179. else self.inputs[0].amt - self.outputs[0].amt
  180. )
  181. return "Transaction leaves ≈{} {} and {} {} in the sender's account".format(
  182. funds_left.hl(),
  183. self.proto.coin,
  184. token_bal.hl(),
  185. self.proto.dcoin
  186. )