Browse Source

new `proto.vm.tx` package

The MMGen Project 5 months ago
parent
commit
3ad310e259

+ 4 - 27
mmgen/proto/eth/tx/completed.py

@@ -13,17 +13,13 @@ proto.eth.tx.completed: Ethereum completed transaction class
 """
 
 from ....tx import completed as TxBase
-from .base import Base, TokenBase
 
-class Completed(Base, TxBase.Completed):
-	fn_fee_unit = 'Mwei'
+from ...vm.tx.completed import Completed as VmCompleted
 
-	def get_swap_memo_maybe(self):
-		return self.swap_memo.encode() if getattr(self, 'swap_memo', None) else None
+from .base import Base, TokenBase
 
-	@property
-	def send_amt(self):
-		return self.outputs[0].amt if self.outputs else self.proto.coin_amt('0')
+class Completed(VmCompleted, Base, TxBase.Completed):
+	fn_fee_unit = 'Mwei'
 
 	@property
 	def total_gas(self):
@@ -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

+ 7 - 107
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

+ 5 - 39
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'
 

+ 41 - 0
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 <mmgen@tuta.io>
+# 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

+ 121 - 0
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 <mmgen@tuta.io>
+# 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)

+ 54 - 0
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 <mmgen@tuta.io>
+# 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

+ 2 - 0
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