diff --git a/mmgen/proto/eth/tx/completed.py b/mmgen/proto/eth/tx/completed.py index e5479fea..6306563d 100755 --- a/mmgen/proto/eth/tx/completed.py +++ b/mmgen/proto/eth/tx/completed.py @@ -13,18 +13,14 @@ proto.eth.tx.completed: Ethereum completed transaction class """ from ....tx import completed as TxBase + +from ...vm.tx.completed import Completed as VmCompleted + from .base import Base, TokenBase -class Completed(Base, TxBase.Completed): +class Completed(VmCompleted, Base, TxBase.Completed): fn_fee_unit = 'Mwei' - def get_swap_memo_maybe(self): - return self.swap_memo.encode() if getattr(self, 'swap_memo', None) else None - - @property - def send_amt(self): - return self.outputs[0].amt if self.outputs else self.proto.coin_amt('0') - @property def total_gas(self): return self.txobj['startGas'] @@ -33,25 +29,6 @@ class Completed(Base, TxBase.Completed): def fee(self): return self.fee_gasPrice2abs(self.txobj['gasPrice'].toWei()) - @property - def change(self): - return self.sum_inputs() - self.send_amt - self.fee - - def check_txfile_hex_data(self): - pass - - def check_sigs(self): # TODO - from ....util import is_hex_str - if is_hex_str(self.serialized): - return True - return False - - def check_pubkey_scripts(self): - pass - - def get_serialized_locktime(self): - return None # TODO - class TokenCompleted(TokenBase, Completed): @property diff --git a/mmgen/proto/eth/tx/new.py b/mmgen/proto/eth/tx/new.py index 6ae4fe72..e553f0dd 100755 --- a/mmgen/proto/eth/tx/new.py +++ b/mmgen/proto/eth/tx/new.py @@ -13,19 +13,18 @@ proto.eth.tx.new: Ethereum new transaction class """ from ....tx import new as TxBase -from ....obj import Int, ETHNonce, MMGenTxID -from ....util import msg, ymsg, is_int, is_hex_str, make_chksum_6, suf, die -from ....tw.ctl import TwCtl -from ....addr import is_mmgen_id, is_coin_addr +from ....obj import Int, ETHNonce +from ....util import msg, ymsg, is_int + +from ...vm.tx.new import New as VmNew + from ..contract import Token + from .base import Base, TokenBase -class New(Base, TxBase.New): - desc = 'transaction' +class New(VmNew, Base, TxBase.New): fee_fail_fs = 'Network fee estimation failed' - no_chg_msg = 'Warning: Transaction leaves account with zero balance' usr_fee_prompt = 'Enter transaction fee or gas price: ' - msg_insufficient_funds = 'Account balance insufficient to fund this transaction ({} {} needed)' byte_cost = 68 # https://ethereum.stackexchange.com/questions/39401/ # how-do-you-calculate-gas-limit-for-transaction-with-data-in-ethereum @@ -70,24 +69,6 @@ class New(Base, TxBase.New): 'chainId': self.rpc.chainID, 'data': self.usr_contract_data.hex()} - # Instead of serializing tx data as with BTC, just create a JSON dump. - # This complicates things but means we avoid using the rlp library to deserialize the data, - # thus removing an attack vector - async def create_serialized(self, *, locktime=None): - assert len(self.inputs) == 1, 'Transaction has more than one input!' - o_num = len(self.outputs) - o_ok = 0 if self.usr_contract_data else 1 - assert o_num == o_ok, f'Transaction has {o_num} output{suf(o_num)} (should have {o_ok})' - await self.make_txobj() - self.serialized = {k:v if v is None else str(v) for k, v in self.txobj.items() if k != 'token_to'} - self.update_txid() - - def update_txid(self): - import json - assert not is_hex_str(self.serialized), ( - 'update_txid() must be called only when self.serialized is not hex data') - self.txid = MMGenTxID(make_chksum_6(json.dumps(self.serialized)).upper()) - def set_gas_with_data(self, data): if not self.is_token: self.gas = self.dfl_gas + self.byte_cost * len(data) @@ -98,47 +79,6 @@ class New(Base, TxBase.New): self.gas += self.byte_cost * extra_data_len self._gas_adjusted = True - async def process_cmdline_args(self, cmd_args, ad_f, ad_w): - - lc = len(cmd_args) - - if lc == 2 and self.is_swap: - data_arg = cmd_args.pop() - lc = 1 - assert data_arg.startswith('data:'), f'{data_arg}: invalid data arg (must start with "data:")' - self.swap_memo = data_arg.removeprefix('data:') - self.set_gas_with_data(self.swap_memo.encode()) - - if lc == 0 and self.usr_contract_data and 'Token' not in self.name: - return - - if lc != 1: - die(1, f'{lc} output{suf(lc)} specified, but Ethereum transactions must have exactly one') - - a = self.parse_cmdline_arg(self.proto, cmd_args[0], ad_f, ad_w) - - self.add_output( - coinaddr = None if a.is_vault else a.addr, - amt = self.proto.coin_amt(a.amt or '0'), - is_chg = not a.amt, - is_vault = a.is_vault) - - self.add_mmaddrs_to_outputs(ad_f, ad_w) - - def get_unspent_nums_from_user(self, unspent): - from ....ui import line_input - while True: - reply = line_input(self.cfg, 'Enter an account to spend from: ').strip() - if reply: - if not is_int(reply): - msg('Account number must be an integer') - elif int(reply) < 1: - msg('Account number must be >= 1') - elif int(reply) > len(unspent): - msg(f'Account number must be <= {len(unspent)}') - else: - return [int(reply)] - @property def network_estimated_fee_label(self): return 'Network-estimated' @@ -153,17 +93,6 @@ class New(Base, TxBase.New): Int(await self.rpc.call('eth_gasPrice'), base=16), 'eth_gasPrice') - def check_chg_addr_is_wallet_addr(self): - pass - - def check_fee(self): - if not self.disable_fee_check: - assert self.usr_fee <= self.proto.max_tx_fee - - @property - def total_gas(self): - return self.gas - # given rel fee and units, return absolute fee using self.total_gas def fee_rel2abs(self, tx_size, amt_in_units, unit): return self.proto.coin_amt(int(amt_in_units * self.total_gas), from_unit=unit) @@ -189,35 +118,6 @@ class New(Base, TxBase.New): else: return abs_fee - def update_change_output(self, funds_left): - if self.outputs and self.outputs[0].is_chg: - self.update_output_amt(0, funds_left) - - async def get_input_addrs_from_inputs_opt(self): - ret = [] - if self.cfg.inputs: - data_root = (await TwCtl(self.cfg, self.proto)).data_root # must create new instance here - errmsg = 'Address {!r} not in tracking wallet' - for addr in self.cfg.inputs.split(','): - if is_mmgen_id(self.proto, addr): - for waddr in data_root: - if data_root[waddr]['mmid'] == addr: - ret.append(waddr) - break - else: - die('UserAddressNotInWallet', errmsg.format(addr)) - elif is_coin_addr(self.proto, addr): - if not addr in data_root: - die('UserAddressNotInWallet', errmsg.format(addr)) - ret.append(addr) - else: - die(1, f'{addr!r}: not an MMGen ID or coin address') - return ret - - def final_inputs_ok_msg(self, funds_left): - chg = self.proto.coin_amt('0') if (self.outputs and self.outputs[0].is_chg) else funds_left - return 'Transaction leaves {} {} in the sender’s account'.format(chg.hl(), self.proto.coin) - class TokenNew(TokenBase, New): desc = 'transaction' fee_is_approximate = True diff --git a/mmgen/proto/eth/tx/unsigned.py b/mmgen/proto/eth/tx/unsigned.py index ccb9a171..16c0dbaf 100755 --- a/mmgen/proto/eth/tx/unsigned.py +++ b/mmgen/proto/eth/tx/unsigned.py @@ -15,14 +15,16 @@ proto.eth.tx.unsigned: Ethereum unsigned transaction class import json from ....tx import unsigned as TxBase -from ....util import msg, msg_r, die from ....obj import CoinTxID, ETHNonce, Int, HexStr from ....addr import CoinAddr, ContractAddr + +from ...vm.tx.unsigned import Unsigned as VmUnsigned + from ..contract import Token, THORChainRouterContract + from .completed import Completed, TokenCompleted -class Unsigned(Completed, TxBase.Unsigned): - desc = 'unsigned transaction' +class Unsigned(VmUnsigned, Completed, TxBase.Unsigned): def parse_txfile_serialized_data(self): d = self.serialized if isinstance(self.serialized, dict) else json.loads(self.serialized) @@ -66,42 +68,6 @@ class Unsigned(Completed, TxBase.Unsigned): else: self.txobj['token_addr'] = ContractAddr(self.proto, etx.creates.hex()) - async def sign(self, tx_num_str, keys): # return TX object or False; don't exit or raise exception - - from ....exception import TransactionChainMismatch - try: - self.check_correct_chain() - except TransactionChainMismatch: - return False - - o = self.txobj - - def do_mismatch_err(io, j, k, desc): - m = 'A compromised online installation may have altered your serialized data!' - fs = '\n{} mismatch!\n{}\n orig: {}\n serialized: {}' - die(3, fs.format(desc.upper(), m, getattr(io[0], k), o[j])) - - if o['from'] != self.inputs[0].addr: - do_mismatch_err(self.inputs, 'from', 'addr', 'from-address') - if self.outputs: - if o['to'] != self.outputs[0].addr: - do_mismatch_err(self.outputs, 'to', 'addr', 'to-address') - if o['amt'] != self.outputs[0].amt: - do_mismatch_err(self.outputs, 'amt', 'amt', 'amount') - - msg_r(f'Signing transaction{tx_num_str}...') - - try: - await self.do_sign(o, keys[0].sec.wif) - msg('OK') - from ....tx import SignedTX - tx = SignedTX(cfg=self.cfg, data=self.__dict__, automount=self.automount) - tx.check_serialized_integrity() - return tx - except Exception as e: - msg(f'{e}: transaction signing failed!') - return False - class TokenUnsigned(TokenCompleted, Unsigned): desc = 'unsigned transaction' diff --git a/mmgen/proto/vm/tx/completed.py b/mmgen/proto/vm/tx/completed.py new file mode 100755 index 00000000..c11ad744 --- /dev/null +++ b/mmgen/proto/vm/tx/completed.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +# +# MMGen Wallet, a terminal-based cryptocurrency wallet +# Copyright (C)2013-2025 The MMGen Project +# Licensed under the GNU General Public License, Version 3: +# https://www.gnu.org/licenses +# Public project repositories: +# https://github.com/mmgen/mmgen-wallet +# https://gitlab.com/mmgen/mmgen-wallet + +""" +proto.vm.tx.completed: completed transaction methods for VM chains +""" + +class Completed: + + def get_swap_memo_maybe(self): + return self.swap_memo.encode() if getattr(self, 'swap_memo', None) else None + + @property + def send_amt(self): + return self.outputs[0].amt if self.outputs else self.proto.coin_amt('0') + + @property + def change(self): + return self.sum_inputs() - self.send_amt - self.fee + + def check_txfile_hex_data(self): + pass + + def check_sigs(self): # TODO + from ....util import is_hex_str + if is_hex_str(self.serialized): + return True + return False + + def check_pubkey_scripts(self): + pass + + def get_serialized_locktime(self): + return None # TODO diff --git a/mmgen/proto/vm/tx/new.py b/mmgen/proto/vm/tx/new.py new file mode 100755 index 00000000..198a8eef --- /dev/null +++ b/mmgen/proto/vm/tx/new.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# +# MMGen Wallet, a terminal-based cryptocurrency wallet +# Copyright (C)2013-2025 The MMGen Project +# Licensed under the GNU General Public License, Version 3: +# https://www.gnu.org/licenses +# Public project repositories: +# https://github.com/mmgen/mmgen-wallet +# https://gitlab.com/mmgen/mmgen-wallet + +""" +proto.vm.tx.new: new transaction class methods for VM chains +""" + +from ....obj import MMGenTxID +from ....util import msg, is_int, is_hex_str, make_chksum_6, suf, die +from ....tw.ctl import TwCtl +from ....addr import is_mmgen_id, is_coin_addr + +class New: + desc = 'transaction' + no_chg_msg = 'Warning: Transaction leaves account with zero balance' + msg_insufficient_funds = 'Account balance insufficient to fund this transaction ({} {} needed)' + + # Instead of serializing tx data online as with BTC, put the data in a dict and serialize + # offline before signing + async def create_serialized(self, *, locktime=None): + assert len(self.inputs) == 1, 'Transaction has more than one input!' + o_num = len(self.outputs) + o_ok = 0 if self.usr_contract_data else 1 + assert o_num == o_ok, f'Transaction has {o_num} output{suf(o_num)} (should have {o_ok})' + await self.make_txobj() + self.serialized = {k:v if v is None else str(v) for k, v in self.txobj.items() if k != 'token_to'} + self.update_txid() + + def update_txid(self): + import json + assert not is_hex_str(self.serialized), ( + 'update_txid() must be called only when self.serialized is not hex data') + self.txid = MMGenTxID(make_chksum_6(json.dumps(self.serialized)).upper()) + + async def process_cmdline_args(self, cmd_args, ad_f, ad_w): + + lc = len(cmd_args) + + if lc == 2 and self.is_swap: + data_arg = cmd_args.pop() + lc = 1 + assert data_arg.startswith('data:'), f'{data_arg}: invalid data arg (must start with "data:")' + self.swap_memo = data_arg.removeprefix('data:') + self.set_gas_with_data(self.swap_memo.encode()) + + if lc == 0 and self.usr_contract_data and 'Token' not in self.name: + return + + if lc != 1: + die(1, f'{lc} output{suf(lc)} specified, but VM transactions must have exactly one') + + a = self.parse_cmdline_arg(self.proto, cmd_args[0], ad_f, ad_w) + + self.add_output( + coinaddr = None if a.is_vault else a.addr, + amt = self.proto.coin_amt(a.amt or '0'), + is_chg = not a.amt, + is_vault = a.is_vault) + + self.add_mmaddrs_to_outputs(ad_f, ad_w) + + def get_unspent_nums_from_user(self, unspent): + from ....ui import line_input + while True: + reply = line_input(self.cfg, 'Enter an account to spend from: ').strip() + if reply: + if not is_int(reply): + msg('Account number must be an integer') + elif int(reply) < 1: + msg('Account number must be >= 1') + elif int(reply) > len(unspent): + msg(f'Account number must be <= {len(unspent)}') + else: + return [int(reply)] + + def check_chg_addr_is_wallet_addr(self): + pass + + def check_fee(self): + if not self.disable_fee_check: + assert self.usr_fee <= self.proto.max_tx_fee + + @property + def total_gas(self): + return self.gas + + def update_change_output(self, funds_left): + if self.outputs and self.outputs[0].is_chg: + self.update_output_amt(0, funds_left) + + async def get_input_addrs_from_inputs_opt(self): + ret = [] + if self.cfg.inputs: + data_root = (await TwCtl(self.cfg, self.proto)).data_root # must create new instance here + errmsg = 'Address {!r} not in tracking wallet' + for addr in self.cfg.inputs.split(','): + if is_mmgen_id(self.proto, addr): + for waddr in data_root: + if data_root[waddr]['mmid'] == addr: + ret.append(waddr) + break + else: + die('UserAddressNotInWallet', errmsg.format(addr)) + elif is_coin_addr(self.proto, addr): + if not addr in data_root: + die('UserAddressNotInWallet', errmsg.format(addr)) + ret.append(addr) + else: + die(1, f'{addr!r}: not an MMGen ID or coin address') + return ret + + def final_inputs_ok_msg(self, funds_left): + chg = self.proto.coin_amt('0') if (self.outputs and self.outputs[0].is_chg) else funds_left + return 'Transaction leaves {} {} in the sender’s account'.format(chg.hl(), self.proto.coin) diff --git a/mmgen/proto/vm/tx/unsigned.py b/mmgen/proto/vm/tx/unsigned.py new file mode 100755 index 00000000..1e780050 --- /dev/null +++ b/mmgen/proto/vm/tx/unsigned.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# +# MMGen Wallet, a terminal-based cryptocurrency wallet +# Copyright (C)2013-2025 The MMGen Project +# Licensed under the GNU General Public License, Version 3: +# https://www.gnu.org/licenses +# Public project repositories: +# https://github.com/mmgen/mmgen-wallet +# https://gitlab.com/mmgen/mmgen-wallet + +""" +proto.vm.tx.unsigned: unsigned transaction methods for VM chains +""" + +from ....util import msg, msg_r, die + +class Unsigned: + desc = 'unsigned transaction' + + async def sign(self, tx_num_str, keys): # return TX object or False; don't exit or raise exception + + from ....exception import TransactionChainMismatch + try: + self.check_correct_chain() + except TransactionChainMismatch: + return False + + o = self.txobj + + def do_mismatch_err(io, j, k, desc): + m = 'A compromised online installation may have altered your serialized data!' + fs = '\n{} mismatch!\n{}\n orig: {}\n serialized: {}' + die(3, fs.format(desc.upper(), m, getattr(io[0], k), o[j])) + + if o['from'] != self.inputs[0].addr: + do_mismatch_err(self.inputs, 'from', 'addr', 'from-address') + if self.outputs: + if o['to'] != self.outputs[0].addr: + do_mismatch_err(self.outputs, 'to', 'addr', 'to-address') + if o['amt'] != self.outputs[0].amt: + do_mismatch_err(self.outputs, 'amt', 'amt', 'amount') + + msg_r(f'Signing transaction{tx_num_str}...') + + try: + await self.do_sign(o, keys[0].sec.wif) + msg('OK') + from ....tx import SignedTX + tx = SignedTX(cfg=self.cfg, data=self.__dict__, automount=self.automount) + tx.check_serialized_integrity() + return tx + except Exception as e: + msg(f'{e}: transaction signing failed!') + return False diff --git a/setup.cfg b/setup.cfg index 1a0a439c..ee9ffc9d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -100,6 +100,8 @@ packages = mmgen.proto.rune.tw mmgen.proto.rune.tx mmgen.proto.secp256k1 + mmgen.proto.vm + mmgen.proto.vm.tx mmgen.proto.xchain mmgen.proto.xmr mmgen.proto.zec