new proto.vm.tx package

This commit is contained in:
The MMGen Project 2025-06-12 12:48:38 +00:00
commit 3ad310e259
Signed by: mmgen
GPG key ID: 3F8B1861E32B7DA2
7 changed files with 234 additions and 173 deletions

View file

@ -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

View file

@ -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

View file

@ -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
mmgen/proto/vm/tx/completed.py Executable file
View file

@ -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
mmgen/proto/vm/tx/new.py Executable file
View file

@ -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
mmgen/proto/vm/tx/unsigned.py Executable file
View file

@ -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

View file

@ -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