diff --git a/cmds/mmgen-swaptxcreate b/cmds/mmgen-swaptxcreate new file mode 100755 index 00000000..b0f66f96 --- /dev/null +++ b/cmds/mmgen-swaptxcreate @@ -0,0 +1,16 @@ +#!/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 + +""" +mmgen-swaptxcreate: Create an unsigned DEX swap transaction with MMGen or non-MMGen inputs +""" + +from mmgen.main import launch +launch(mod='txcreate') diff --git a/cmds/mmgen-swaptxdo b/cmds/mmgen-swaptxdo new file mode 100755 index 00000000..dac647e2 --- /dev/null +++ b/cmds/mmgen-swaptxdo @@ -0,0 +1,16 @@ +#!/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 + +""" +mmgen-swaptxdo: Create, sign and broadcast a DEX swap transaction +""" + +from mmgen.main import launch +launch(mod='txdo') diff --git a/mmgen/cfg.py b/mmgen/cfg.py index 30e95e4e..a11f091a 100755 --- a/mmgen/cfg.py +++ b/mmgen/cfg.py @@ -376,6 +376,7 @@ class Config(Lockable): _autoset_opts = { 'fee_estimate_mode': _ov('nocase_pfx', ['conservative', 'economical']), 'rpc_backend': _ov('nocase_pfx', ['auto', 'httplib', 'curl', 'aiohttp', 'requests']), + 'swap_proto': _ov('nocase_pfx', ['thorchain']), } _auto_typeset_opts = { diff --git a/mmgen/help/__init__.py b/mmgen/help/__init__.py index bd28024f..955548cd 100755 --- a/mmgen/help/__init__.py +++ b/mmgen/help/__init__.py @@ -46,10 +46,10 @@ def show_hash_presets(cfg): def gen_arg_tuple(cfg, func, text): - def help_notes(k): + def help_notes(k, *args, **kwargs): import importlib return getattr(importlib.import_module( - f'{cfg._opts.help_pkg}.help_notes').help_notes(proto, cfg), k)() + f'{cfg._opts.help_pkg}.help_notes').help_notes(proto, cfg), k)(*args, **kwargs) def help_mod(modname): import importlib diff --git a/mmgen/help/help_notes.py b/mmgen/help/help_notes.py index 9cc32918..74515edf 100755 --- a/mmgen/help/help_notes.py +++ b/mmgen/help/help_notes.py @@ -20,8 +20,10 @@ class help_notes: self.proto = proto self.cfg = cfg - def txcreate_args(self): + def txcreate_args(self, target): return ( + 'COIN1 [AMT CHG_ADDR] COIN2 [ADDR]' + if target == 'swaptx' else '[ADDR,AMT ... | DATA_SPEC] ADDR ' if self.proto.base_proto == 'Bitcoin' else 'ADDR,AMT') diff --git a/mmgen/main_txcreate.py b/mmgen/main_txcreate.py index ec11d721..8f597ae8 100755 --- a/mmgen/main_txcreate.py +++ b/mmgen/main_txcreate.py @@ -24,11 +24,19 @@ mmgen-txcreate: Create a cryptocoin transaction with MMGen- and/or non-MMGen from .cfg import gc, Config from .util import fmt_list, async_run +target = gc.prog_name.split('-')[1].removesuffix('create') + opts_data = { - 'filter_codes': ['-'], + 'filter_codes': { + 'tx': ['-', 't'], + 'swaptx': ['-', 's'], + }[target], 'sets': [('yes', True, 'quiet', True)], 'text': { - 'desc': f'Create a transaction with outputs to specified coin or {gc.proj_name} addresses', + 'desc': { + 'tx': f'Create a transaction with outputs to specified coin or {gc.proj_name} addresses', + 'swaptx': f'Create a DEX swap transaction with {gc.proj_name} inputs and outputs', + }[target], 'usage': '[opts] {u_args} [addr file ...]', 'options': """ -- -h, --help Print this help message @@ -54,15 +62,17 @@ opts_data = { -- -I, --inputs= i Specify transaction inputs (comma-separated list of + MMGen IDs or coin addresses). Note that ALL unspent + outputs associated with each address will be included. - b- -l, --locktime= t Lock time (block height or unix seconds) (default: 0) + bt -l, --locktime= t Lock time (block height or unix seconds) (default: 0) b- -L, --autochg-ignore-labels Ignore labels when autoselecting change addresses -- -m, --minconf= n Minimum number of confirmations required to spend + outputs (default: 1) -- -q, --quiet Suppress warnings; overwrite files without prompting - b- -R, --no-rbf Make transaction non-replaceable (non-replace-by-fee + bt -R, --no-rbf Make transaction non-replaceable (non-replace-by-fee + according to BIP 125) -- -v, --verbose Produce more verbose output b- -V, --vsize-adj= f Adjust transaction's estimated vsize by factor 'f' + -s -x, --swap-proto Swap protocol to use (Default: {x_dfl}, + + Choices: {x_all}) -- -y, --yes Answer 'yes' to prompts, suppress non-essential output e- -X, --cached-balances Use cached balances """, @@ -70,7 +80,7 @@ opts_data = { }, 'code': { 'usage': lambda cfg, proto, help_notes, s: s.format( - u_args = help_notes('txcreate_args')), + u_args = help_notes('txcreate_args', target)), 'options': lambda cfg, proto, help_notes, s: s.format( cfg = cfg, cu = proto.coin, @@ -78,7 +88,9 @@ opts_data = { fu = help_notes('rel_fee_desc'), fl = help_notes('fee_spec_letters'), fe_all = fmt_list(cfg._autoset_opts['fee_estimate_mode'].choices, fmt='no_spc'), - fe_dfl = cfg._autoset_opts['fee_estimate_mode'].choices[0]), + fe_dfl = cfg._autoset_opts['fee_estimate_mode'].choices[0], + x_all = fmt_list(cfg._autoset_opts['swap_proto'].choices, fmt='no_spc'), + x_dfl = cfg._autoset_opts['swap_proto'].choices[0]), 'notes': lambda cfg, help_notes, s: s.format( c = help_notes('txcreate'), F = help_notes('fee'), @@ -98,7 +110,7 @@ async def main(): Signable.automount_transaction(asi).check_create_ok() from .tx import NewTX - tx1 = await NewTX(cfg=cfg, proto=cfg._proto) + tx1 = await NewTX(cfg=cfg, proto=cfg._proto, target=target) from .rpc import rpc_init tx1.rpc = await rpc_init(cfg) diff --git a/mmgen/main_txdo.py b/mmgen/main_txdo.py index 932cf8bb..e0f78c7c 100755 --- a/mmgen/main_txdo.py +++ b/mmgen/main_txdo.py @@ -24,11 +24,19 @@ from .cfg import gc, Config from .util import die, fmt_list, async_run from .subseed import SubSeedIdxRange +target = gc.prog_name.split('-')[1].removesuffix('do') + opts_data = { - 'filter_codes': ['-'], + 'filter_codes': { + 'tx': ['-', 't'], + 'swaptx': ['-', 's'], + }[target], 'sets': [('yes', True, 'quiet', True)], 'text': { - 'desc': f'Create, sign and send an {gc.proj_name} transaction', + 'desc': { + 'tx': f'Create, sign and send an {gc.proj_name} transaction', + 'swaptx': f'Create, sign and send a DEX swap transaction with {gc.proj_name} inputs and outputs', + }[target], 'usage': '[opts] {u_args} [addr file ...] [seed source ...]', 'options': """ -- -h, --help Print this help message @@ -62,7 +70,7 @@ opts_data = { -- -k, --keys-from-file=f Provide additional keys for non-{pnm} addresses -- -K, --keygen-backend=n Use backend 'n' for public key generation. Options + for {coin_id}: {kgs} - b- -l, --locktime= t Lock time (block height or unix seconds) (default: 0) + bt -l, --locktime= t Lock time (block height or unix seconds) (default: 0) b- -L, --autochg-ignore-labels Ignore labels when autoselecting change addresses -- -m, --minconf=n Minimum number of confirmations required to spend + outputs (default: 1) @@ -75,7 +83,7 @@ opts_data = { -- -p, --hash-preset= p Use the scrypt hash parameters defined by preset 'p' + for password hashing (default: '{gc.dfl_hash_preset}') -- -P, --passwd-file= f Get {pnm} wallet passphrase from file 'f' - b- -R, --no-rbf Make transaction non-replaceable (non-replace-by-fee + bt -R, --no-rbf Make transaction non-replaceable (non-replace-by-fee + according to BIP 125) -- -q, --quiet Suppress warnings; overwrite files without prompting -- -u, --subseeds= n The number of subseed pairs to scan for (default: {ss}, @@ -83,6 +91,8 @@ opts_data = { + wallet is scanned for subseeds. -- -v, --verbose Produce more verbose output b- -V, --vsize-adj= f Adjust transaction's estimated vsize by factor 'f' + -s -x, --swap-proto Swap protocol to use (Default: {x_dfl}, + + Choices: {x_all}) e- -X, --cached-balances Use cached balances -- -y, --yes Answer 'yes' to prompts, suppress non-essential output -- -z, --show-hash-presets Show information on available hash presets @@ -103,7 +113,7 @@ column below: }, 'code': { 'usage': lambda cfg, proto, help_notes, s: s.format( - u_args = help_notes('txcreate_args')), + u_args = help_notes('txcreate_args', target)), 'options': lambda cfg, proto, help_notes, s: s.format( gc = gc, cfg = cfg, @@ -119,7 +129,9 @@ column below: ss = help_notes('dfl_subseeds'), ss_max = SubSeedIdxRange.max_idx, fe_all = fmt_list(cfg._autoset_opts['fee_estimate_mode'].choices, fmt='no_spc'), - fe_dfl = cfg._autoset_opts['fee_estimate_mode'].choices[0]), + fe_dfl = cfg._autoset_opts['fee_estimate_mode'].choices[0], + x_all = fmt_list(cfg._autoset_opts['swap_proto'].choices, fmt='no_spc'), + x_dfl = cfg._autoset_opts['swap_proto'].choices[0]), 'notes': lambda cfg, help_notes, s: s.format( c = help_notes('txcreate'), F = help_notes('fee'), @@ -139,7 +151,7 @@ seed_files = get_seed_files(cfg, cfg._args) async def main(): - tx1 = await NewTX(cfg=cfg, proto=cfg._proto) + tx1 = await NewTX(cfg=cfg, proto=cfg._proto, target=target) from .rpc import rpc_init tx1.rpc = await rpc_init(cfg) diff --git a/mmgen/proto/btc/tx/new_swap.py b/mmgen/proto/btc/tx/new_swap.py new file mode 100755 index 00000000..10746a03 --- /dev/null +++ b/mmgen/proto/btc/tx/new_swap.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# +# MMGen Wallet, a terminal-based cryptocurrency wallet +# Copyright (C)2013-2024 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.btc.tx.new_swap: Bitcoin new swap transaction class +""" + +from ....tx.new_swap import NewSwap as TxNewSwap +from .new import New + +class NewSwap(New, TxNewSwap): + desc = 'Bitcoin swap transaction' + + async def process_swap_cmd_args(self, cmd_args): + import sys + from ....util import msg + msg(' '.join(cmd_args)) + sys.exit(0) + raise NotImplementedError('Work in Progress!') + return cmd_args diff --git a/mmgen/tx/__init__.py b/mmgen/tx/__init__.py index 4e6a8219..5abcc573 100755 --- a/mmgen/tx/__init__.py +++ b/mmgen/tx/__init__.py @@ -45,6 +45,9 @@ def _get_cls_info(clsname, modname, kwargs): die(1, f'{ext!r}: unrecognized file extension for CompletedTX') clsname = cls.__name__ modname = cls.__module__.rsplit('.', maxsplit=1)[-1] + elif clsname == 'New' and kwargs['target'] == 'swaptx': + clsname = 'NewSwap' + modname = 'new_swap' kwargs['proto'] = proto @@ -94,6 +97,7 @@ BaseTX = _get('Base', 'base') UnsignedTX = _get('Unsigned', 'unsigned') NewTX = _get_async('New', 'new') +NewSwapTX = _get_async('NewSwap', 'new_swap') CompletedTX = _get_async('Completed', 'completed') SignedTX = _get_async('Signed', 'signed') OnlineSignedTX = _get_async('OnlineSigned', 'online') diff --git a/mmgen/tx/new.py b/mmgen/tx/new.py index 8cae7919..02cf945f 100755 --- a/mmgen/tx/new.py +++ b/mmgen/tx/new.py @@ -82,6 +82,10 @@ class New(Base): chg_autoselected = False _funds_available = namedtuple('funds_available', ['is_positive', 'amt']) + def __init__(self, *args, target=None, **kwargs): + self.target = target + super().__init__(*args, **kwargs) + def warn_insufficient_funds(self, amt, coin): msg(self.msg_insufficient_funds.format(amt.hl(), coin)) @@ -412,6 +416,8 @@ class New(Base): ad_f, cmd_args = self.get_addrdata_from_files(cmd_args) # pops from end of cmd_args from ..addrdata import TwAddrData ad_w = await TwAddrData(self.cfg, self.proto, twctl=self.twctl) + if self.target == 'swaptx': + cmd_args = await self.process_swap_cmd_args(cmd_args) await self.process_cmd_args(cmd_args, ad_f, ad_w) from ..ui import do_license_msg diff --git a/mmgen/tx/new_swap.py b/mmgen/tx/new_swap.py new file mode 100755 index 00000000..564910f2 --- /dev/null +++ b/mmgen/tx/new_swap.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +# +# MMGen Wallet, a terminal-based cryptocurrency wallet +# Copyright (C)2013-2024 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 + +""" +tx.new_swap: new swap transaction class +""" + +from .new import New + +class NewSwap(New): + desc = 'swap transaction' + + async def process_swap_cmd_args(self, cmd_args): + raise NotImplementedError('Work in Progress!') + return cmd_args diff --git a/setup.cfg b/setup.cfg index 40644263..b7fc9b9e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -107,6 +107,8 @@ scripts = cmds/mmgen-seedjoin cmds/mmgen-seedsplit cmds/mmgen-subwalletgen + cmds/mmgen-swaptxcreate + cmds/mmgen-swaptxdo cmds/mmgen-tool cmds/mmgen-txbump cmds/mmgen-txcreate diff --git a/test/cmdtest_d/ct_ethdev.py b/test/cmdtest_d/ct_ethdev.py index 7fec53fb..e4855493 100755 --- a/test/cmdtest_d/ct_ethdev.py +++ b/test/cmdtest_d/ct_ethdev.py @@ -975,7 +975,7 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared): if self.daemon.id == 'geth': # yet another Geth bug await asyncio.sleep(0.5) from mmgen.tx import NewTX - tx = await NewTX(cfg=cfg, proto=self.proto) + tx = await NewTX(cfg=cfg, proto=self.proto, target='tx') tx.rpc = await self.rpc res = await tx.get_receipt(txid) imsg(f'Gas sent: {res.gas_sent.hl():<9} {(res.gas_sent*res.gas_price).hl2(encl="()")}') diff --git a/test/cmdtest_d/ct_help.py b/test/cmdtest_d/ct_help.py index 039c9b75..555ceae3 100755 --- a/test/cmdtest_d/ct_help.py +++ b/test/cmdtest_d/ct_help.py @@ -148,7 +148,7 @@ class CmdTestHelp(CmdTestBase): scripts = ( 'walletgen', 'walletconv', 'walletchk', 'passchg', 'subwalletgen', 'addrgen', 'keygen', 'passgen', - 'txsign', 'txdo', 'txbump'), + 'txdo', 'swaptxdo', 'txsign', 'txbump'), expect = 'Available parameters.*Preset', pager = False) diff --git a/test/cmdtest_d/ct_swap.py b/test/cmdtest_d/ct_swap.py index 58f8fe4d..9d2c487c 100755 --- a/test/cmdtest_d/ct_swap.py +++ b/test/cmdtest_d/ct_swap.py @@ -26,7 +26,8 @@ class CmdTestSwap(CmdTestRegtest): ('setup', 'regtest (Bob and Alice) mode setup'), ('subgroup.init_bob', []), ('subgroup.fund_bob', ['init_bob']), - ('subgroup.data', ['init_bob']), + ('subgroup.data', ['fund_bob']), + ('subgroup.swap', ['fund_bob']), ('stop', 'stopping regtest daemon'), ) cmd_subgroups = { @@ -38,8 +39,9 @@ class CmdTestSwap(CmdTestRegtest): ), 'fund_bob': ( 'funding Bob’s wallet', - ('fund_bob', 'funding Bob’s wallet'), - ('bob_bal1', 'Bob’s balance'), + ('fund_bob1', 'funding Bob’s wallet (bech32)'), + ('fund_bob2', 'funding Bob’s wallet (native Segwit)'), + ('bob_bal', 'displaying Bob’s balance'), ), 'data': ( 'OP_RETURN data operations', @@ -51,6 +53,11 @@ class CmdTestSwap(CmdTestRegtest): ('data_tx2_do', 'Creating and sending a transaction with OP_RETURN data (binary)'), ('data_tx2_chk', 'Checking the sent transaction'), ('generate3', 'Generate 3 blocks'), + ('bob_listaddrs', 'Display Bob’s addresses'), + ), + 'swap': ( + 'Swap operations', + ('bob_swaptxcreate1', 'Create a swap transaction'), ), } @@ -70,9 +77,15 @@ class CmdTestSwap(CmdTestRegtest): def addrimport_bob(self): return self.addrimport('bob', mmtypes=['S', 'B']) - def fund_bob(self): + def fund_bob1(self): return self.fund_wallet('bob', 'B', '500') + def fund_bob2(self): + return self.fund_wallet('bob', 'S', '500') + + def bob_bal(self): + return self.user_bal('bob', '1000') + def data_tx1_create(self): return self._data_tx_create('1', 'B:2', 'B:3', 'data', sample1) @@ -149,3 +162,13 @@ class CmdTestSwap(CmdTestRegtest): def generate3(self): return self.generate(3) + + def bob_listaddrs(self): + t = self.spawn('mmgen-tool', ['--bob', 'listaddresses']) + return t + + def bob_swaptxcreate1(self): + t = self.spawn( + 'mmgen-swaptxcreate', + ['-d', self.tmpdir, '-B', '--bob', 'BTC', '1.234', f'{self.sid}:S:3', 'LTC']) + return t diff --git a/test/daemontest_d/ut_tx.py b/test/daemontest_d/ut_tx.py index 8751d746..d57abba8 100755 --- a/test/daemontest_d/ut_tx.py +++ b/test/daemontest_d/ut_tx.py @@ -114,7 +114,7 @@ class unit_tests: d.start() proto = init_proto(cfg, 'btc', need_amt=True) - await NewTX(cfg=cfg, proto=proto) + await NewTX(cfg=cfg, proto=proto, target='tx') d.stop() d.remove_datadir()