new.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. #!/usr/bin/env python3
  2. #
  3. # MMGen Wallet, a terminal-based cryptocurrency wallet
  4. # Copyright (C)2013-2025 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
  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. msg_insufficient_funds = 'Account balance insufficient to fund this transaction ({} {} needed)'
  27. byte_cost = 68 # https://ethereum.stackexchange.com/questions/39401/
  28. # how-do-you-calculate-gas-limit-for-transaction-with-data-in-ethereum
  29. def __init__(self, *args, **kwargs):
  30. super().__init__(*args, **kwargs)
  31. if self.cfg.gas:
  32. self.gas = self.proto.coin_amt(int(self.cfg.gas), from_unit='wei')
  33. else:
  34. self.gas = self.proto.coin_amt(self.dfl_gas, from_unit='wei')
  35. if self.cfg.contract_data:
  36. m = "'--contract-data' option may not be used with token transaction"
  37. assert 'Token' not in self.name, m
  38. with open(self.cfg.contract_data) as fp:
  39. self.usr_contract_data = bytes.fromhex(fp.read().strip())
  40. self.disable_fee_check = True
  41. async def get_nonce(self):
  42. return ETHNonce(int(
  43. await self.rpc.call('eth_getTransactionCount', '0x'+self.inputs[0].addr, 'pending'), 16))
  44. async def make_txobj(self): # called by create_serialized()
  45. self.txobj = {
  46. 'from': self.inputs[0].addr,
  47. 'to': self.outputs[0].addr if self.outputs else None,
  48. 'amt': self.outputs[0].amt if self.outputs else self.proto.coin_amt('0'),
  49. 'gasPrice': self.fee_abs2gasprice(self.usr_fee),
  50. 'startGas': self.gas,
  51. 'nonce': await self.get_nonce(),
  52. 'chainId': self.rpc.chainID,
  53. 'data': self.usr_contract_data.hex()}
  54. # Instead of serializing tx data as with BTC, just create a JSON dump.
  55. # This complicates things but means we avoid using the rlp library to deserialize the data,
  56. # thus removing an attack vector
  57. async def create_serialized(self, *, locktime=None):
  58. assert len(self.inputs) == 1, 'Transaction has more than one input!'
  59. o_num = len(self.outputs)
  60. o_ok = 0 if self.usr_contract_data and not self.is_swap else 1
  61. assert o_num == o_ok, f'Transaction has {o_num} output{suf(o_num)} (should have {o_ok})'
  62. await self.make_txobj()
  63. odict = {k:v if v is None else str(v) for k, v in self.txobj.items() if k != 'token_to'}
  64. self.serialized = json.dumps(odict)
  65. self.update_txid()
  66. def update_txid(self):
  67. assert not is_hex_str(self.serialized), (
  68. 'update_txid() must be called only when self.serialized is not hex data')
  69. self.txid = MMGenTxID(make_chksum_6(self.serialized).upper())
  70. def set_gas_with_data(self, data):
  71. self.gas = self.proto.coin_amt(self.dfl_gas + self.byte_cost * len(data), from_unit='wei')
  72. # one-shot method
  73. def adj_gas_with_extra_data_len(self, extra_data_len):
  74. if not hasattr(self, '_gas_adjusted'):
  75. self.gas += self.proto.coin_amt(self.byte_cost * extra_data_len, from_unit='wei')
  76. self._gas_adjusted = True
  77. async def process_cmdline_args(self, cmd_args, ad_f, ad_w):
  78. lc = len(cmd_args)
  79. if lc == 2 and self.is_swap:
  80. data_arg = cmd_args.pop()
  81. lc = 1
  82. assert data_arg.startswith('data:'), f'{data_arg}: invalid data arg (must start with "data:")'
  83. self.usr_contract_data = data_arg.removeprefix('data:').encode()
  84. self.set_gas_with_data(self.usr_contract_data)
  85. if lc == 0 and self.usr_contract_data and 'Token' not in self.name:
  86. return
  87. if lc != 1:
  88. die(1, f'{lc} output{suf(lc)} specified, but Ethereum transactions must have exactly one')
  89. a = self.parse_cmdline_arg(self.proto, cmd_args[0], ad_f, ad_w)
  90. self.add_output(
  91. coinaddr = None if a.is_vault else a.addr,
  92. amt = self.proto.coin_amt(a.amt or '0'),
  93. is_chg = not a.amt,
  94. is_vault = a.is_vault)
  95. self.add_mmaddrs_to_outputs(ad_f, ad_w)
  96. def get_unspent_nums_from_user(self, unspent):
  97. from ....ui import line_input
  98. while True:
  99. reply = line_input(self.cfg, 'Enter an account to spend from: ').strip()
  100. if reply:
  101. if not is_int(reply):
  102. msg('Account number must be an integer')
  103. elif int(reply) < 1:
  104. msg('Account number must be >= 1')
  105. elif int(reply) > len(unspent):
  106. msg(f'Account number must be <= {len(unspent)}')
  107. else:
  108. return [int(reply)]
  109. @property
  110. def network_estimated_fee_label(self):
  111. return 'Network-estimated'
  112. def network_fee_to_unit_disp(self, net_fee):
  113. return '{} Gwei'.format(self.pretty_fmt_fee(
  114. self.proto.coin_amt(net_fee.fee, from_unit='wei').to_unit('Gwei')))
  115. # get rel_fee (gas price) from network, return in native wei
  116. async def get_rel_fee_from_network(self):
  117. return self._net_fee(
  118. Int(await self.rpc.call('eth_gasPrice'), base=16),
  119. 'eth_gasPrice')
  120. def check_chg_addr_is_wallet_addr(self):
  121. pass
  122. def check_fee(self):
  123. if not self.disable_fee_check:
  124. assert self.usr_fee <= self.proto.max_tx_fee
  125. # given rel fee and units, return absolute fee using self.gas
  126. def fee_rel2abs(self, tx_size, amt_in_units, unit):
  127. return self.proto.coin_amt(int(amt_in_units * self.gas.toWei()), from_unit=unit)
  128. # given fee estimate (gas price) in wei, return absolute fee, adjusting by self.cfg.fee_adjust
  129. def fee_est2abs(self, net_fee):
  130. ret = self.fee_gasPrice2abs(net_fee.fee) * self.cfg.fee_adjust
  131. if self.cfg.verbose:
  132. msg(f'Estimated fee: {net_fee.fee} ETH')
  133. return ret
  134. def convert_and_check_fee(self, fee, desc):
  135. abs_fee = self.feespec2abs(fee, None)
  136. if abs_fee is False:
  137. return False
  138. elif not self.disable_fee_check and (abs_fee > self.proto.max_tx_fee):
  139. msg('{} {c}: {} fee too large (maximum fee: {} {c})'.format(
  140. abs_fee.hl(),
  141. desc,
  142. self.proto.max_tx_fee.hl(),
  143. c = self.proto.coin))
  144. return False
  145. else:
  146. return abs_fee
  147. def update_change_output(self, funds_left):
  148. if self.outputs and self.outputs[0].is_chg:
  149. self.update_output_amt(0, funds_left)
  150. async def get_input_addrs_from_inputs_opt(self):
  151. ret = []
  152. if self.cfg.inputs:
  153. data_root = (await TwCtl(self.cfg, self.proto)).data_root # must create new instance here
  154. errmsg = 'Address {!r} not in tracking wallet'
  155. for addr in self.cfg.inputs.split(','):
  156. if is_mmgen_id(self.proto, addr):
  157. for waddr in data_root:
  158. if data_root[waddr]['mmid'] == addr:
  159. ret.append(waddr)
  160. break
  161. else:
  162. die('UserAddressNotInWallet', errmsg.format(addr))
  163. elif is_coin_addr(self.proto, addr):
  164. if not addr in data_root:
  165. die('UserAddressNotInWallet', errmsg.format(addr))
  166. ret.append(addr)
  167. else:
  168. die(1, f'{addr!r}: not an MMGen ID or coin address')
  169. return ret
  170. def final_inputs_ok_msg(self, funds_left):
  171. chg = self.proto.coin_amt('0') if (self.outputs and self.outputs[0].is_chg) else funds_left
  172. return 'Transaction leaves {} {} in the sender’s account'.format(chg.hl(), self.proto.coin)
  173. class TokenNew(TokenBase, New):
  174. desc = 'transaction'
  175. fee_is_approximate = True
  176. async def make_txobj(self): # called by create_serialized()
  177. await super().make_txobj()
  178. t = Token(self.cfg, self.proto, self.twctl.token, self.twctl.decimals)
  179. o = self.txobj
  180. o['token_addr'] = t.addr
  181. o['decimals'] = t.decimals
  182. o['token_to'] = o['to']
  183. o['data'] = t.create_data(o['token_to'], o['amt'])
  184. def update_change_output(self, funds_left):
  185. if self.outputs[0].is_chg:
  186. self.update_output_amt(0, self.inputs[0].amt)
  187. # token transaction, so check both eth and token balances
  188. # TODO: add test with insufficient funds
  189. async def precheck_sufficient_funds(self, inputs_sum, sel_unspent, outputs_sum):
  190. eth_bal = await self.twctl.get_eth_balance(sel_unspent[0].addr)
  191. if eth_bal == 0: # we don't know the fee yet
  192. msg('This account has no ether to pay for the transaction fee!')
  193. return False
  194. return await super().precheck_sufficient_funds(inputs_sum, sel_unspent, outputs_sum)
  195. async def get_funds_available(self, fee, outputs_sum):
  196. bal = await self.twctl.get_eth_balance(self.inputs[0].addr)
  197. return self._funds_available(bal >= fee, bal - fee if bal >= fee else fee - bal)
  198. def final_inputs_ok_msg(self, funds_left):
  199. token_bal = (
  200. self.proto.coin_amt('0') if self.outputs[0].is_chg
  201. else self.inputs[0].amt - self.outputs[0].amt
  202. )
  203. return "Transaction leaves ≈{} {} and {} {} in the sender's account".format(
  204. funds_left.hl(),
  205. self.proto.coin,
  206. token_bal.hl(),
  207. self.proto.dcoin
  208. )