From 85cec5655d2f439ff46117a391b140176d1e2dd2 Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Mon, 24 Feb 2025 11:27:49 +0000 Subject: [PATCH] THORChain DEX integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-chain native asset swaps directly from MMGen Wallet! Currently supported coins: BTC, LTC, BCH. Work on ETH support is underway. All supported asset pairs have undergone thorough testing on mainnet. Sample workflow for a BTC->LTC swap (assumes offline autosigning is set up and the removable device inserted on the online machine): $ mmgen-swaptxcreate --autosign BTC LTC remove device - insert - wait for signing - remove - insert $ mmgen-txsend --autosign Note that other command-line options and arguments will likely be required. For further information, see: $ mmgen-swaptxcreate --help Be aware that transactions stuck for a long time in the mempool can potentially lead to loss of funds, so users should first learn how to create replacement transactions with ‘mmgen-txbump’ before attempting a swap. In all cases, it’s advisable to begin with small amounts. Double-checking the vault address on a block explorer such as thorchain.net or runescan.io before sending the transaction is also recommended. Testing: $ test/modtest.py tx.memo $ test/cmdtest.py regtest_legacy.main autosign_automount swap --- mmgen/amt.py | 4 + mmgen/data/version | 2 +- mmgen/exception.py | 1 + mmgen/help/swaptxcreate.py | 82 +++ mmgen/help/swaptxcreate_examples.py | 72 +++ mmgen/main_autosign.py | 2 + mmgen/main_txbump.py | 8 + mmgen/main_txcreate.py | 7 +- mmgen/main_txdo.py | 7 +- mmgen/main_txsend.py | 3 + mmgen/main_txsign.py | 2 + mmgen/proto/bch/params.py | 1 + mmgen/proto/btc/params.py | 1 + mmgen/proto/btc/tx/base.py | 16 +- mmgen/proto/btc/tx/bump.py | 4 +- mmgen/proto/btc/tx/completed.py | 20 +- mmgen/proto/btc/tx/info.py | 2 +- mmgen/proto/btc/tx/new.py | 7 +- mmgen/proto/btc/tx/new_swap.py | 129 +++- mmgen/proto/eth/params.py | 1 + mmgen/proto/eth/tx/completed.py | 3 + mmgen/swap/proto/thorchain/__init__.py | 25 + mmgen/swap/proto/thorchain/memo.py | 134 ++++ mmgen/swap/proto/thorchain/midgard.py | 113 ++++ mmgen/swap/proto/thorchain/params.py | 28 + mmgen/tw/addresses.py | 22 +- mmgen/tx/base.py | 4 + mmgen/tx/bump.py | 21 +- mmgen/tx/file.py | 5 +- mmgen/tx/info.py | 16 +- mmgen/tx/new.py | 38 +- mmgen/tx/new_swap.py | 22 +- mmgen/tx/online.py | 16 + mmgen/tx/sign.py | 6 + setup.cfg | 3 + test/cmdtest_d/ct_regtest.py | 4 +- test/cmdtest_d/ct_swap.py | 580 +++++++++++++++++- test/cmdtest_d/midgard.py | 89 +++ test/modtest_d/ut_tx.py | 73 ++- .../mmgen/swap/proto/thorchain/midgard.py | 11 + 40 files changed, 1511 insertions(+), 73 deletions(-) create mode 100755 mmgen/help/swaptxcreate.py create mode 100755 mmgen/help/swaptxcreate_examples.py create mode 100755 mmgen/swap/proto/thorchain/__init__.py create mode 100755 mmgen/swap/proto/thorchain/memo.py create mode 100755 mmgen/swap/proto/thorchain/midgard.py create mode 100755 mmgen/swap/proto/thorchain/params.py create mode 100755 test/cmdtest_d/midgard.py create mode 100755 test/overlay/fakemods/mmgen/swap/proto/thorchain/midgard.py diff --git a/mmgen/amt.py b/mmgen/amt.py index 421303f8..a071ccb5 100755 --- a/mmgen/amt.py +++ b/mmgen/amt.py @@ -22,6 +22,7 @@ amt: MMGen CoinAmt and related classes from decimal import Decimal from .objmethods import Hilite, InitErrors +from .obj import get_obj class CoinAmt(Decimal, Hilite, InitErrors): # abstract class """ @@ -155,6 +156,9 @@ class CoinAmt(Decimal, Hilite, InitErrors): # abstract class def __mod__(self, *args, **kwargs): self.method_not_implemented() +def is_coin_amt(proto, num, from_unit=None, from_decimal=False): + return get_obj(proto.coin_amt, num=num, from_unit=from_unit, from_decimal=from_decimal, silent=True, return_bool=True) + class BTCAmt(CoinAmt): coin = 'BTC' max_prec = 8 diff --git a/mmgen/data/version b/mmgen/data/version index 6ad6249c..86ce4c31 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -15.1.dev16 +15.1.dev17 diff --git a/mmgen/exception.py b/mmgen/exception.py index 1856332c..45f280dc 100755 --- a/mmgen/exception.py +++ b/mmgen/exception.py @@ -70,6 +70,7 @@ class ExtensionModuleError(Exception): mmcode = 2 class MoneroMMGenTXFileParseError(Exception): mmcode = 2 class AutosignTXError(Exception): mmcode = 2 class MMGenImportError(Exception): mmcode = 2 +class SwapMemoParseError(Exception): mmcode = 2 # 3: yellow hl, 'MMGen Error' + exception + message class RPCFailure(Exception): mmcode = 3 diff --git a/mmgen/help/swaptxcreate.py b/mmgen/help/swaptxcreate.py new file mode 100755 index 00000000..bc8e5947 --- /dev/null +++ b/mmgen/help/swaptxcreate.py @@ -0,0 +1,82 @@ +#!/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 + +""" +help.swaptxcreate: swaptxcreate and swaptxdo help notes for the MMGen Wallet suite +""" + +def help(proto, cfg): + return """ +This script is similar in operation to ‘mmgen-txcreate’, only with additional +steps. Users are advised to first familiarize themselves with the use of that +script before attempting to perform a swap with this one. + +The tracking wallets of both the send and receive coins must be available when +the script is invoked. If the two coin daemons are running on different hosts +than the script, or with non-standard ports, coin-specific RPC options may be +required (see EXAMPLES below). + +The swap protocol’s quote server on the Internet must be reachable either +directly or via the SOCKS5 proxy specified with the --proxy option. To improve +privacy, it’s recommended to proxy requests to the quote server via Tor or +some other anonymity network. + +The resulting transaction file is saved, signed, sent, and optionally bumped, +exactly the same way as one created with ‘mmgen-txcreate’. Autosign with +automount is likewise supported via the --autosign option. + +The command line must contain at minimum a send coin (COIN1) and receive coin +(COIN2) symbol. Currently supported coins are BTC, LTC and BCH. All other +arguments are optional. If AMT is specified, the specified value of send coin +will be swapped and the rest returned to a change address in the originating +tracking wallet. Otherwise, the entire value of the interactively selected +inputs will be swapped. + +By default, the change and destination addresses are chosen automatically by +finding the lowest-indexed unused addresses of the preferred address types in +the send and receive tracking wallets. Types ‘B’, ‘S’ and ‘C’ (see ADDRESS +TYPES below) are searched in that order for unused addresses. + +If the wallet contains eligible unused addresses with multiple Seed IDs, the +user will be presented with a list of the lowest-indexed addresses of +preferred type for each Seed ID and prompted to choose from among them. + +Change and destination addresses may also be specified manually with the +CHG_ADDR and ADDR arguments. These may be given as full MMGen IDs or in the +form ADDRTYPE_CODE or SEED_ID:ADDRTYPE_CODE (see EXAMPLES below and the +‘mmgen-txcreate’ help screen for details). + +While discouraged, sending change or swapping to non-wallet addresses is also +supported, in which case the signing script (‘mmgen-txsign’ or ‘mmgen- +autosign’, as applicable) must be invoked with the --allow-non-wallet-swap +option. + +Rather than specifying a transaction fee on the command line, it’s advisable +to start with the fee suggested by the swap protocol quote server (the script +does this automatically) and then adjust the fee interactively if desired. + +When choosing a fee, bear in mind that the longer the transaction remains +unconfirmed, the greater the risk that the vault address will expire, leading +to loss of funds. It’s therefore advisable to learn how to create, sign and +send replacement transactions with ‘mmgen-txbump’ before performing a swap +with this script. When bumping a stuck swap transaction, the safest option +is to create a replacement transaction with one output that returns funds back +to the originating tracking wallet, thus aborting the swap, rather than one +that merely increases the fee (see EXAMPLES below). + +Before broadcasting the transaction, it’s advisable to double-check the vault +address on a block explorer such as thorchain.net or runescan.io. + +The MMGen Node Tools suite contains two useful tools to help with fine-tuning +transaction fees, ‘mmnode-feeview’ and ‘mmnode-blocks-info’, in addition to +‘mmnode-ticker’, which can be used to calculate the current cross-rate between +the asset pair of a swap, as well as the total receive value in terms of the +send value. +""" diff --git a/mmgen/help/swaptxcreate_examples.py b/mmgen/help/swaptxcreate_examples.py new file mode 100755 index 00000000..6b5fda37 --- /dev/null +++ b/mmgen/help/swaptxcreate_examples.py @@ -0,0 +1,72 @@ +#!/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 + +""" +help.swaptxcreate_examples: swaptxcreate and swaptxdo help examples for the MMGen Wallet suite +""" + +from ..cfg import gc + +def help(proto, cfg): + + return f""" +EXAMPLES: + + Create a BTC-to-LTC swap transaction, prompting the user for transaction + inputs. The full value of the inputs, minus miner fees, will be swapped + and sent to an unused address in the user’s LTC tracking wallet: + + $ {gc.prog_name} BTC LTC + + Same as above, but swap 0.123 BTC, minus miner fees, and send the change to + an unused address in the BTC tracking wallet: + + $ {gc.prog_name} BTC 0.123 LTC + + Same as above, but specify that the change address be a Segwit P2SH (‘S’) + address: + + $ {gc.prog_name} BTC 0.123 S LTC + + Same as above, but additionally specify that the destination LTC address be + a compressed P2PKH (‘C’) address: + + $ {gc.prog_name} BTC 0.123 S LTC C + + Same as above, but specify the BTC change address explicitly and the + destination LTC address by Seed ID and address type: + + $ {gc.prog_name} BTC 0.123 BEADCAFE:S:6 LTC BEADCAFE:C + + Abort the above swap by creating a replacement transaction that returns the + funds to the originating tracking wallet (omit the transaction filename if + using --autosign): + + $ mmgen-txbump BEADCAFE:S:6 [raw transaction file] + + Swap 0.123 BTC to a non-wallet address (not recommended): + + $ {gc.prog_name} BTC 0.123 LTC ltc1qaq8t3pakcftpk095tnqfv5cmmczysls0xx9388 + + Create an LTC-to-BCH swap transaction, with the Litecoin daemon running on + host ‘orion’ and Bitcoin Cash Node daemon on host ‘gemini’ with non-standard + RPC port 8332. Communicate with the swap quote server via Tor. + + $ {gc.prog_name} --ltc-rpc-host=orion --bch-rpc-host=gemini --bch-rpc-port=8332 --proxy=localhost:9050 LTC BCH + + After sending, check the status of the above swap’s LTC deposit transaction + (omit the transaction filename if using --autosign): + + $ mmgen-txsend --ltc-rpc-host=orion --status [transaction file] + + Check whether the funds have arrived in the BCH destination wallet: + + $ mmgen-tool --coin=bch --bch-rpc-host=gemini twview minconf=0 +""" diff --git a/mmgen/main_autosign.py b/mmgen/main_autosign.py index b70f0996..b401d52e 100755 --- a/mmgen/main_autosign.py +++ b/mmgen/main_autosign.py @@ -57,6 +57,8 @@ opts_data = { -v, --verbose Produce more verbose output -w, --wallet-dir=D Specify an alternate wallet dir (default: {asi.dfl_wallet_dir!r}) +-W, --allow-non-wallet-swap Allow signing of swap transactions that send funds + to non-wallet addresses -x, --xmrwallets=L Range or list of wallets to be used for XMR autosigning """, 'notes': """ diff --git a/mmgen/main_txbump.py b/mmgen/main_txbump.py index 5f288e49..74be793f 100755 --- a/mmgen/main_txbump.py +++ b/mmgen/main_txbump.py @@ -79,6 +79,9 @@ opts_data = { -- -s, --send Sign and send the transaction (the default if seed + data is provided) -- -v, --verbose Produce more verbose output + -- -W, --allow-non-wallet-swap Allow signing of swap transactions that send funds + + to non-wallet addresses + -- -x, --proxy=P Fetch the swap quote via SOCKS5 proxy ‘P’ (host:port) -- -y, --yes Answer 'yes' to prompts, suppress non-essential output -- -z, --show-hash-presets Show information on available hash presets """, @@ -95,6 +98,11 @@ identical to that of ‘mmgen-txcreate’. The user should take care to select a fee sufficient to ensure the original transaction is replaced in the mempool. +When bumping a swap transaction, the swap protocol’s quote server on the +Internet must be reachable either directly or via the SOCKS5 proxy specified +with the --proxy option. To improve privacy, it’s recommended to proxy +requests to the quote server via Tor or some other anonymity network. + {e} {s} Seed source files must have the canonical extensions listed in the 'FileExt' diff --git a/mmgen/main_txcreate.py b/mmgen/main_txcreate.py index 19601897..fe1f634a 100755 --- a/mmgen/main_txcreate.py +++ b/mmgen/main_txcreate.py @@ -73,6 +73,7 @@ opts_data = { + Choices: {x_all}) -- -v, --verbose Produce more verbose output b- -V, --vsize-adj= f Adjust transaction's estimated vsize by factor 'f' + -s -x, --proxy=P Fetch the swap quote via SOCKS5 proxy ‘P’ (host:port) -- -y, --yes Answer 'yes' to prompts, suppress non-essential output e- -X, --cached-balances Use cached balances """, @@ -80,7 +81,7 @@ opts_data = { }, 'code': { 'usage': lambda cfg, proto, help_notes, s: s.format( - u_args = help_notes('txcreate_args')), + u_args = help_notes(f'{target}create_args')), 'options': lambda cfg, proto, help_notes, s: s.format( cfg = cfg, cu = proto.coin, @@ -92,10 +93,10 @@ opts_data = { 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_mod, help_notes, s: s.format( - c = help_mod('txcreate'), + c = help_mod(f'{target}create'), F = help_notes('fee'), n_at = help_notes('address_types'), - x = help_mod('txcreate_examples')) + x = help_mod(f'{target}create_examples')) } } diff --git a/mmgen/main_txdo.py b/mmgen/main_txdo.py index 759a31fe..5d1dcefa 100755 --- a/mmgen/main_txdo.py +++ b/mmgen/main_txdo.py @@ -93,6 +93,7 @@ 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, --proxy=P Fetch the swap quote via SOCKS5 proxy ‘P’ (host:port) 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 @@ -114,7 +115,7 @@ column below: }, 'code': { 'usage': lambda cfg, proto, help_notes, s: s.format( - u_args = help_notes('txcreate_args')), + u_args = help_notes(f'{target}create_args')), 'options': lambda cfg, proto, help_notes, s: s.format( gc = gc, cfg = cfg, @@ -134,12 +135,12 @@ column below: 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_mod, help_notes, s: s.format( - c = help_mod('txcreate'), + c = help_mod(f'{target}create'), F = help_notes('fee'), n_at = help_notes('address_types'), f = help_notes('fmt_codes'), s = help_mod('txsign'), - x = help_mod('txcreate_examples')) + x = help_mod(f'{target}create_examples')) } } diff --git a/mmgen/main_txsend.py b/mmgen/main_txsend.py index 9b0ee4ca..aed132bd 100755 --- a/mmgen/main_txsend.py +++ b/mmgen/main_txsend.py @@ -110,6 +110,9 @@ async def main(): tx.info.view_with_prompt('View transaction details?', pause=False) sys.exit(retval) + if tx.is_swap: + tx.check_swap_expiry() + if not cfg.yes: tx.info.view_with_prompt('View transaction details?') if tx.add_comment(): # edits an existing comment, returns true if changed diff --git a/mmgen/main_txsign.py b/mmgen/main_txsign.py index 258af5a3..c9fcb751 100755 --- a/mmgen/main_txsign.py +++ b/mmgen/main_txsign.py @@ -67,6 +67,8 @@ opts_data = { wallet is scanned for subseeds. -v, --verbose Produce more verbose output -V, --vsize-adj= f Adjust transaction's estimated vsize by factor 'f' +-W, --allow-non-wallet-swap Allow signing of swap transactions that send funds + to non-wallet addresses -y, --yes Answer 'yes' to prompts, suppress non-essential output """, 'notes': """ diff --git a/mmgen/proto/bch/params.py b/mmgen/proto/bch/params.py index 10662b05..d1851903 100755 --- a/mmgen/proto/bch/params.py +++ b/mmgen/proto/bch/params.py @@ -21,6 +21,7 @@ from .cashaddr import cashaddr_decode_addr, cashaddr_encode_addr, cashaddr_addr_ class mainnet(mainnet): is_fork_of = 'Bitcoin' mmtypes = ('L', 'C') + preferred_mmtypes = ('C',) sighash_type = 'ALL|FORKID' forks = [ _finfo(478559, '000000000000000000651ef99cb9fcbe0dadde1d424bd9f15ff20136191a5eec', 'BTC', False) diff --git a/mmgen/proto/btc/params.py b/mmgen/proto/btc/params.py index 92b2f0f8..815d0e12 100755 --- a/mmgen/proto/btc/params.py +++ b/mmgen/proto/btc/params.py @@ -26,6 +26,7 @@ class mainnet(CoinProtocol.Secp256k1): # chainparams.cpp addr_len = 20 wif_ver_num = {'std': '80'} mmtypes = ('L', 'C', 'S', 'B') + preferred_mmtypes = ('B', 'S', 'C') dfl_mmtype = 'L' coin_amt = 'BTCAmt' max_tx_fee = 0.003 diff --git a/mmgen/proto/btc/tx/base.py b/mmgen/proto/btc/tx/base.py index 43a7db74..b5ebc634 100755 --- a/mmgen/proto/btc/tx/base.py +++ b/mmgen/proto/btc/tx/base.py @@ -250,7 +250,10 @@ class Base(TxBase): # DATA: opcode_byte ('6a') + push_byte + nulldata_bytes return sum( {'p2pkh':34, 'p2sh':32, 'bech32':31}[o.addr.addr_fmt] if o.addr else - (11 + len(o.data)) + (11 + len(o.data)) if o.data else + # guess value if o.addr is missing (probably a vault address): + 34 if self.proto.coin == 'BCH' else + 31 for o in self.outputs) # https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki @@ -295,6 +298,17 @@ class Base(TxBase): getattr(self.proto.coin_amt, to_unit) / self.estimate_size())) + @property + def data_output(self): + res = self.data_outputs + if len(res) > 1: + raise ValueError(f'{res}: too many data outputs in transaction (only one allowed)') + return res[0] if len(res) == 1 else None + + @property + def data_outputs(self): + return [o for o in self.outputs if o.data] + @property def nondata_outputs(self): return [o for o in self.outputs if not o.data] diff --git a/mmgen/proto/btc/tx/bump.py b/mmgen/proto/btc/tx/bump.py index 661f578b..61e5f6b6 100755 --- a/mmgen/proto/btc/tx/bump.py +++ b/mmgen/proto/btc/tx/bump.py @@ -14,11 +14,11 @@ proto.btc.tx.bump: Bitcoin transaction bump class from ....tx import bump as TxBase from ....util import msg -from .new import New +from .new_swap import NewSwap from .completed import Completed from .unsigned import AutomountUnsigned -class Bump(Completed, New, TxBase.Bump): +class Bump(Completed, NewSwap, TxBase.Bump): desc = 'fee-bumped transaction' def get_orig_rel_fee(self): diff --git a/mmgen/proto/btc/tx/completed.py b/mmgen/proto/btc/tx/completed.py index f5b3a1e5..0d3d3587 100755 --- a/mmgen/proto/btc/tx/completed.py +++ b/mmgen/proto/btc/tx/completed.py @@ -14,7 +14,7 @@ proto.btc.tx.completed: Bitcoin completed transaction class from ....tx import completed as TxBase from ....obj import HexStr -from ....util import msg, die +from ....util import msg, ymsg, die from .base import Base, decodeScriptPubKey class Completed(Base, TxBase.Completed): @@ -43,6 +43,24 @@ class Completed(Base, TxBase.Completed): assert (200 < len(ti['scriptSig']) < 300), 'malformed scriptSig' # VERY rough check return True + def check_swap_memo(self): + if o := self.data_output: + from ....swap.proto.thorchain.memo import Memo + if Memo.is_partial_memo(o.data): + from ....protocol import init_proto + p = Memo.parse(o.data) + assert p.function == 'SWAP', f'‘{p.function}’: unsupported function in swap memo ‘{o.data}’' + assert p.chain == p.asset, f'{p.chain} != {p.asset}: chain/asset mismatch in swap memo ‘{o.data}’' + proto = init_proto(self.cfg, p.asset, network=self.cfg.network, need_amt=True) + if self.swap_recv_addr_mmid: + mmid = self.swap_recv_addr_mmid + elif self.cfg.allow_non_wallet_swap: + ymsg('Warning: allowing swap to non-wallet address (--allow-non-wallet-swap)') + mmid = None + else: + raise ValueError('Swap to non-wallet address forbidden (override with --allow-non-wallet-swap)') + return self.Output(proto, addr=p.address, mmid=mmid, amt=proto.coin_amt('0')) + def check_pubkey_scripts(self): for n, i in enumerate(self.inputs, 1): ds = decodeScriptPubKey(self.proto, i.scriptPubKey) diff --git a/mmgen/proto/btc/tx/info.py b/mmgen/proto/btc/tx/info.py index ebba85ae..253e8c12 100755 --- a/mmgen/proto/btc/tx/info.py +++ b/mmgen/proto/btc/tx/info.py @@ -65,7 +65,7 @@ class TxInfo(TxInfo): append_color='green') else: return MMGenID.fmtc( - nonmm_str, + '[vault address]' if not is_input and e.is_vault else nonmm_str, width = max_mmwid, color = True) diff --git a/mmgen/proto/btc/tx/new.py b/mmgen/proto/btc/tx/new.py index 49cbf329..4594c61c 100755 --- a/mmgen/proto/btc/tx/new.py +++ b/mmgen/proto/btc/tx/new.py @@ -125,14 +125,17 @@ class New(Base, TxNew): def final_inputs_ok_msg(self, funds_left): return 'Transaction produces {} {} in change'.format(funds_left.hl(), self.coin) - def check_chg_addr_is_wallet_addr(self, message='Change address is not an MMGen wallet address!'): + def check_chg_addr_is_wallet_addr(self, output=None, message='Change address is not an MMGen wallet address!'): def do_err(): from ....ui import confirm_or_raise confirm_or_raise( cfg = self.cfg, message = yellow(message), action = 'Are you sure this is what you want?') - if len(self.nondata_outputs) > 1 and not self.chg_output.mmid: + if output: + if not output.mmid: + do_err() + elif len(self.nondata_outputs) > 1 and not self.chg_output.mmid: do_err() async def create_serialized(self, locktime=None): diff --git a/mmgen/proto/btc/tx/new_swap.py b/mmgen/proto/btc/tx/new_swap.py index b8124a61..e40cef5e 100755 --- a/mmgen/proto/btc/tx/new_swap.py +++ b/mmgen/proto/btc/tx/new_swap.py @@ -12,12 +12,135 @@ proto.btc.tx.new_swap: Bitcoin new swap transaction class """ +from collections import namedtuple + +from ....cfg import gc from ....tx.new_swap import NewSwap as TxNewSwap from .new import New class NewSwap(New, TxNewSwap): desc = 'Bitcoin swap transaction' - async def process_swap_cmdline_args(self, cmd_args, addrfile_args): - import sys - sys.exit(0) + async def get_swap_output(self, proto, arg, addrfiles, desc): + ret = namedtuple('swap_output', ['coin', 'network', 'addr', 'mmid']) + if arg: + from ..addrdata import TwAddrData + pa = self.parse_cmdline_arg( + proto, + arg, + self.get_addrdata_from_files(proto, addrfiles), + await TwAddrData(self.cfg, proto, twctl=None)) # TODO: twctl required for Ethereum + if pa.addr: + await self.warn_addr_used(proto, pa, desc) + return ret(proto.coin, proto.network, pa.addr, pa.mmid) + + full_desc = '{} on the {} {} network'.format(desc, proto.coin, proto.network) + res = await self.get_autochg_addr(proto, arg, exclude=[], desc=full_desc, all_addrtypes=not arg) + self.confirm_autoselected_addr(res.twmmid, full_desc) + return ret(proto.coin, proto.network, res.addr, res.twmmid) + + async def process_swap_cmdline_args(self, cmd_args, addrfiles): + + from ....protocol import init_proto + import importlib + sp = importlib.import_module(f'mmgen.swap.proto.{self.swap_proto}') + + class CmdlineArgs: # listed in command-line order + # send_coin # required: uppercase coin symbol + send_amt = None # optional: Omit to skip change addr and send value of all inputs minus fees to vault + chg_spec = None # optional: change address spec, e.g. ‘B’ ‘DEADBEEF:B’ ‘DEADBEEF:B:1’ or coin address. + # Omit for autoselected change address. Use of non-wallet change address + # will emit warning and prompt user for confirmation + # recv_coin # required: uppercase coin symbol + recv_spec = None # optional: destination address spec. Same rules as for chg_spec + + def check_coin_arg(coin, desc): + if coin not in sp.params.coins[desc]: + raise ValueError(f'{coin!r}: unsupported {desc} coin for {gc.proj_name} {sp.name} swap') + return coin + + def get_arg(): + try: + return args_in.pop(0) + except: + self.cfg._usage() + + def init_proto_from_coin(coinsym, desc): + return init_proto( + self.cfg, + check_coin_arg(coinsym, desc), + network = self.proto.network, + need_amt = True) + + def parse(): + + from ....amt import is_coin_amt + arg = get_arg() + + # arg 1: send_coin + self.send_proto = init_proto_from_coin(arg, 'send') + arg = get_arg() + + # arg 2: amt + if is_coin_amt(self.send_proto, arg): + args.send_amt = self.send_proto.coin_amt(arg) + arg = get_arg() + + # arg 3: chg_spec (change address spec) + if args.send_amt: + if not arg in sp.params.coins['receive']: # is change arg + args.chg_spec = arg + arg = get_arg() + + # arg 4: recv_coin + self.recv_proto = init_proto_from_coin(arg, 'receive') + + # arg 5: recv_spec (receive address spec) + if args_in: + args.recv_spec = get_arg() + + if args_in: # done parsing, all args consumed + self.cfg._usage() + + args_in = list(cmd_args) + args = CmdlineArgs() + parse() + + chg_output = ( + await self.get_swap_output(self.send_proto, args.chg_spec, addrfiles, 'change address') + if args.send_amt else None) + + if chg_output: + self.check_chg_addr_is_wallet_addr(chg_output) + + recv_output = await self.get_swap_output(self.recv_proto, args.recv_spec, addrfiles, 'destination address') + + self.check_chg_addr_is_wallet_addr( + recv_output, + message = ( + 'Swap destination address is not an MMGen wallet address!\n' + 'To sign this transaction, autosign or txsign must be invoked with --allow-non-wallet-swap')) + + memo = sp.data(self.recv_proto, recv_output.addr) + + # this goes into the transaction file: + self.swap_recv_addr_mmid = recv_output.mmid + + return ( + [f'vault,{args.send_amt}', chg_output.mmid, f'data:{memo}'] if args.send_amt else + ['vault', f'data:{memo}']) + + def update_vault_addr(self, addr): + vault_idx = self.vault_idx + assert vault_idx == 0, f'{vault_idx}: vault index is not zero!' + o = self.outputs[vault_idx]._asdict() + o['addr'] = addr + self.outputs[vault_idx] = self.Output(self.proto, **o) + + @property + def vault_idx(self): + return self._chg_output_ops('idx', 'is_vault') + + @property + def vault_output(self): + return self._chg_output_ops('output', 'is_vault') diff --git a/mmgen/proto/eth/params.py b/mmgen/proto/eth/params.py index 9ec72904..db78bae6 100755 --- a/mmgen/proto/eth/params.py +++ b/mmgen/proto/eth/params.py @@ -21,6 +21,7 @@ class mainnet(CoinProtocol.DummyWIF, CoinProtocol.Secp256k1): network_names = _nw('mainnet', 'testnet', 'devnet') addr_len = 20 mmtypes = ('E',) + preferred_mmtypes = ('E',) dfl_mmtype = 'E' mod_clsname = 'Ethereum' pubkey_type = 'std' # required by DummyWIF diff --git a/mmgen/proto/eth/tx/completed.py b/mmgen/proto/eth/tx/completed.py index 54cda630..d6f8e2f5 100755 --- a/mmgen/proto/eth/tx/completed.py +++ b/mmgen/proto/eth/tx/completed.py @@ -27,6 +27,9 @@ class Completed(Base, TxBase.Completed): self.gas = self.proto.coin_amt(self.dfl_gas, from_unit='wei') self.start_gas = self.proto.coin_amt(self.dfl_start_gas, from_unit='wei') + def check_swap_memo(self): + pass + @property def send_amt(self): return self.outputs[0].amt if self.outputs else self.proto.coin_amt('0') diff --git a/mmgen/swap/proto/thorchain/__init__.py b/mmgen/swap/proto/thorchain/__init__.py new file mode 100755 index 00000000..211180a8 --- /dev/null +++ b/mmgen/swap/proto/thorchain/__init__.py @@ -0,0 +1,25 @@ +#!/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 + +""" +swap.proto.thorchain: THORChain swap protocol implementation for the MMGen Wallet suite +""" + +__all__ = ['params', 'data'] + +name = 'THORChain' + +from .params import params + +from .memo import Memo as data + +def rpc_client(tx, amt): + from .midgard import Midgard + return Midgard(tx, amt) diff --git a/mmgen/swap/proto/thorchain/memo.py b/mmgen/swap/proto/thorchain/memo.py new file mode 100755 index 00000000..2d6e89cf --- /dev/null +++ b/mmgen/swap/proto/thorchain/memo.py @@ -0,0 +1,134 @@ +#!/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 + +""" +swap.proto.thorchain.memo: THORChain swap protocol memo class +""" + +from . import name as proto_name + +class Memo: + + # The trade limit, i.e., set 100000000 to get a minimum of 1 full asset, else a refund + # Optional. 1e8 or scientific notation + trade_limit = 0 + + # Swap interval in blocks. Optional. If 0, do not stream + stream_interval = 1 + + # Swap quantity. The interval value determines the frequency of swaps in blocks + # Optional. If 0, network will determine the number of swaps + stream_quantity = 0 + + max_len = 250 + function = 'SWAP' + + asset_abbrevs = { + 'BTC.BTC': 'b', + 'LTC.LTC': 'l', + 'BCH.BCH': 'c', + 'ETH.ETH': 'e', + 'DOGE.DOGE': 'd', + 'THOR.RUNE': 'r', + } + + function_abbrevs = { + 'SWAP': '=', + } + + @classmethod + def is_partial_memo(cls, s): + import re + ops = { + 'swap': ('SWAP', 's', '='), + 'add': ('ADD', 'a', r'\+'), + 'withdraw': ('WITHDRAW', 'wd', '-'), + 'loan': (r'LOAN(\+|-)', r'\$(\+|-)'), # open/repay + 'pool': (r'POOL(\+|-)',), + 'trade': (r'TRADE(\+|-)',), + 'secure': (r'SECURE(\+|-)',), + 'misc': ('BOND', 'UNBOND', 'LEAVE', 'MIGRATE', 'NOOP', 'DONATE', 'RESERVE'), + } + pat = r'^(' + '|'.join('|'.join(pats) for pats in ops.values()) + r'):\S\S+' + return bool(re.search(pat, str(s))) + + @classmethod + def parse(cls, s): + """ + All fields are validated, excluding address (cannot validate, since network is unknown) + """ + from collections import namedtuple + from ....exception import SwapMemoParseError + from ....util import is_int + + def get_item(desc): + try: + return fields.pop(0) + except IndexError as e: + raise SwapMemoParseError(f'malformed {proto_name} memo (missing {desc} field)') from e + + def get_id(data, item, desc): + if item in data: + return item + rev_data = {v:k for k,v in data.items()} + if item in rev_data: + return rev_data[item] + raise SwapMemoParseError(f'{item!r}: unrecognized {proto_name} {desc} abbreviation') + + fields = str(s).split(':') + + if len(fields) < 4: + raise SwapMemoParseError('memo must contain at least 4 comma-separated fields') + + function = get_id(cls.function_abbrevs, get_item('function'), 'function') + + chain, asset = get_id(cls.asset_abbrevs, get_item('asset'), 'asset').split('.') + + address = get_item('address') + + desc = 'trade_limit/stream_interval/stream_quantity' + lsq = get_item(desc) + + try: + limit, interval, quantity = lsq.split('/') + except ValueError as e: + raise SwapMemoParseError(f'malformed memo (failed to parse {desc} field) [{lsq}]') from e + + for n in (limit, interval, quantity): + if not is_int(n): + raise SwapMemoParseError(f'malformed memo (non-integer in {desc} field [{lsq}])') + + if fields: + raise SwapMemoParseError('malformed memo (unrecognized extra data)') + + ret = namedtuple( + 'parsed_memo', + ['proto', 'function', 'chain', 'asset', 'address', 'trade_limit', 'stream_interval', 'stream_quantity']) + + return ret(proto_name, function, chain, asset, address, int(limit), int(interval), int(quantity)) + + def __init__(self, proto, addr, chain=None): + self.proto = proto + self.chain = chain or proto.coin + from ....addr import CoinAddr + assert isinstance(addr, CoinAddr) + self.addr = addr.views[addr.view_pref] + assert not ':' in self.addr # colon is record separator, so address mustn’t contain one + + def __str__(self): + suf = '/'.join(str(n) for n in (self.trade_limit, self.stream_interval, self.stream_quantity)) + asset = f'{self.chain}.{self.proto.coin}' + ret = ':'.join([ + self.function_abbrevs[self.function], + self.asset_abbrevs[asset], + self.addr, + suf]) + assert len(ret) <= self.max_len, f'{proto_name} memo exceeds maximum length of {self.max_len}' + return ret diff --git a/mmgen/swap/proto/thorchain/midgard.py b/mmgen/swap/proto/thorchain/midgard.py new file mode 100755 index 00000000..46f04284 --- /dev/null +++ b/mmgen/swap/proto/thorchain/midgard.py @@ -0,0 +1,113 @@ +#!/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 + +""" +swap.proto.thorchain.midgard: THORChain swap protocol network query ops +""" + +import json + +class MidgardRPCClient: + + http_hdrs = {'Content-Type': 'application/json'} + proto = 'https' + host = 'thornode.ninerealms.com' + verify = True + timeout = 5 + + def __init__(self, tx, proto=None, host=None): + self.cfg = tx.cfg + if proto: + self.proto = proto + if host: + self.host = host + import requests + self.session = requests.Session() + self.session.trust_env = False # ignore *_PROXY environment vars + self.session.headers = self.http_hdrs + if self.cfg.proxy: + self.session.proxies.update({ + 'http': f'socks5h://{self.cfg.proxy}', + 'https': f'socks5h://{self.cfg.proxy}' + }) + + def get(self, path, timeout=None): + return self.session.get( + url = self.proto + '://' + self.host + path, + timeout = timeout or self.timeout, + verify = self.verify) + +class Midgard: + + def __init__(self, tx, amt): + self.tx = tx + self.in_amt = amt + self.rpc = MidgardRPCClient(tx) + + def get_quote(self): + self.get_str = '/thorchain/quote/swap?from_asset={a}.{a}&to_asset={b}.{b}&amount={c}'.format( + a = self.tx.send_proto.coin, + b = self.tx.recv_proto.coin, + c = self.in_amt.to_unit('satoshi')) + self.result = self.rpc.get(self.get_str) + self.data = json.loads(self.result.content) + + def format_quote(self): + from ....util import make_timestr, pp_fmt, die + from ....util2 import format_elapsed_hr + from ....color import blue, cyan, pink, orange + from . import name + + d = self.data + if not 'expiry' in d: + die(2, pp_fmt(d)) + tx = self.tx + in_coin = tx.send_proto.coin + out_coin = tx.recv_proto.coin + out_amt = tx.recv_proto.coin_amt(int(d['expected_amount_out']), from_unit='satoshi') + min_in_amt = tx.send_proto.coin_amt(int(d['recommended_min_amount_in']), from_unit='satoshi') + gas_unit = { + 'satsperbyte': 'sat/byte', + }.get(d['gas_rate_units'], d['gas_rate_units']) + elapsed_disp = format_elapsed_hr(d['expiry'], future_msg='from now') + fees = d['fees'] + fees_t = tx.recv_proto.coin_amt(int(fees['total']), from_unit='satoshi') + fees_pct_disp = str(fees['total_bps'] / 100) + '%' + slip_pct_disp = str(fees['slippage_bps'] / 100) + '%' + hdr = f'SWAP QUOTE (source: {self.rpc.host})' + return f""" +{cyan(hdr)} + Protocol: {blue(name)} + Direction: {orange(f'{in_coin} => {out_coin}')} + Vault address: {cyan(d['inbound_address'])} + Quote expires: {pink(elapsed_disp)} [{make_timestr(d['expiry'])}] + Amount in: {self.in_amt.hl()} {in_coin} + Expected amount out: {out_amt.hl()} {out_coin} + Rate: {(out_amt / self.in_amt).hl()} {out_coin}/{in_coin} + Reverse rate: {(self.in_amt / out_amt).hl()} {in_coin}/{out_coin} + Recommended minimum in amount: {min_in_amt.hl()} {in_coin} + Recommended fee: {pink(d['recommended_gas_rate'])} {pink(gas_unit)} + Fees: + Total: {fees_t.hl()} {out_coin} ({pink(fees_pct_disp)}) + Slippage: {pink(slip_pct_disp)} +""" + + @property + def inbound_address(self): + return self.data['inbound_address'] + + @property + def rel_fee_hint(self): + if self.data['gas_rate_units'] == 'satsperbyte': + return f'{self.data["recommended_gas_rate"]}s' + + def __str__(self): + from pprint import pformat + return pformat(self.data) diff --git a/mmgen/swap/proto/thorchain/params.py b/mmgen/swap/proto/thorchain/params.py new file mode 100755 index 00000000..55343cd5 --- /dev/null +++ b/mmgen/swap/proto/thorchain/params.py @@ -0,0 +1,28 @@ +#!/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 + +""" +swap.proto.thorchain.params: THORChain swap protocol parameters +""" + +class params: + + coins = { + 'send': { + 'BTC': 'Bitcoin', + 'LTC': 'Litecoin', + 'BCH': 'Bitcoin Cash', + }, + 'receive': { + 'BTC': 'Bitcoin', + 'LTC': 'Litecoin', + 'BCH': 'Bitcoin Cash', + } + } diff --git a/mmgen/tw/addresses.py b/mmgen/tw/addresses.py index 64367eeb..8ea225be 100755 --- a/mmgen/tw/addresses.py +++ b/mmgen/tw/addresses.py @@ -338,6 +338,8 @@ class TwAddresses(TwView): Find the lowest-indexed change addresses in tracking wallet of given address type, present them in a menu and return a single change address chosen by the user. + If mmtype is None, search all preferred_mmtypes in tracking wallet + Return values on failure: None: no addresses in wallet of requested address type False: no unused addresses in wallet of requested address type @@ -363,10 +365,24 @@ class TwAddresses(TwView): return addrs[int(res)-1] msg(f'{res}: invalid entry') - assert isinstance(mmtype, MMGenAddrType) + def get_addr(mmtype): + return [self.get_change_address(f'{sid}:{mmtype}', r.bot, r.top, exclude=exclude, desc=desc) + for sid, r in self.sid_ranges.items()] - res = [self.get_change_address(f'{sid}:{mmtype}', r.bot, r.top, exclude) - for sid, r in self.sid_ranges.items()] + assert isinstance(mmtype, (type(None), MMGenAddrType)) + + if mmtype: + res = get_addr(mmtype) + else: + have_used = False + for mmtype in self.proto.preferred_mmtypes: + res = get_addr(mmtype) + if any(res): + break + if False in res: + have_used = True + else: + return False if have_used else None if any(res): res = list(filter(None, res)) diff --git a/mmgen/tx/base.py b/mmgen/tx/base.py index 53af0f3f..b0f309ad 100755 --- a/mmgen/tx/base.py +++ b/mmgen/tx/base.py @@ -81,6 +81,9 @@ class Base(MMGenObject): signed = False is_bump = False is_swap = False + swap_proto = None + swap_quote_expiry = None + swap_recv_addr_mmid = None file_format = 'json' non_mmgen_inputs_msg = f""" This transaction includes inputs with non-{gc.proj_name} addresses. When @@ -100,6 +103,7 @@ class Base(MMGenObject): class Output(MMGenTxIO): is_chg = ListItemAttr(bool, typeconv=False) + is_vault = ListItemAttr(bool, typeconv=False) data = ListItemAttr(None, typeconv=False) # placeholder class InputList(MMGenTxIOList): diff --git a/mmgen/tx/bump.py b/mmgen/tx/bump.py index 8fd36976..327b0279 100755 --- a/mmgen/tx/bump.py +++ b/mmgen/tx/bump.py @@ -12,17 +12,21 @@ tx.bump: transaction bump class """ -from .new import New +from .new_swap import NewSwap from .completed import Completed from ..util import msg, ymsg, is_int, die from ..color import pink -class Bump(Completed, New): +class Bump(Completed, NewSwap): desc = 'fee-bumped transaction' ext = 'rawtx' bump_output_idx = None is_bump = True - swap_attrs = ('is_swap',) + swap_attrs = ( + 'is_swap', + 'swap_proto', + 'swap_quote_expiry', + 'swap_recv_addr_mmid') def __init__(self, *, check_sent, new_outputs, **kwargs): @@ -79,7 +83,16 @@ class Bump(Completed, New): pink(self.fee_abs2rel(self.min_fee)), self.rel_fee_disp)) - self.usr_fee = self.get_usr_fee_interactive(fee=self.cfg.fee, desc='User-selected') + if self.is_swap: + self.send_proto = self.proto + self.recv_proto = self.check_swap_memo().proto + fee_hint = self.update_vault_output(self.send_amt) + else: + fee_hint = None + + self.usr_fee = self.get_usr_fee_interactive( + fee = self.cfg.fee or fee_hint, + desc = 'User-selected' if self.cfg.fee else 'Recommended' if fee_hint else None) self.bump_fee(output_idx, self.usr_fee) diff --git a/mmgen/tx/file.py b/mmgen/tx/file.py index d963a5e1..4a105a6f 100755 --- a/mmgen/tx/file.py +++ b/mmgen/tx/file.py @@ -70,7 +70,10 @@ class MMGenTxFile(MMGenObject): 'comment': MMGenTxComment, 'coin_txid': CoinTxID, 'sent_timestamp': None, - 'is_swap': None} + 'is_swap': None, + 'swap_proto': None, + 'swap_quote_expiry': None, + 'swap_recv_addr_mmid': None} def __init__(self, tx): self.tx = tx diff --git a/mmgen/tx/info.py b/mmgen/tx/info.py index 7d0bd74f..0ba44627 100755 --- a/mmgen/tx/info.py +++ b/mmgen/tx/info.py @@ -15,7 +15,7 @@ tx.info: transaction info class import importlib from ..cfg import gc -from ..color import red, green, cyan, orange +from ..color import red, green, cyan, orange, blue, yellow, magenta from ..util import msg, msg_r, decode_timestamp, make_timestr from ..util2 import format_elapsed_hr @@ -51,7 +51,7 @@ class TxInfo: def gen_view(): yield (self.txinfo_hdr_fs_short if terse else self.txinfo_hdr_fs).format( - hdr = cyan('TRANSACTION DATA'), + hdr = cyan(('SWAP ' if tx.is_swap else '') + 'TRANSACTION DATA'), i = tx.txid.hl(), a = tx.send_amt.hl(), c = tx.dcoin, @@ -72,6 +72,18 @@ class TxInfo: if tx.coin_txid: yield f' {tx.coin} TxID: {tx.coin_txid.hl()}\n' + if tx.is_swap: + from ..swap.proto.thorchain.memo import Memo, proto_name + if Memo.is_partial_memo(tx.data_output.data): + p = Memo.parse(tx.data_output.data) + yield ' {} {}\n'.format(magenta('DEX Protocol:'), blue(proto_name)) + yield ' Swap: {}\n'.format(orange(f'{tx.proto.coin} => {p.asset}')) + yield ' Dest: {}{}\n'.format( + cyan(p.address), + orange(f' ({tx.swap_recv_addr_mmid})') if tx.swap_recv_addr_mmid else '') + if not tx.swap_recv_addr_mmid: + yield yellow(' Warning: swap destination address is not a wallet address!\n') + enl = ('\n', '')[bool(terse)] yield enl diff --git a/mmgen/tx/new.py b/mmgen/tx/new.py index 14b42904..77cd1e6f 100755 --- a/mmgen/tx/new.py +++ b/mmgen/tx/new.py @@ -170,24 +170,27 @@ class New(Base): return False return True - def add_output(self, coinaddr, amt, is_chg=False, data=None): - self.outputs.append(self.Output(self.proto, addr=coinaddr, amt=amt, is_chg=is_chg, data=data)) + def add_output(self, coinaddr, amt, is_chg=False, is_vault=False, data=None): + self.outputs.append( + self.Output(self.proto, addr=coinaddr, amt=amt, is_chg=is_chg, is_vault=is_vault, data=data)) def process_data_output_arg(self, arg): return None def parse_cmdline_arg(self, proto, arg_in, ad_f, ad_w): - _pa = namedtuple('txcreate_cmdline_output', ['arg', 'mmid', 'addr', 'amt', 'data']) + _pa = namedtuple('txcreate_cmdline_output', ['arg', 'mmid', 'addr', 'amt', 'data', 'is_vault']) if data := self.process_data_output_arg(arg_in): - return _pa(arg_in, None, None, None, data) + return _pa(arg_in, None, None, None, data, False) arg, amt = arg_in.split(',', 1) if ',' in arg_in else (arg_in, None) - coin_addr, mmid = (None, None) + coin_addr, mmid, is_vault = (None, None, False) - if mmid := get_obj(MMGenID, proto=proto, id_str=arg, silent=True): + if arg == 'vault' and self.is_swap: + is_vault = True + elif mmid := get_obj(MMGenID, proto=proto, id_str=arg, silent=True): coin_addr = mmaddr2coinaddr(self.cfg, arg, ad_w, ad_f, proto) elif is_coin_addr(proto, arg): coin_addr = CoinAddr(proto, arg) @@ -198,13 +201,16 @@ class New(Base): else: die(2, f'{arg_in}: invalid command-line argument') - return _pa(arg, mmid, coin_addr, amt, None) + return _pa(arg, mmid, coin_addr, amt, None, is_vault) - async def get_autochg_addr(self, proto, arg, exclude, desc): + async def get_autochg_addr(self, proto, arg, exclude, desc, all_addrtypes=False): from ..tw.addresses import TwAddresses al = await TwAddresses(self.cfg, proto, get_data=True) - if obj := get_obj(MMGenAddrType, proto=proto, id_str=arg, silent=True): + if all_addrtypes: + res = al.get_change_address_by_addrtype(None, exclude=exclude, desc=desc) + req_desc = 'of any allowed address type' + elif obj := get_obj(MMGenAddrType, proto=proto, id_str=arg, silent=True): res = al.get_change_address_by_addrtype(obj, exclude=exclude, desc=desc) req_desc = f'of address type {arg!r}' else: @@ -233,14 +239,15 @@ class New(Base): self.add_output(None, self.proto.coin_amt('0'), data=a.data) else: self.add_output( - coinaddr = a.addr or ( + coinaddr = None if a.is_vault else a.addr or ( await self.get_autochg_addr( self.proto, a.arg, exclude = [a.mmid for a in parsed_args if a.mmid], desc = 'change address')).addr, amt = self.proto.coin_amt(a.amt or '0'), - is_chg = not a.amt) + is_chg = not a.amt, + is_vault = a.is_vault) if self.chg_idx is None: die(2, @@ -261,7 +268,7 @@ class New(Base): self.check_dup_addrs('outputs') if self.chg_output is not None: - if self.chg_autoselected: + if self.chg_autoselected and not self.is_swap: # swap TX, so user has already confirmed self.confirm_autoselected_addr(self.chg_output.mmid, 'change address') elif len(self.nondata_outputs) > 1: await self.warn_addr_used(self.proto, self.chg_output, 'change address') @@ -430,8 +437,8 @@ class New(Base): if not do_info: cmd_args, addrfile_args = self.get_addrfiles_from_cmdline(cmd_args) if self.is_swap: - # updates self.proto! - self.proto, cmd_args = await self.process_swap_cmdline_args(cmd_args, addrfile_args) + cmd_args = await self.process_swap_cmdline_args(cmd_args, addrfile_args) + self.proto = self.send_proto # updating self.proto! from ..rpc import rpc_init self.rpc = await rpc_init(self.cfg, self.proto) from ..addrdata import TwAddrData @@ -489,6 +496,9 @@ class New(Base): if not self.cfg.yes: self.add_comment() # edits an existing comment + if self.is_swap: + self.update_vault_output(self.vault_output.amt) + await self.create_serialized(locktime=locktime) # creates self.txid too self.add_timestamp() diff --git a/mmgen/tx/new_swap.py b/mmgen/tx/new_swap.py index b98ea2bb..9dc176d6 100755 --- a/mmgen/tx/new_swap.py +++ b/mmgen/tx/new_swap.py @@ -16,7 +16,27 @@ from .new import New class NewSwap(New): desc = 'swap transaction' - is_swap = True async def process_swap_cmdline_args(self, cmd_args, addrfiles): raise NotImplementedError(f'Swap not implemented for protocol {self.proto.__name__}') + + def update_vault_output(self, amt): + import importlib + sp = importlib.import_module(f'mmgen.swap.proto.{self.swap_proto}') + c = sp.rpc_client(self, amt) + + from ..util import msg + from ..term import get_char + while True: + self.cfg._util.qmsg(f'Retrieving data from {c.rpc.host}...') + c.get_quote() + self.cfg._util.qmsg('OK') + msg(c.format_quote()) + ch = get_char('Press ‘r’ to refresh quote, any other key to continue: ') + msg('') + if ch not in 'Rr': + break + + self.swap_quote_expiry = c.data['expiry'] + self.update_vault_addr(c.inbound_address) + return c.rel_fee_hint diff --git a/mmgen/tx/online.py b/mmgen/tx/online.py index 17e26ed1..3aa24986 100755 --- a/mmgen/tx/online.py +++ b/mmgen/tx/online.py @@ -21,6 +21,22 @@ class OnlineSigned(Signed): from . import _base_proto_subclass return _base_proto_subclass('Status', 'status', self.proto)(self) + def check_swap_expiry(self): + import time + from ..util import msg, make_timestr, die + from ..util2 import format_elapsed_hr + from ..color import pink, yellow + expiry = self.swap_quote_expiry + now = int(time.time()) + t_rem = expiry - now + clr = yellow if t_rem < 0 else pink + msg('Swap quote {a} {b} [{c}]'.format( + a = clr('expired' if t_rem < 0 else 'expires'), + b = clr(format_elapsed_hr(expiry, now=now, future_msg='from now')), + c = make_timestr(expiry))) + if t_rem < 0: + die(2, 'Swap quote has expired. Please re-create the transaction') + def confirm_send(self): from ..util import msg from ..ui import confirm_or_raise diff --git a/mmgen/tx/sign.py b/mmgen/tx/sign.py index d2a2cc68..5da919eb 100755 --- a/mmgen/tx/sign.py +++ b/mmgen/tx/sign.py @@ -178,12 +178,18 @@ async def txsign(cfg_parm, tx, seed_files, kl, kal, tx_num_str='', passwd_file=N sep + sep.join(missing))) keys += tmp.data + sm_output = tx.check_swap_memo() # do this for non-swap transactions too! + if cfg.mmgen_keys_from_file: keys += add_keys('inputs', tx.inputs, keyaddr_list=kal) add_keys('outputs', tx.outputs, keyaddr_list=kal) + if sm_output: + add_keys('swap destination address', [sm_output], keyaddr_list=kal) keys += add_keys('inputs', tx.inputs, seed_files, saved_seeds) add_keys('outputs', tx.outputs, seed_files, saved_seeds) + if sm_output: + add_keys('swap destination address', [sm_output], seed_files, saved_seeds) # this (boolean) attr isn't needed in transaction file tx.delete_attrs('inputs', 'have_wif') diff --git a/setup.cfg b/setup.cfg index 54288866..919441ae 100644 --- a/setup.cfg +++ b/setup.cfg @@ -86,6 +86,9 @@ packages = mmgen.proto.secp256k1 mmgen.proto.xmr mmgen.proto.zec + mmgen.swap + mmgen.swap.proto + mmgen.swap.proto.thorchain mmgen.tool mmgen.tx mmgen.tw diff --git a/test/cmdtest_d/ct_regtest.py b/test/cmdtest_d/ct_regtest.py index 2d553cdb..1b157b3a 100755 --- a/test/cmdtest_d/ct_regtest.py +++ b/test/cmdtest_d/ct_regtest.py @@ -1194,8 +1194,8 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared): t.expect(f'Mined {num_blocks} block') return t - def _do_cli(self, cmd_args, decode_json=False): - return self._do_mmgen_regtest(['cli'] + cmd_args, decode_json=decode_json) + def _do_cli(self, cmd_args, add_opts=[], decode_json=False): + return self._do_mmgen_regtest(add_opts + ['cli'] + cmd_args, decode_json=decode_json) def _do_mmgen_regtest(self, cmd_args, decode_json=False): ret = self.spawn( diff --git a/test/cmdtest_d/ct_swap.py b/test/cmdtest_d/ct_swap.py index 9917671a..c6d19c6b 100755 --- a/test/cmdtest_d/ct_swap.py +++ b/test/cmdtest_d/ct_swap.py @@ -12,39 +12,54 @@ test.cmdtest_d.ct_swap: asset swap tests for the cmdtest.py test suite """ +from pathlib import Path + from mmgen.protocol import init_proto -from ..include.common import gr_uc -from .ct_regtest import CmdTestRegtest, rt_data, dfl_wcls, rt_pw, cfg +from ..include.common import make_burn_addr, gr_uc +from .common import dfl_bip39_file +from .midgard import run_midgard_server + +from .ct_autosign import CmdTestAutosign, CmdTestAutosignThreaded +from .ct_regtest import CmdTestRegtest, rt_data, dfl_wcls, rt_pw, cfg, strip_ansi_escapes sample1 = gr_uc[:24] sample2 = '00010203040506' -class CmdTestSwap(CmdTestRegtest): +def midgard_server_start(): + import threading + t = threading.Thread(target=run_midgard_server, name='Midgard server thread') + t.daemon = True + t.start() + +class CmdTestSwap(CmdTestRegtest, CmdTestAutosignThreaded): bdb_wallet = True networks = ('btc',) tmpdir_nums = [37] passthru_opts = ('rpc_backend',) + coins = ('btc',) + need_daemon = True cmd_group_in = ( - ('setup', 'regtest (Bob and Alice) mode setup'), - ('subgroup.init_bob', []), - ('subgroup.fund_bob', ['init_bob']), - ('subgroup.data', ['fund_bob']), - ('subgroup.swap', ['fund_bob']), - ('stop', 'stopping regtest daemon'), + ('subgroup.init_data', []), + ('subgroup.data', ['init_data']), + ('subgroup.init_swap', []), + ('subgroup.create', ['init_swap']), + ('subgroup.create_bad', ['init_swap']), + ('subgroup.signsend', ['init_swap']), + ('subgroup.signsend_bad', ['init_swap']), + ('subgroup.autosign', ['init_data', 'signsend']), + ('stop', 'stopping regtest daemons'), ) cmd_subgroups = { - 'init_bob': ( - 'creating Bob’s MMGen wallet and tracking wallet', - ('walletgen_bob', 'wallet generation (Bob)'), - ('addrgen_bob', 'address generation (Bob)'), - ('addrimport_bob', 'importing Bob’s addresses'), - ), - 'fund_bob': ( - 'funding Bob’s wallet', - ('fund_bob1', 'funding Bob’s wallet (bech32)'), - ('fund_bob2', 'funding Bob’s wallet (native Segwit)'), - ('bob_bal', 'displaying Bob’s balance'), + 'init_data': ( + 'Initialize regtest setup for OP_RETURN data operations', + ('setup', 'regtest (Bob and Alice) mode setup'), + ('walletcreate_bob', 'wallet creation (Bob)'), + ('addrgen_bob', 'address generation (Bob)'), + ('addrimport_bob', 'importing Bob’s addresses'), + ('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', @@ -58,15 +73,101 @@ class CmdTestSwap(CmdTestRegtest): ('generate3', 'Generate 3 blocks'), ('bob_listaddrs', 'Display Bob’s addresses'), ), - 'swap': ( - 'Swap operations', - ('bob_swaptxcreate1', 'Create a swap transaction'), + 'init_swap': ( + 'Initialize regtest setup for swap operations', + ('setup_send_coin', 'setting up the sending coin regtest blockchain'), + ('walletcreate_bob', 'wallet creation (Bob)'), + ('addrgen_bob_send', 'address generation (Bob, sending coin)'), + ('addrimport_bob_send', 'importing Bob’s addresses (sending coin)'), + ('fund_bob_send', 'funding Bob’s wallet (bech32)'), + ('bob_bal_send', 'displaying Bob’s send balance'), + + ('setup_recv_coin', 'setting up the receiving coin regtest blockchain'), + ('addrgen_bob_recv', 'address generation (Bob, receiving coin)'), + ('addrimport_bob_recv', 'importing Bob’s addresses (receiving coin)'), + ('fund_bob_recv1', 'funding Bob’s wallet (bech32)'), + ('fund_bob_recv2', 'funding Bob’s wallet (native Segwit)'), + ('addrgen_bob_recv_subwallet', 'address generation (Bob, receiving coin)'), + ('addrimport_bob_recv_subwallet', 'importing Bob’s addresses (receiving coin)'), + ('fund_bob_recv_subwallet', 'funding Bob’s subwwallet (native Segwit)'), + ('bob_bal_recv', 'displaying Bob’s receive balance'), + ), + 'create': ( + 'Swap TX create operations (BCH => LTC)', + ('swaptxcreate1', 'creating a swap transaction (full args)'), + ('swaptxcreate2', 'creating a swap transaction (coin args only)'), + ('swaptxcreate3', 'creating a swap transaction (no chg arg)'), + ('swaptxcreate4', 'creating a swap transaction (chg and dest by addrtype)'), + ('swaptxcreate5', 'creating a swap transaction (chg and dest by addrlist ID)'), + ('swaptxcreate6', 'creating a swap transaction (dest is non-wallet addr)'), + ('swaptxcreate7', 'creating a swap transaction (coin-amt-coin)'), + ), + 'create_bad': ( + 'Swap TX create operations: error handling', + ('swaptxcreate_bad1', 'creating a swap transaction (bad, used destination address)'), + ('swaptxcreate_bad2', 'creating a swap transaction (bad, used change address)'), + ('swaptxcreate_bad3', 'creating a swap transaction (bad, unsupported send coin)'), + ('swaptxcreate_bad4', 'creating a swap transaction (bad, unsupported recv coin)'), + ('swaptxcreate_bad5', 'creating a swap transaction (bad, malformed cmdline)'), + ('swaptxcreate_bad6', 'creating a swap transaction (bad, malformed cmdline)'), + ('swaptxcreate_bad7', 'creating a swap transaction (bad, bad user input, user exit)'), + ('swaptxcreate_bad8', 'creating a swap transaction (bad, non-MMGen change address)'), + ('swaptxcreate_bad9', 'creating a swap transaction (bad, invalid addrtype)'), + ), + 'signsend': ( + 'Swap TX create, sign and send operations (LTC => BCH)', + ('swaptxsign1_create', 'creating a swap transaction (full args)'), + ('swaptxsign1', 'signing the transaction'), + ('swaptxsend1', 'sending the transaction'), + ('swaptxsend1_status', 'getting status of sent transaction'), + ('generate1', 'generating a block'), + ('swaptxsign2_create', 'creating a swap transaction (non-wallet swap address)'), + ('swaptxsign2', 'signing the transaction'), + ('swaptxsend2', 'sending the transaction'), + ('mempool1', 'viewing the mempool'), + ('swaptxbump1', 'bumping the transaction'), + ('swaptxsign3', 'signing the transaction'), + ('swaptxsend3', 'sending the transaction'), + ('mempool1', 'viewing the mempool'), + ('swaptxbump2', 'bumping the transaction again'), + ('swaptxsign4', 'signing the transaction'), + ('swaptxsend4', 'sending the transaction'), + ('mempool1', 'viewing the mempool'), + ('generate1', 'generating a block'), + ('swap_bal1', 'checking the balance'), + ('swaptxsign1_do', 'creating, signing and sending a swap transaction'), + ('generate1', 'generating a block'), + ('swap_bal2', 'checking the balance'), + ), + 'signsend_bad': ( + 'Swap TX create, sign and send operations: error handling', + ('swaptxsign_bad1_create', 'creating a swap transaction (non-wallet swap address)'), + ('swaptxsign_bad1', 'signing the transaction (non-wallet swap address)'), + ('swaptxsign_bad2_create', 'creating a swap transaction'), + ('swaptxsign_bad2', 'signing the transaction'), + ('swaptxsend_bad2', 'sending the transaction (swap quote expired)'), + ), + 'autosign': ( + 'Swap TX operations with autosigning (BTC => LTC)', + ('run_setup_bip39', 'setting up offline autosigning'), + ('swap_wait_loop_start', 'starting autosign wait loop'), + ('autosign_swaptxcreate1', 'creating a swap transaction'), + ('autosign_swaptxsend1', 'sending the transaction'), + ('autosign_swaptxbump1', 'bumping the transaction'), + ('autosign_swaptxsend2', 'sending the transaction'), + ('generate0', 'generating a block'), + ('swap_bal3', 'checking the balance'), + ('wait_loop_kill', 'stopping autosign wait loop'), ), } def __init__(self, trunner, cfgs, spawn): - super().__init__(trunner, cfgs, spawn) + CmdTestAutosignThreaded.__init__(self, trunner, cfgs, spawn) + CmdTestRegtest.__init__(self, trunner, cfgs, spawn) + + if trunner is None: + return globals_dict = globals() for k in rt_data: @@ -74,10 +175,29 @@ class CmdTestSwap(CmdTestRegtest): self.protos = [init_proto(cfg, k, network='regtest', need_amt=True) for k in ('btc', 'ltc', 'bch')] + midgard_server_start() # TODO: stop server when test group finishes executing + + self.opts.append('--bob') + @property def sid(self): return self._user_sid('bob') + def walletcreate_bob(self): + dest = Path(self.tr.data_dir, 'regtest', 'bob') + dest.mkdir(exist_ok=True) + t = self.spawn('mmgen-walletconv', [ + '--quiet', + '--usr-randchars=0', + '--hash-preset=1', + '--label=SwapWalletLabel', + f'--outdir={str(dest)}', + dfl_bip39_file]) + t.expect('wallet: ', rt_pw + '\n') + t.expect('phrase: ', rt_pw + '\n') + t.written_to_file('wallet') + return t + def _addrgen_bob(self, proto_idx, mmtypes, subseed_idx=None): return self.addrgen('bob', subseed_idx=subseed_idx, mmtypes=mmtypes, proto=self.protos[proto_idx]) @@ -186,8 +306,412 @@ class CmdTestSwap(CmdTestRegtest): 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']) + def setup_send_coin(self): + self.user_sids = {} + return self._setup(proto=self.protos[2], remove_datadir=True) + + def addrgen_bob_send(self): + return self._addrgen_bob(2, ['C']) + + def addrimport_bob_send(self): + return self.addrimport('bob', mmtypes=['C'], proto=self.protos[2]) + + def fund_bob_send(self): + return self._fund_bob(2, 'C', '500') + + def bob_bal_send(self): + return self._bob_bal(2, '500') + + def setup_recv_coin(self): + return self._setup(proto=self.protos[1], remove_datadir=False) + + def addrgen_bob_recv(self): + return self._addrgen_bob(1, ['S', 'B']) + + def addrimport_bob_recv(self): + return self._addrimport_bob(1) + + def fund_bob_recv1(self): + return self._fund_bob(1, 'S', '500') + + def fund_bob_recv2(self): + return self._fund_bob(1, 'B', '500') + + def addrgen_bob_recv_subwallet(self): + return self._addrgen_bob(1, ['C', 'B'], subseed_idx='29L') + + def addrimport_bob_recv_subwallet(self): + return self._subwallet_addrimport('bob', '29L', ['C', 'B'], proto=self.protos[1]) + + def fund_bob_recv_subwallet(self, proto_idx=1, amt='500'): + coin_arg = f'--coin={self.protos[proto_idx].coin}' + t = self.spawn('mmgen-tool', ['--bob', coin_arg, 'listaddresses']) + addr = [s for s in strip_ansi_escapes(t.read()).splitlines() if 'C:1 No' in s][0].split()[3] + t = self.spawn('mmgen-regtest', [coin_arg, 'send', addr, str(amt)], no_passthru_opts=True, no_msg=True) return t + + def bob_bal_recv(self): + return self._bob_bal(1, '1500') + + def _swaptxcreate_ui_common( + self, + t, + *, + inputs = '1', + interactive_fee = None, + file_desc = 'Unsigned transaction', + reload_quote = False, + sign_and_send = False): + t.expect('abel:\b', 'q') + t.expect('to spend: ', f'{inputs}\n') + if reload_quote: + t.expect('to continue: ', 'r') # reload swap quote + t.expect('to continue: ', '\n') # exit swap quote view + t.expect('(Y/n): ', 'y') # fee OK? + t.expect('(Y/n): ', 'y') # change OK? + t.expect('(y/N): ', 'n') # add comment? + if reload_quote: + t.expect('to continue: ', 'r') # reload swap quote + t.expect('to continue: ', '\n') # exit swap quote view + t.expect('view: ', 'y') # view TX + t.expect('to continue: ', '\n') + if sign_and_send: + t.passphrase(dfl_wcls.desc, rt_pw) + t.expect('to confirm: ', 'YES\n') + else: + t.expect('(y/N): ', 'y') # save? + t.written_to_file(file_desc) + return t + + def _swaptxcreate(self, args, *, action='txcreate', add_opts=[], exit_val=None): + return self.spawn( + f'mmgen-swap{action}', + ['-q', '-d', self.tmpdir, '-B', '--bob'] + + add_opts + + args, + exit_val = exit_val) + + def swaptxcreate1(self, idx=3): + return self._swaptxcreate_ui_common( + self._swaptxcreate(['BCH', '1.234', f'{self.sid}:C:{idx}', 'LTC', f'{self.sid}:B:3'])) + + def swaptxcreate2(self): + t = self._swaptxcreate(['BCH', 'LTC'], add_opts=['--no-quiet']) + t.expect('Enter a number> ', '1') + t.expect('OK? (Y/n): ', 'y') + return self._swaptxcreate_ui_common(t, reload_quote=True) + + def swaptxcreate3(self): + return self._swaptxcreate_ui_common( + self._swaptxcreate(['BCH', 'LTC', f'{self.sid}:B:3'])) + + def swaptxcreate4(self): + t = self._swaptxcreate(['BCH', '1.234', 'C', 'LTC', 'B']) + t.expect('OK? (Y/n): ', 'y') + t.expect('Enter a number> ', '1') + t.expect('OK? (Y/n): ', 'y') + return self._swaptxcreate_ui_common(t) + + def swaptxcreate5(self): + t = self._swaptxcreate(['BCH', '1.234', f'{self.sid}:C', 'LTC', f'{self.sid}:B']) + t.expect('OK? (Y/n): ', 'y') + t.expect('OK? (Y/n): ', 'y') + return self._swaptxcreate_ui_common(t) + + def swaptxcreate6(self): + addr = make_burn_addr(self.protos[1], mmtype='bech32') + t = self._swaptxcreate(['BCH', '1.234', f'{self.sid}:C', 'LTC', addr]) + t.expect('OK? (Y/n): ', 'y') + t.expect('to confirm: ', 'YES\n') + return self._swaptxcreate_ui_common(t) + + def swaptxcreate7(self): + t = self._swaptxcreate(['BCH', '0.56789', 'LTC']) + t.expect('OK? (Y/n): ', 'y') + t.expect('Enter a number> ', '1') + t.expect('OK? (Y/n): ', 'y') + return self._swaptxcreate_ui_common(t) + + def _swaptxcreate_bad(self, args, *, exit_val=1, expect1=None, expect2=None): + t = self._swaptxcreate(args, exit_val=exit_val) + if expect1: + t.expect(expect1) + if expect2: + t.expect(expect2) + return t + + def swaptxcreate_bad1(self): + t = self._swaptxcreate_bad( + ['BCH', '1.234', f'{self.sid}:C:3', 'LTC', f'{self.sid}:S:1'], + expect1 = 'Requested destination address', + expect2 = 'Address reuse harms your privacy') + t.expect('(y/N): ', 'n') + return t + + def swaptxcreate_bad2(self): + t = self._swaptxcreate_bad( + ['BCH', '1.234', f'{self.sid}:C:1', 'LTC', f'{self.sid}:S:2'], + expect1 = 'Requested change address', + expect2 = 'Address reuse harms your privacy') + t.expect('(y/N): ', 'n') + return t + + def swaptxcreate_bad3(self): + return self._swaptxcreate_bad(['RTC', 'LTC'], expect1='unsupported send coin') + + def swaptxcreate_bad4(self): + return self._swaptxcreate_bad(['LTC', 'XTC'], expect1='unsupported receive coin') + + def swaptxcreate_bad5(self): + return self._swaptxcreate_bad(['LTC'], expect1='USAGE:') + + def swaptxcreate_bad6(self): + return self._swaptxcreate_bad(['LTC', '1.2345'], expect1='USAGE:') + + def swaptxcreate_bad7(self): + t = self._swaptxcreate(['BCH', 'LTC'], exit_val=1) + t.expect('Enter a number> ', '3') + t.expect('Enter a number> ', '1') + t.expect('OK? (Y/n): ', 'n') + return t + + def swaptxcreate_bad8(self): + addr = make_burn_addr(self.protos[2], mmtype='compressed') + t = self._swaptxcreate_bad(['BCH', '1.234', addr, 'LTC', 'S']) + t.expect('to confirm: ', 'NO\n') + return t + + def swaptxcreate_bad9(self): + return self._swaptxcreate_bad(['BCH', '1.234', 'S', 'LTC', 'B'], exit_val=2, expect1='invalid command-') + + def swaptxsign1_create(self): + self.get_file_with_ext('rawtx', delete_all=True) + return self._swaptxcreate_ui_common( + self._swaptxcreate(['LTC', '5.4321', f'{self.sid}:S:2', 'BCH', f'{self.sid}:C:2'])) + + def swaptxsign1(self): + return self._swaptxsign() + + def swaptxsend1(self): + return self._swaptxsend1() + + def swaptxsend1_status(self): + t = self._swaptxsend1(add_opts=['--status'], spawn_only=True) + t.expect('in mempool') + return t + + def _swaptxsend1(self, *, add_opts=[], spawn_only=False): + return self._swaptxsend( + add_opts = add_opts + [ + # test overriding host:port with coin-specific options: + '--rpc-host=unreachable', # unreachable host + '--ltc-rpc-host=localhost', + '--rpc-port=46381', # bad port + '--ltc-rpc-port=20680', + ], + spawn_only = spawn_only) + + def _swaptxsend(self, *, add_opts=[], spawn_only=False): + fn = self.get_file_with_ext('sigtx') + t = self.spawn('mmgen-txsend', add_opts + ['-q', '-d', self.tmpdir, '--bob', fn]) + if spawn_only: + return t + t.expect('view: ', 'v') + t.expect('(y/N): ', 'n') + t.expect('to confirm: ', 'YES\n') + return t + + def _swaptxsign(self, *, add_opts=[], expect=None): + self.get_file_with_ext('sigtx', delete_all=True) + fn = self.get_file_with_ext('rawtx') + t = self.spawn('mmgen-txsign', add_opts + ['-d', self.tmpdir, '--bob', fn]) + t.view_tx('t') + if expect: + t.expect(expect) + t.passphrase(dfl_wcls.desc, rt_pw) + t.do_comment(None) + t.expect('(Y/n): ', 'y') + t.written_to_file('Signed transaction') + return t + + def swaptxsign2_create(self): + self.get_file_with_ext('rawtx', delete_all=True) + addr = make_burn_addr(self.protos[2], mmtype='compressed') + t = self._swaptxcreate(['LTC', '4.56789', f'{self.sid}:S:3', 'BCH', addr]) + t.expect('to confirm: ', 'YES\n') # confirm non-MMGen destination + return self._swaptxcreate_ui_common(t) + + def swaptxsign2(self): + return self._swaptxsign(add_opts=['--allow-non-wallet-swap'], expect='swap to non-wallet address') + + def swaptxsend2(self): + return self._swaptxsend() + + def swaptxbump1(self): + return self._swaptxbump('20s', add_opts=['--allow-non-wallet-swap']) + + def swaptxbump2(self): # create one-output TX back to self to rescue funds + return self._swaptxbump('40s', output_args=[f'{self.sid}:S:4']) + + def _swaptxbump(self, fee, *, add_opts=[], output_args=[], exit_val=None): + self.get_file_with_ext('rawtx', delete_all=True) + fn = self.get_file_with_ext('sigtx') + t = self.spawn( + 'mmgen-txbump', + ['-q', '-d', self.tmpdir, '--bob'] + add_opts + output_args + [fn], + exit_val = exit_val) + return self._swaptxbump_ui_common(t, interactive_fee=fee, new_outputs=bool(output_args)) + + def _swaptxbump_ui_common_new_outputs(self, t, *, inputs=None, interactive_fee=None, file_desc=None): + return self._swaptxbump_ui_common(t, interactive_fee=interactive_fee, new_outputs=True) + + def _swaptxbump_ui_common(self, t, *, inputs=None, interactive_fee=None, file_desc=None, new_outputs=False): + if new_outputs: + t.expect('fee: ', interactive_fee + '\n') + t.expect('(Y/n): ', 'y') # fee ok? + t.expect('(Y/n): ', 'y') # change ok? + else: + t.expect('ENTER for the change output): ', '\n') + t.expect('(Y/n): ', 'y') # confirm deduct from chg output + t.expect('to continue: ', '\n') # exit swap quote + t.expect('fee: ', interactive_fee + '\n') + t.expect('(Y/n): ', 'y') # fee ok? + t.expect('(y/N): ', 'n') # comment? + t.expect('(y/N): ', 'y') # save? + return t + + def swaptxsign3(self): + return self.swaptxsign2() + + def swaptxsend3(self): + return self._swaptxsend() + + def swaptxsign4(self): + return self._swaptxsign() + + def swaptxsend4(self): + return self._swaptxsend() + + def _generate_for_proto(self, proto_idx): + return self.generate(num_blocks=1, add_opts=[f'--coin={self.protos[proto_idx].coin}']) + + def generate0(self): + return self._generate_for_proto(0) + + def generate1(self): + return self._generate_for_proto(1) + + def generate2(self): + return self._generate_for_proto(2) + + def swap_bal1(self): + return self._bob_bal(1, '1494.56784238') + + def swap_bal2(self): + return self._bob_bal(1, '1382.79038152') + + def swap_bal3(self): + return self._bob_bal(0, '999.99990407') + + def swaptxsign1_do(self): + return self._swaptxcreate_ui_common( + self._swaptxcreate(['LTC', '111.777444', f'{self.sid}:B:2', 'BCH', f'{self.sid}:C:2'], action='txdo'), + sign_and_send = True, + file_desc = 'Sent transaction') + + def swaptxsign_bad1_create(self): + self.get_file_with_ext('rawtx', delete_all=True) + return self.swaptxcreate6() + + def swaptxsign_bad1(self): + self.get_file_with_ext('sigtx', delete_all=True) + return self._swaptxsign_bad('non-wallet address forbidden') + + def _swaptxsign_bad(self, expect, *, add_opts=[], exit_val=1): + fn = self.get_file_with_ext('rawtx') + t = self.spawn('mmgen-txsign', add_opts + ['-d', self.tmpdir, '--bob', fn], exit_val=exit_val) + t.expect('view: ', '\n') + t.expect(expect) + return t + + def swaptxsign_bad2_create(self): + self.get_file_with_ext('rawtx', delete_all=True) + return self.swaptxcreate1(idx=4) + + def swaptxsign_bad2(self): + return self._swaptxsign() + + def swaptxsend_bad2(self): + import json + from mmgen.tx.file import json_dumps + from mmgen.util import make_chksum_6 + fn = self.get_file_with_ext('sigtx') + with open(fn) as fh: + data = json.load(fh) + data['MMGenTransaction']['swap_quote_expiry'] -= 2400 + data['chksum'] = make_chksum_6(json_dumps(data['MMGenTransaction'])) + with open(fn, 'w') as fh: + json.dump(data, fh) + t = self.spawn('mmgen-txsend', ['-d', self.tmpdir, '--bob', fn], exit_val=2) + t.expect('expired') + return t + + run_setup_bip39 = CmdTestAutosign.run_setup_bip39 + run_setup = CmdTestAutosign.run_setup + + def swap_wait_loop_start(self): + return self.wait_loop_start(add_opts=['--allow-non-wallet-swap']) + + def autosign_swaptxcreate1(self): + return self._user_txcreate( + 'bob', + progname = 'swaptxcreate', + input_handler = self._swaptxcreate_ui_common, + output_args = ['BTC', '8.88', f'{self.sid}:S:3', 'LTC', f'{self.sid}:S:3']) + + def autosign_swaptxsend1(self): + return self._user_txsend('bob', need_rbf=True) + + def autosign_swaptxbump1(self): + return self._user_txcreate( + 'bob', + progname = 'txbump', + input_handler = self._swaptxbump_ui_common_new_outputs, + output_args = [f'{self.sid}:S:3']) + + def autosign_swaptxsend2(self): + return self._user_txsend('bob', need_rbf=True) + + # admin methods: + + def sleep(self): + import time + time.sleep(1000) + return 'ok' + + def listaddresses0(self): + return self._listaddresses(0) + + def listaddresses1(self): + return self._listaddresses(1) + + def listaddresses2(self): + return self._listaddresses(2) + + def _listaddresses(self, proto_idx): + return self.user_bal('bob', None, proto=self.protos[proto_idx], skip_check=True) + + def mempool0(self): + return self._mempool(0) + + def mempool1(self): + return self._mempool(1) + + def mempool2(self): + return self._mempool(2) + + def _mempool(self, proto_idx): + self.spawn('', msg_only=True) + data = self._do_cli(['getrawmempool'], add_opts=[f'--coin={self.protos[proto_idx].coin}']) + assert data + return 'ok' diff --git a/test/cmdtest_d/midgard.py b/test/cmdtest_d/midgard.py new file mode 100755 index 00000000..39701c27 --- /dev/null +++ b/test/cmdtest_d/midgard.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 + +import json, re, time +from http.server import HTTPServer, BaseHTTPRequestHandler + +from mmgen.cfg import Config +from mmgen.util import msg, make_timestr + +cfg = Config() + +def make_inbound_addr(proto, mmtype): + from mmgen.tool.coin import tool_cmd + n = int(time.time()) // (60 * 60 * 24) # increments once every 24 hrs + return tool_cmd( + cfg = cfg, + cmdname = 'pubhash2addr', + proto = proto, + mmtype = mmtype).pubhash2addr(f'{n:040x}') + +data_template = { + 'inbound_address': None, + 'inbound_confirmation_blocks': 4, + 'inbound_confirmation_seconds': 2400, + 'outbound_delay_blocks': 5, + 'outbound_delay_seconds': 30, + 'fees': { + 'asset': 'LTC.LTC', + 'affiliate': '0', + 'outbound': '878656', + 'liquidity': '8945012', + 'total': '9823668', + 'slippage_bps': 31, + 'total_bps': 34 + }, + 'expiry': None, + 'warning': 'Do not cache this response. Do not send funds after the expiry.', + 'notes': 'First output should be to inbound_address, second output should be change back to self, third output should be OP_RETURN, limited to 80 bytes. Do not send below the dust threshold. Do not use exotic spend scripts, locks or address formats.', + 'dust_threshold': '10000', + 'recommended_min_amount_in': '1222064', + 'recommended_gas_rate': '6', + 'gas_rate_units': 'satsperbyte', + 'expected_amount_out': None, + 'max_streaming_quantity': 0, + 'streaming_swap_blocks': 0, + 'total_swap_seconds': 2430 +} + +# https://thornode.ninerealms.com/thorchain/quote/swap?from_asset=BCH.BCH&to_asset=LTC.LTC&amount=1000000 + +sample_request = 'GET /thorchain/quote/swap?from_asset=BCH.BCH&to_asset=LTC.LTC&amount=1000000000 HTTP/1.1' + +request_pat = r'/thorchain/quote/swap\?from_asset=(\S+)\.(\S+)&to_asset=(\S+)\.(\S+)&amount=(\d+) HTTP/' + +prices = { 'BTC': 97000, 'LTC': 115, 'BCH': 330 } + +def create_data(request_line): + m = re.search(request_pat, request_line) + try: + _, send_coin, _, recv_coin, amt_atomic = m.groups() + except Exception as e: + msg(f'{type(e)}: {e}') + return {} + + from mmgen.protocol import init_proto + send_proto = init_proto(cfg, send_coin, network='regtest', need_amt=True) + in_amt = send_proto.coin_amt(int(amt_atomic), from_unit='satoshi') + out_amt = in_amt * (prices[send_coin] / prices[recv_coin]) + + addr = make_inbound_addr(send_proto, send_proto.preferred_mmtypes[0]) + expiry = int(time.time()) + (10 * 60) + return data_template | { + 'expected_amount_out': str(out_amt.to_unit('satoshi')), + 'expiry': expiry, + 'inbound_address': addr, + } + +class handler(BaseHTTPRequestHandler): + header = b'HTTP/1.1 200 OK\nContent-type: application/json\n\n' + + def do_GET(self): + # print(f'Midgard server received:\n {self.requestline}') + self.wfile.write(self.header + json.dumps(create_data(self.requestline)).encode()) + +def run_midgard_server(server_class=HTTPServer, handler_class=handler): + print('Midgard server listening on port 18800') + server_address = ('localhost', 18800) + httpd = server_class(server_address, handler_class) + httpd.serve_forever() + print('Midgard server exiting') diff --git a/test/modtest_d/ut_tx.py b/test/modtest_d/ut_tx.py index c382ce22..47ec2360 100755 --- a/test/modtest_d/ut_tx.py +++ b/test/modtest_d/ut_tx.py @@ -10,7 +10,7 @@ from mmgen.tx import CompletedTX, UnsignedTX from mmgen.tx.file import MMGenTxFile from mmgen.cfg import Config -from ..include.common import cfg, qmsg, vmsg, gr_uc +from ..include.common import cfg, qmsg, vmsg, gr_uc, make_burn_addr async def do_txfile_test(desc, fns, cfg=cfg, check=False): qmsg(f'\n Testing CompletedTX initializer ({desc})') @@ -169,3 +169,74 @@ class unit_tests: ), pfx='') return True + + def memo(self, name, ut, desc='Swap transaction memo'): + from mmgen.protocol import init_proto + from mmgen.swap.proto.thorchain.memo import Memo + for coin, addrtype in ( + ('ltc', 'bech32'), + ('bch', 'compressed'), + ): + proto = init_proto(cfg, coin) + addr = make_burn_addr(proto, addrtype) + + vmsg('\nTesting memo initialization:') + m = Memo(proto, addr) + vmsg(f'str(memo): {m}') + vmsg(f'repr(memo): {m!r}') + + vmsg('\nTesting memo parsing:') + p = Memo.parse(m) + from pprint import pformat + vmsg(pformat(p._asdict())) + assert p.proto == 'THORChain' + assert p.function == 'SWAP' + assert p.chain == coin.upper() + assert p.asset == coin.upper() + assert p.address == addr.views[addr.view_pref] + assert p.trade_limit == 0 + assert p.stream_interval == 1 + assert p.stream_quantity == 0 # auto + + vmsg('\nTesting is_partial_memo():') + for vec in ( + str(m), + 'SWAP:xyz', + '=:xyz', + 's:xyz', + 'a:xz', + '+:xz', + 'WITHDRAW:xz', + 'LOAN+:xz:x:x', + 'TRADE-:xz:x:x', + 'BOND:xz', + ): + vmsg(f' pass: {vec}') + assert Memo.is_partial_memo(vec), vec + + for vec in ( + '=', + 'swap', + 'swap:', + 'swap:abc', + 'SWAP:a', + ): + vmsg(f' fail: {vec}') + assert not Memo.is_partial_memo(vec), vec + + vmsg('\nTesting error handling:') + + def bad(s): + return lambda: Memo.parse(s) + + ut.process_bad_data(( + ('bad1', 'SwapMemoParseError', 'must contain', bad('x')), + ('bad2', 'SwapMemoParseError', 'must contain', bad('y:z:x')), + ('bad3', 'SwapMemoParseError', 'function abbrev', bad('z:l:foobar:0/1/0')), + ('bad4', 'SwapMemoParseError', 'asset abbrev', bad('=:x:foobar:0/1/0')), + ('bad5', 'SwapMemoParseError', 'failed to parse', bad('=:l:foobar:n')), + ('bad6', 'SwapMemoParseError', 'non-integer', bad('=:l:foobar:x/1/0')), + ('bad7', 'SwapMemoParseError', 'extra', bad('=:l:foobar:0/1/0:x')), + ), pfx='') + + return True diff --git a/test/overlay/fakemods/mmgen/swap/proto/thorchain/midgard.py b/test/overlay/fakemods/mmgen/swap/proto/thorchain/midgard.py new file mode 100755 index 00000000..995c608e --- /dev/null +++ b/test/overlay/fakemods/mmgen/swap/proto/thorchain/midgard.py @@ -0,0 +1,11 @@ +from .midgard_orig import * + +class overlay_fake_MidgardRPCClient: + + proto = 'http' + host = 'localhost:18800' + verify = False + +MidgardRPCClient.proto = overlay_fake_MidgardRPCClient.proto +MidgardRPCClient.host = overlay_fake_MidgardRPCClient.host +MidgardRPCClient.verify = overlay_fake_MidgardRPCClient.verify