- This functionality is work-in-progress: sweep transactions, spending to
MMGen IDs and certain options are not supported yet.
- The `--autosign` option is required. It may be set in the config file.
- On the offline signing machine, `--xmrwallet-compat` is required, or it
may be set in the config file. On the online machine, the option is
required to permit interoperability with `mmgen-xmrwallet`.
- Note that transactions created with `mmgen-txcreate` must be sent with
`mmgen-txsend`, not `mmgen-xmrwallet submit`, which looks for signed
transactions in a different directory on the removable device.
- Signed or unsigned transactions may be aborted with `mmgen-txsend --abort`.
- Compat mode uses `mmgen-xmrwallet` as its backend, and that command is still
required for all operations beyond simple create-sign-send to address. For
details, invoke `mmgen-xmrwallet --help`.
Sample workflow:
$ mmgen-txcreate --coin=xmr XMR_ADDRESS,AMOUNT
(remove - insert - wait for signing - remove - insert)
$ mmgen-txsend
Testing/demo:
$ test/cmdtest.py --coin=xmr -e xmr_compat
255 lines
7.4 KiB
Python
Executable file
255 lines
7.4 KiB
Python
Executable file
#!/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
|
|
|
|
"""
|
|
tx.base: base transaction class
|
|
"""
|
|
|
|
from ..cfg import gc
|
|
from ..objmethods import MMGenObject
|
|
from ..obj import (
|
|
ImmutableAttr,
|
|
ListItemAttr,
|
|
MMGenListItem,
|
|
MMGenTxComment,
|
|
TwComment,
|
|
CoinTxID,
|
|
HexStr,
|
|
NonNegativeInt
|
|
)
|
|
from ..amt import CoinAmtChk
|
|
from ..addr import MMGenID, CoinAddr
|
|
from ..util import msg, ymsg, fmt, remove_dups, make_timestamp, die, cached_property
|
|
|
|
class MMGenTxIO(MMGenListItem):
|
|
vout = ListItemAttr(NonNegativeInt)
|
|
amt = ImmutableAttr(CoinAmtChk, include_proto=True)
|
|
comment = ListItemAttr(TwComment, reassign_ok=True)
|
|
mmid = ListItemAttr(MMGenID, include_proto=True)
|
|
addr = ImmutableAttr(CoinAddr, include_proto=True)
|
|
confs = ListItemAttr(int) # confs of type long exist in the wild, so convert
|
|
txid = ListItemAttr(CoinTxID)
|
|
have_wif = ListItemAttr(bool, typeconv=False, delete_ok=True)
|
|
|
|
invalid_attrs = {'proto', 'tw_copy_attrs'}
|
|
|
|
def __init__(self, proto, **kwargs):
|
|
self.__dict__['proto'] = proto
|
|
MMGenListItem.__init__(self, **kwargs)
|
|
|
|
@property
|
|
def mmtype(self):
|
|
"""
|
|
Attempt to determine input or output’s MMGenAddrType. For non-MMGen
|
|
addresses, infer the type from the address format, returning None for
|
|
P2PKH, which could be either 'L' or 'C'.
|
|
"""
|
|
return (
|
|
str(self.mmid.mmtype) if self.mmid else
|
|
'B' if self.addr.addr_fmt == 'bech32' else
|
|
'S' if self.addr.addr_fmt == 'p2sh' else
|
|
None
|
|
) if self.addr else None
|
|
|
|
class MMGenTxIOList(list, MMGenObject):
|
|
|
|
def __init__(self, parent, data=None):
|
|
self.parent = parent
|
|
if data:
|
|
assert isinstance(data, list), 'MMGenTxIOList_check1'
|
|
else:
|
|
data = []
|
|
list.__init__(self, data)
|
|
|
|
class Base(MMGenObject):
|
|
desc = 'transaction'
|
|
comment = None
|
|
txid = None
|
|
coin_txid = None
|
|
timestamp = None
|
|
sent_timestamp = None
|
|
blockcount = None
|
|
locktime = None
|
|
chain = None
|
|
signed = False
|
|
is_bump = False
|
|
is_swap = False
|
|
is_compat = False
|
|
has_comment = True
|
|
swap_attrs = {
|
|
'swap_proto': None,
|
|
'swap_quote_expiry': None,
|
|
'swap_recv_addr_mmid': None,
|
|
'swap_recv_asset_spec': None,
|
|
'swap_memo': None,
|
|
'token_vault_addr': None,
|
|
'serialized2': None,
|
|
'coin_txid2': CoinTxID}
|
|
file_format = 'json'
|
|
non_mmgen_inputs_msg = f"""
|
|
This transaction includes inputs with non-{gc.proj_name} addresses. When
|
|
signing the transaction, private keys for the addresses listed below must
|
|
be supplied using the --keys-from-file option. The key file must contain
|
|
one key per line.
|
|
|
|
Non-{gc.proj_name} addresses found in inputs:
|
|
{{}}
|
|
"""
|
|
|
|
class Input(MMGenTxIO):
|
|
scriptPubKey = ListItemAttr(HexStr)
|
|
sequence = ListItemAttr(int, typeconv=False)
|
|
tw_copy_attrs = {'scriptPubKey', 'vout', 'amt', 'comment', 'mmid', 'addr', 'confs', 'txid'}
|
|
|
|
class Output(MMGenTxIO):
|
|
addr = ListItemAttr(CoinAddr, include_proto=True) # ImmutableAttr in parent cls
|
|
is_chg = ListItemAttr(bool, typeconv=False)
|
|
is_vault = ListItemAttr(bool, typeconv=False)
|
|
data = ListItemAttr(None, typeconv=False) # placeholder
|
|
|
|
class InputList(MMGenTxIOList):
|
|
desc = 'transaction inputs'
|
|
|
|
class OutputList(MMGenTxIOList):
|
|
desc = 'transaction outputs'
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.cfg = kwargs['cfg']
|
|
self.inputs = self.InputList(self)
|
|
self.outputs = self.OutputList(self)
|
|
self.name = type(self).__name__
|
|
self.proto = kwargs['proto']
|
|
self.twctl = kwargs.get('twctl')
|
|
self.is_token = 'Token' in self.name
|
|
|
|
@property
|
|
def coin(self):
|
|
return self.proto.coin
|
|
|
|
@property
|
|
def dcoin(self):
|
|
return self.proto.dcoin
|
|
|
|
@property
|
|
def info(self):
|
|
from .info import init_info
|
|
return init_info(self.cfg, self)
|
|
|
|
def check_correct_chain(self):
|
|
if hasattr(self, 'rpc'):
|
|
if self.chain != self.rpc.chain:
|
|
die('TransactionChainMismatch',
|
|
f'Transaction is for {self.chain}, but coin daemon chain is {self.rpc.chain}!')
|
|
|
|
def sum_inputs(self):
|
|
return sum(e.amt for e in self.inputs)
|
|
|
|
def sum_outputs(self, *, exclude=None):
|
|
if exclude is None:
|
|
olist = self.outputs
|
|
else:
|
|
olist = self.outputs[:exclude] + self.outputs[exclude+1:]
|
|
if not olist:
|
|
return self.proto.coin_amt('0')
|
|
return sum(e.amt for e in olist)
|
|
|
|
def _chg_output_ops(self, op, attr):
|
|
is_chgs = [getattr(x, attr) for x in self.outputs]
|
|
if is_chgs.count(True) == 1:
|
|
return is_chgs.index(True) if op == 'idx' else self.outputs[is_chgs.index(True)]
|
|
elif is_chgs.count(True) == 0:
|
|
return None
|
|
else:
|
|
raise ValueError('more than one change output!')
|
|
|
|
@property
|
|
def chg_idx(self):
|
|
return self._chg_output_ops('idx', 'is_chg')
|
|
|
|
@property
|
|
def chg_output(self):
|
|
return self._chg_output_ops('output', 'is_chg')
|
|
|
|
def add_timestamp(self):
|
|
self.timestamp = make_timestamp()
|
|
|
|
def add_sent_timestamp(self):
|
|
self.sent_timestamp = make_timestamp()
|
|
|
|
def add_blockcount(self):
|
|
self.blockcount = self.rpc.blockcount
|
|
|
|
# returns True if comment added or changed, False otherwise
|
|
def add_comment(self, *, infile=None):
|
|
if infile:
|
|
from ..fileutil import get_data_from_file
|
|
self.comment = MMGenTxComment(
|
|
get_data_from_file(self.cfg, infile, desc='transaction comment'))
|
|
else:
|
|
from ..ui import keypress_confirm, line_input
|
|
if keypress_confirm(
|
|
self.cfg,
|
|
prompt = 'Edit transaction comment?' if self.comment else 'Add a comment to transaction?',
|
|
default_yes = False):
|
|
res = MMGenTxComment(line_input(self.cfg, 'Comment: ', insert_txt=self.comment))
|
|
if not res:
|
|
ymsg('Warning: comment is empty')
|
|
changed = res != self.comment
|
|
self.comment = res
|
|
return changed
|
|
else:
|
|
return False
|
|
|
|
def get_non_mmaddrs(self, desc):
|
|
return remove_dups(
|
|
(i.addr for i in getattr(self, desc) if not i.mmid),
|
|
edesc = 'non-MMGen address',
|
|
quiet = True)
|
|
|
|
def check_non_mmgen_inputs(self, *, caller, non_mmaddrs=None):
|
|
assert caller in ('txcreate', 'txdo', 'txsign', 'autosign')
|
|
non_mmaddrs = non_mmaddrs or self.get_non_mmaddrs('inputs')
|
|
if non_mmaddrs:
|
|
indent = ' '
|
|
fs = fmt(self.non_mmgen_inputs_msg, strip_char='\t', indent=indent).strip()
|
|
m = fs.format('\n '.join(non_mmaddrs))
|
|
if caller in ('txdo', 'txsign'):
|
|
if not self.cfg.keys_from_file:
|
|
die('UserOptError', f'\n{indent}ERROR: {m}\n')
|
|
else:
|
|
msg(f'\n{indent}WARNING: {m}\n')
|
|
if not (caller == 'autosign' or self.cfg.yes):
|
|
from ..ui import keypress_confirm
|
|
keypress_confirm(self.cfg, 'Continue?', default_yes=True, do_exit=True)
|
|
|
|
# swap methods:
|
|
@cached_property
|
|
def swap_proto_mod(self):
|
|
from .new_swap import get_swap_proto_mod
|
|
return get_swap_proto_mod(self.swap_proto)
|
|
|
|
@cached_property
|
|
def send_asset(self):
|
|
spec = self.proto.coin + (f'.{self.proto.tokensym}' if self.proto.tokensym else '')
|
|
return self.swap_proto_mod.SwapAsset(spec, 'send')
|
|
|
|
@cached_property
|
|
def recv_asset(self):
|
|
if hasattr(self, 'swap_recv_asset_spec'):
|
|
return self.swap_proto_mod.SwapAsset(self.swap_recv_asset_spec, 'recv')
|
|
else: # backwards-compatibility workaround
|
|
from ..swap.asset import SwapAsset
|
|
x = '[unknown]'
|
|
return SwapAsset._ad(x, x, x, x, x)
|
|
|
|
# token methods:
|
|
@property
|
|
def token_op(self):
|
|
return 'approve' if self.is_swap else 'transfer'
|