8 Commits ef5f6e4b22 ... 85cec5655d

Author SHA1 Message Date
  The MMGen Project 85cec5655d THORChain DEX integration 9 months ago
  The MMGen Project 5a443c31a0 mmgen-txsend --status --verbose: optionally display transaction info 9 months ago
  The MMGen Project 2be9de113a get_autosign_obj(): clone existing cfg 9 months ago
  The MMGen Project 5a436b9673 rpc: remove localhost override for test suite, regtest 9 months ago
  The MMGen Project 73efa84b48 tx.file.format(): remove false boolean values from outputs 9 months ago
  The MMGen Project b3bda1b62b help: minor cleanups, move help texts to individual modules 9 months ago
  The MMGen Project 28cecaec08 cmdtest.py autosign_automount: add hooks for multi-user support 9 months ago
  The MMGen Project 7b6717d85a fixes and cleanups throughout 9 months ago
59 changed files with 1959 additions and 405 deletions
  1. 4 0
      mmgen/amt.py
  2. 1 1
      mmgen/data/version
  3. 1 0
      mmgen/exception.py
  4. 5 174
      mmgen/help/help_notes.py
  5. 52 0
      mmgen/help/subwallet.py
  6. 82 0
      mmgen/help/swaptxcreate.py
  7. 72 0
      mmgen/help/swaptxcreate_examples.py
  8. 59 0
      mmgen/help/txcreate.py
  9. 65 0
      mmgen/help/txcreate_examples.py
  10. 48 0
      mmgen/help/txsign.py
  11. 1 1
      mmgen/help/xmrwallet.py
  12. 2 2
      mmgen/main_addrgen.py
  13. 2 0
      mmgen/main_autosign.py
  14. 18 17
      mmgen/main_txbump.py
  15. 7 6
      mmgen/main_txcreate.py
  16. 12 10
      mmgen/main_txdo.py
  17. 7 2
      mmgen/main_txsend.py
  18. 4 2
      mmgen/main_txsign.py
  19. 1 1
      mmgen/main_wallet.py
  20. 1 0
      mmgen/proto/bch/params.py
  21. 1 0
      mmgen/proto/btc/params.py
  22. 1 3
      mmgen/proto/btc/rpc.py
  23. 15 1
      mmgen/proto/btc/tx/base.py
  24. 2 2
      mmgen/proto/btc/tx/bump.py
  25. 19 1
      mmgen/proto/btc/tx/completed.py
  26. 1 1
      mmgen/proto/btc/tx/info.py
  27. 5 2
      mmgen/proto/btc/tx/new.py
  28. 126 3
      mmgen/proto/btc/tx/new_swap.py
  29. 11 4
      mmgen/proto/btc/tx/status.py
  30. 1 0
      mmgen/proto/eth/params.py
  31. 1 1
      mmgen/proto/eth/rpc.py
  32. 2 2
      mmgen/proto/eth/tx/bump.py
  33. 3 0
      mmgen/proto/eth/tx/completed.py
  34. 10 3
      mmgen/proto/eth/tx/status.py
  35. 25 0
      mmgen/swap/proto/thorchain/__init__.py
  36. 134 0
      mmgen/swap/proto/thorchain/memo.py
  37. 113 0
      mmgen/swap/proto/thorchain/midgard.py
  38. 28 0
      mmgen/swap/proto/thorchain/params.py
  39. 19 3
      mmgen/tw/addresses.py
  40. 4 0
      mmgen/tx/base.py
  41. 37 7
      mmgen/tx/bump.py
  42. 6 2
      mmgen/tx/file.py
  43. 14 2
      mmgen/tx/info.py
  44. 42 23
      mmgen/tx/new.py
  45. 21 1
      mmgen/tx/new_swap.py
  46. 16 0
      mmgen/tx/online.py
  47. 6 0
      mmgen/tx/sign.py
  48. 2 4
      mmgen/tx/util.py
  49. 12 10
      scripts/exec_wrapper.py
  50. 3 0
      setup.cfg
  51. 3 2
      test/cmdtest.py
  52. 17 65
      test/cmdtest_d/ct_automount.py
  53. 76 4
      test/cmdtest_d/ct_autosign.py
  54. 6 5
      test/cmdtest_d/ct_regtest.py
  55. 550 33
      test/cmdtest_d/ct_swap.py
  56. 89 0
      test/cmdtest_d/midgard.py
  57. 82 2
      test/modtest_d/ut_tx.py
  58. 11 0
      test/overlay/fakemods/mmgen/swap/proto/thorchain/midgard.py
  59. 1 3
      test/test-release.sh

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

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-15.1.dev16
+15.1.dev17

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

+ 5 - 174
mmgen/help/help_notes.py

@@ -12,22 +12,21 @@
 help: help notes functions for MMGen suite commands
 """
 
-from ..cfg import gc
-
 class help_notes:
 
 	def __init__(self, proto, cfg):
 		self.proto = proto
 		self.cfg = cfg
 
-	def txcreate_args(self, target):
+	def txcreate_args(self):
 		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')
 
+	def swaptxcreate_args(self):
+		return 'COIN1 [AMT CHG_ADDR] COIN2 [ADDR]'
+
 	def account_info_desc(self):
 		return 'unspent outputs' if self.proto.base_proto == 'Bitcoin' else 'account info'
 
@@ -40,11 +39,6 @@ class help_notes:
 		cu = self.proto.coin_amt.units
 		return ', '.join(cu[:-1]) + ('', ' and ')[len(cu)>1] + cu[-1] + ('', ',\nrespectively')[len(cu)>1]
 
-	def coind_exec(self):
-		from ..daemon import CoinDaemon
-		return (
-			CoinDaemon(self.cfg, self.proto.coin).exec_fn if self.proto.coin in CoinDaemon.coins else 'bitcoind')
-
 	def dfl_twname(self):
 		from ..proto.btc.rpc import BitcoinRPCClient
 		return BitcoinRPCClient.dfl_twname
@@ -128,7 +122,7 @@ as {r}, using an integer followed by '{l}', for {u}.
 	c = self.proto.coin,
 	r = BaseTX(cfg=self.cfg, proto=self.proto).rel_fee_desc,
 	l = self.fee_spec_letters(use_quotes=True),
-	u = self.fee_spec_names() )
+	u = self.fee_spec_names())
 
 	def passwd(self):
 		return """
@@ -146,167 +140,4 @@ BRAINWALLET NOTE:
 To thwart dictionary attacks, it’s recommended to use a strong hash preset
 with brainwallets.  For a brainwallet passphrase to generate the correct
 seed, the same seed length and hash preset parameters must always be used.
-""".strip()
-
-	def txcreate_examples(self):
-
-		mmtype = 'B' if 'B' in self.proto.mmtypes else self.proto.mmtypes[0]
-		from ..tool.coin import tool_cmd
-		t = tool_cmd(self.cfg, mmtype=mmtype)
-		addr = t.privhex2addr('bead' * 16)
-		sample_addr = addr.views[addr.view_pref]
-
-		return f"""
-EXAMPLES:
-
-  Send 0.123 {self.proto.coin} to an external {self.proto.name} address, returning the change to a
-  specific MMGen address in the tracking wallet:
-
-    $ {gc.prog_name} {sample_addr},0.123 01ABCDEF:{mmtype}:7
-
-  Same as above, but select the change address automatically:
-
-    $ {gc.prog_name} {sample_addr},0.123 01ABCDEF:{mmtype}
-
-  Same as above, but select the change address automatically by address type:
-
-    $ {gc.prog_name} {sample_addr},0.123 {mmtype}
-
-  Same as above, but reduce verbosity and specify fee of 20 satoshis
-  per byte:
-
-    $ {gc.prog_name} -q -f 20s {sample_addr},0.123 {mmtype}
-
-  Send entire balance of selected inputs minus fee to an external {self.proto.name}
-  address:
-
-    $ {gc.prog_name} {sample_addr}
-
-  Send entire balance of selected inputs minus fee to first unused wallet
-  address of specified type:
-
-    $ {gc.prog_name} {mmtype}
-""" if self.proto.base_proto == 'Bitcoin' else f"""
-EXAMPLES:
-
-  Send 0.123 {self.proto.coin} to an external {self.proto.name} address:
-
-    $ {gc.prog_name} {sample_addr},0.123
-
-  Send 0.123 {self.proto.coin} to another account in wallet 01ABCDEF:
-
-    $ {gc.prog_name} 01ABCDEF:{mmtype}:7,0.123
-"""
-
-	def txcreate(self):
-		outputs_info = (
-			"""
-Outputs are specified in the form ADDRESS,AMOUNT or ADDRESS.  The first form
-creates an output sending the given amount to the given address.  The bare
-address form designates the given address as either the change output or the
-sole output of the transaction (excluding any data output).  Exactly one bare
-address argument is required.
-
-For convenience, the bare address argument may be given as ADDRTYPE_CODE or
-SEED_ID:ADDRTYPE_CODE (see ADDRESS TYPES below). In the first form, the first
-unused address of type ADDRTYPE_CODE for each Seed ID in the tracking wallet
-will be displayed in a menu, with the user prompted to select one.  In the
-second form, the user specifies the Seed ID as well, allowing the script to
-select the transaction’s change output or single output without prompting.
-See EXAMPLES below.
-
-A single DATA_SPEC argument may also be given on the command line to create
-an OP_RETURN data output with a zero spend amount.  This is the preferred way
-to embed data in the blockchain.  DATA_SPEC may be of the form "data":DATA
-or "hexdata":DATA. In the first form, DATA is a string in your system’s native
-encoding, typically UTF-8.  In the second, DATA is a hexadecimal string (with
-the leading ‘0x’ omitted) encoding the binary data to be embedded.  In both
-cases, the resulting byte string must not exceed {bl} bytes in length.
-""".format(bl=self.proto.max_op_return_data_len)
-		if self.proto.base_proto == 'Bitcoin' else """
-The transaction output is specified in the form ADDRESS,AMOUNT.
-""")
-
-		return """
-The transaction’s outputs are listed on the command line, while its inputs
-are chosen from a list of the wallet’s unspent outputs via an interactive
-menu.  Alternatively, inputs may be specified using the --inputs option.
-
-Addresses on the command line can be either native coin addresses or MMGen
-IDs in the form SEED_ID:ADDRTYPE_CODE:INDEX.
-{oinfo}
-If the transaction fee is not specified on the command line (see FEE
-SPECIFICATION below), it will be calculated dynamically using network fee
-estimation for the default (or user-specified) number of confirmations.
-If network fee estimation fails, the user will be prompted for a fee.
-
-Network-estimated fees will be multiplied by the value of --fee-adjust, if
-specified.
-""".format(oinfo=outputs_info)
-
-	def txsign(self):
-		from ..proto.btc.params import mainnet
-		return """
-Transactions may contain both {pnm} or non-{pnm} input addresses.
-
-To sign non-{pnm} inputs, a {wd}flat key list is used
-as the key source (--keys-from-file option).
-
-To sign {pnm} inputs, key data is generated from a seed as with the
-{pnl}-addrgen and {pnl}-keygen commands.  Alternatively, a key-address file
-may be used (--mmgen-keys-from-file option).
-
-Multiple wallets or other seed files can be listed on the command line in
-any order.  If the seeds required to sign the transaction’s inputs are not
-found in these files (or in the default wallet), the user will be prompted
-for seed data interactively.
-
-To prevent an attacker from crafting transactions with bogus {pnm}-to-{pnu}
-address mappings, all outputs to {pnm} addresses are verified with a seed
-source.  Therefore, seed files or a key-address file for all {pnm} outputs
-must also be supplied on the command line if the data can’t be found in the
-default wallet.
-""".format(
-	wd  = f'{self.coind_exec()} wallet dump or ' if isinstance(self.proto, mainnet) else '',
-	pnm = gc.proj_name,
-	pnu = self.proto.name,
-	pnl = gc.proj_name.lower())
-
-	def subwallet(self):
-		from ..subseed import SubSeedIdxRange
-		return f"""
-SUBWALLETS:
-
-Subwallets (subseeds) are specified by a ‘Subseed Index’ consisting of:
-
-  a) an integer in the range 1-{SubSeedIdxRange.max_idx}, plus
-  b) an optional single letter, ‘L’ or ‘S’
-
-The letter designates the length of the subseed.  If omitted, ‘L’ is assumed.
-
-Long (‘L’) subseeds are the same length as their parent wallet’s seed
-(typically 256 bits), while short (‘S’) subseeds are always 128-bit.
-The long and short subseeds for a given index are derived independently,
-so both may be used.
-
-MMGen Wallet has no notion of ‘depth’, and to an outside observer subwallets
-are identical to ordinary wallets.  This is a feature rather than a bug, as
-it denies an attacker any way of knowing whether a given wallet has a parent.
-
-Since subwallets are just wallets, they may be used to generate other
-subwallets, leading to hierarchies of arbitrary depth.  However, this is
-inadvisable in practice for two reasons:  Firstly, it creates accounting
-complexity, requiring the user to independently keep track of a derivation
-tree.  More importantly, however, it leads to the danger of Seed ID
-collisions between subseeds at different levels of the hierarchy, as
-MMGen checks and avoids ID collisions only among sibling subseeds.
-
-An exception to this caveat would be a multi-user setup where sibling
-subwallets are distributed to different users as their default wallets.
-Since the subseeds derived from these subwallets are private to each user,
-Seed ID collisions among them doesn’t present a problem.
-
-A safe rule of thumb, therefore, is for *each user* to derive all of his/her
-subwallets from a single parent.  This leaves each user with a total of two
-million subwallets, which should be enough for most practical purposes.
 """.strip()

+ 52 - 0
mmgen/help/subwallet.py

@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+help.subwallet: subwallet help notes for the MMGen Wallet suite
+"""
+
+def help(proto, cfg):
+	from ..subseed import SubSeedIdxRange
+	return f"""
+SUBWALLETS:
+
+Subwallets (subseeds) are specified by a ‘Subseed Index’ consisting of:
+
+  a) an integer in the range 1-{SubSeedIdxRange.max_idx}, plus
+  b) an optional single letter, ‘L’ or ‘S’
+
+The letter designates the length of the subseed.  If omitted, ‘L’ is assumed.
+
+Long (‘L’) subseeds are the same length as their parent wallet’s seed
+(typically 256 bits), while short (‘S’) subseeds are always 128-bit.
+The long and short subseeds for a given index are derived independently,
+so both may be used.
+
+MMGen Wallet has no notion of ‘depth’, and to an outside observer subwallets
+are identical to ordinary wallets.  This is a feature rather than a bug, as
+it denies an attacker any way of knowing whether a given wallet has a parent.
+
+Since subwallets are just wallets, they may be used to generate other
+subwallets, leading to hierarchies of arbitrary depth.  However, this is
+inadvisable in practice for two reasons:  Firstly, it creates accounting
+complexity, requiring the user to independently keep track of a derivation
+tree.  More importantly, however, it leads to the danger of Seed ID
+collisions between subseeds at different levels of the hierarchy, as
+MMGen checks and avoids ID collisions only among sibling subseeds.
+
+An exception to this caveat would be a multi-user setup where sibling
+subwallets are distributed to different users as their default wallets.
+Since the subseeds derived from these subwallets are private to each user,
+Seed ID collisions among them doesn’t present a problem.
+
+A safe rule of thumb, therefore, is for *each user* to derive all of his/her
+subwallets from a single parent.  This leaves each user with a total of two
+million subwallets, which should be enough for most practical purposes.
+""".strip()

+ 82 - 0
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 <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+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.
+"""

+ 72 - 0
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 <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+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
+"""

+ 59 - 0
mmgen/help/txcreate.py

@@ -0,0 +1,59 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+help.txcreate: txcreate and txdo help notes for the MMGen Wallet suite
+"""
+
+def help(proto, cfg):
+	outputs_info = (
+	"""
+Outputs are specified in the form ADDRESS,AMOUNT or ADDRESS.  The first form
+creates an output sending the given amount to the given address.  The bare
+address form designates the given address as either the change output or the
+sole output of the transaction (excluding any data output).  Exactly one bare
+address argument is required.
+
+For convenience, the bare address argument may be given as ADDRTYPE_CODE or
+SEED_ID:ADDRTYPE_CODE (see ADDRESS TYPES below). In the first form, the first
+unused address of type ADDRTYPE_CODE for each Seed ID in the tracking wallet
+will be displayed in a menu, with the user prompted to select one.  In the
+second form, the user specifies the Seed ID as well, allowing the script to
+select the transaction’s change output or single output without prompting.
+See EXAMPLES below.
+
+A single DATA_SPEC argument may also be given on the command line to create
+an OP_RETURN data output with a zero spend amount.  This is the preferred way
+to embed data in the blockchain.  DATA_SPEC may be of the form "data":DATA
+or "hexdata":DATA. In the first form, DATA is a string in your system’s native
+encoding, typically UTF-8.  In the second, DATA is a hexadecimal string (with
+the leading ‘0x’ omitted) encoding the binary data to be embedded.  In both
+cases, the resulting byte string must not exceed {bl} bytes in length.
+""".format(bl=proto.max_op_return_data_len)
+	if proto.base_proto == 'Bitcoin' else """
+The transaction output is specified in the form ADDRESS,AMOUNT.
+""")
+
+	return f"""
+The transaction’s outputs are listed on the command line, while its inputs
+are chosen from a list of the wallet’s unspent outputs via an interactive
+menu.  Alternatively, inputs may be specified using the --inputs option.
+
+Addresses on the command line can be either native coin addresses or MMGen
+IDs in the form SEED_ID:ADDRTYPE_CODE:INDEX.
+{outputs_info}
+If the transaction fee is not specified on the command line (see FEE
+SPECIFICATION below), it will be calculated dynamically using network fee
+estimation for the default (or user-specified) number of confirmations.
+If network fee estimation fails, the user will be prompted for a fee.
+
+Network-estimated fees will be multiplied by the value of --fee-adjust, if
+specified.
+"""

+ 65 - 0
mmgen/help/txcreate_examples.py

@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+help.txcreate_examples: txcreate and txdo help examples for the MMGen Wallet suite
+"""
+
+from ..cfg import gc
+
+def help(proto, cfg):
+
+	mmtype = 'B' if 'B' in proto.mmtypes else proto.mmtypes[0]
+	from ..tool.coin import tool_cmd
+	t = tool_cmd(cfg, mmtype=mmtype)
+	addr = t.privhex2addr('bead' * 16)
+	sample_addr = addr.views[addr.view_pref]
+
+	return f"""
+EXAMPLES:
+
+  Send 0.123 {proto.coin} to an external {proto.name} address, returning the change to a
+  specific MMGen address in the tracking wallet:
+
+    $ {gc.prog_name} {sample_addr},0.123 01ABCDEF:{mmtype}:7
+
+  Same as above, but select the change address automatically:
+
+    $ {gc.prog_name} {sample_addr},0.123 01ABCDEF:{mmtype}
+
+  Same as above, but select the change address automatically by address type:
+
+    $ {gc.prog_name} {sample_addr},0.123 {mmtype}
+
+  Same as above, but reduce verbosity and specify fee of 20 satoshis
+  per byte:
+
+    $ {gc.prog_name} -q -f 20s {sample_addr},0.123 {mmtype}
+
+  Send entire balance of selected inputs minus fee to an external {proto.name}
+  address:
+
+    $ {gc.prog_name} {sample_addr}
+
+  Send entire balance of selected inputs minus fee to first unused wallet
+  address of specified type:
+
+    $ {gc.prog_name} {mmtype}
+""" if proto.base_proto == 'Bitcoin' else f"""
+EXAMPLES:
+
+  Send 0.123 {proto.coin} to an external {proto.name} address:
+
+    $ {gc.prog_name} {sample_addr},0.123
+
+  Send 0.123 {proto.coin} to another account in wallet 01ABCDEF:
+
+    $ {gc.prog_name} 01ABCDEF:{mmtype}:7,0.123
+"""

+ 48 - 0
mmgen/help/txsign.py

@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+#
+# MMGen Wallet, a terminal-based cryptocurrency wallet
+# Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+help.txsign: txsign help notes for the MMGen Wallet suite
+"""
+
+from ..cfg import gc
+from ..proto.btc.params import mainnet
+from ..daemon import CoinDaemon
+
+def help(proto, cfg):
+
+	def coind_exec():
+		return CoinDaemon(cfg, proto.coin).exec_fn if proto.coin in CoinDaemon.coins else 'bitcoind'
+
+	return """
+Transactions may contain both {pnm} or non-{pnm} input addresses.
+
+To sign non-{pnm} inputs, a {wd}flat key list is used
+as the key source (--keys-from-file option).
+
+To sign {pnm} inputs, key data is generated from a seed as with the
+{pnl}-addrgen and {pnl}-keygen commands.  Alternatively, a key-address file
+may be used (--mmgen-keys-from-file option).
+
+Multiple wallets or other seed files can be listed on the command line in
+any order.  If the seeds required to sign the transaction’s inputs are not
+found in these files (or in the default wallet), the user will be prompted
+for seed data interactively.
+
+To prevent an attacker from crafting transactions with bogus {pnm}-to-{pnu}
+address mappings, all outputs to {pnm} addresses are verified with a seed
+source.  Therefore, seed files or a key-address file for all {pnm} outputs
+must also be supplied on the command line if the data can’t be found in the
+default wallet.
+""".format(
+	wd  = f'{coind_exec()} wallet dump or ' if isinstance(proto, mainnet) else '',
+	pnm = gc.proj_name,
+	pnu = proto.name,
+	pnl = gc.proj_name.lower())

+ 1 - 1
mmgen/help/xmrwallet.py

@@ -9,7 +9,7 @@
 #   https://gitlab.com/mmgen/mmgen-wallet
 
 """
-help.xmrwallet: xmrwallet help notes for MMGen suite
+help.xmrwallet: xmrwallet help notes for the MMGen Wallet suite
 """
 
 def help(proto, cfg):

+ 2 - 2
mmgen/main_addrgen.py

@@ -111,9 +111,9 @@ range(s).
 			cfg       = cfg,
 			gc        = gc,
 		),
-		'notes': lambda help_notes, s: s.format(
+		'notes': lambda help_mod, help_notes, s: s.format(
 			n_addrkey = note_addrkey,
-			n_sw      = help_notes('subwallet')+'\n\n',
+			n_sw      = help_mod('subwallet')+'\n\n',
 			n_pw      = help_notes('passwd')+'\n\n',
 			n_bw      = help_notes('brainwallet'),
 			n_fmt     = help_notes('fmt_codes'),

+ 2 - 0
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': """

+ 18 - 17
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,7 +98,13 @@ 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.
 
-{e}{s}
+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'
 column below:
 
@@ -104,7 +113,7 @@ column below:
 	},
 	'code': {
 		'usage': lambda cfg, proto, help_notes, s: s.format(
-			u_args = help_notes('txcreate_args', 'tx')),
+			u_args = help_notes('txcreate_args')),
 		'options': lambda cfg, help_notes, proto, s: s.format(
 			cfg     = cfg,
 			gc      = gc,
@@ -116,9 +125,9 @@ column below:
 			coin_id = help_notes('coin_id'),
 			dsl     = help_notes('dfl_seed_len'),
 			cu      = proto.coin),
-		'notes': lambda help_notes, s: s.format(
+		'notes': lambda help_mod, help_notes, s: s.format(
 			e       = help_notes('fee'),
-			s       = help_notes('txsign'),
+			s       = help_mod('txsign'),
 			f       = help_notes('fmt_codes')),
 	}
 }
@@ -164,34 +173,26 @@ async def main():
 		kal = kl = sign_and_send = None
 	else:
 		orig_tx = await CompletedTX(cfg=cfg, filename=tx_file)
+		kal = get_keyaddrlist(cfg, orig_tx.proto)
+		kl = get_keylist(cfg)
+		sign_and_send = any([seed_files, kl, kal])
 
 	if not silent:
 		msg(green('ORIGINAL TRANSACTION'))
 		msg(orig_tx.info.format(terse=True))
 
-	if not cfg.autosign:
-		kal = get_keyaddrlist(cfg, orig_tx.proto)
-		kl = get_keylist(cfg)
-		sign_and_send = any([seed_files, kl, kal])
-
 	from .tw.ctl import TwCtl
 	tx = await BumpTX(
 		cfg  = cfg,
 		data = orig_tx.__dict__,
 		automount = cfg.autosign,
 		check_sent = cfg.autosign or sign_and_send,
+		new_outputs = bool(cfg._args),
 		twctl = await TwCtl(cfg, orig_tx.proto) if orig_tx.proto.tokensym else None)
 
-	tx.orig_rel_fee = tx.get_orig_rel_fee()
-
-	if cfg._args:
-		tx.new_outputs = True
-		tx.is_swap = False
-		tx.outputs = tx.OutputList(tx)
-		tx.cfg = cfg # NB: with --automount, must use current cfg opts, not those from orig_tx
+	if tx.new_outputs:
 		await tx.create(cfg._args, caller='txdo' if sign_and_send else 'txcreate')
 	else:
-		tx.new_outputs = False
 		await tx.create_feebump(silent=silent)
 
 	if not silent:

+ 7 - 6
mmgen/main_txcreate.py

@@ -35,7 +35,7 @@ opts_data = {
 	'text': {
 		'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',
+			'swaptx': f'Create a DEX swap transaction from one {gc.proj_name} tracking wallet to another',
 		}[target],
 		'usage':   '[opts] {u_args} [addr file ...]',
 		'options': """
@@ -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', target)),
+			u_args = help_notes(f'{target}create_args')),
 		'options': lambda cfg, proto, help_notes, s: s.format(
 			cfg    = cfg,
 			cu     = proto.coin,
@@ -91,11 +92,11 @@ opts_data = {
 			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'),
+		'notes': lambda cfg, help_mod, help_notes, s: s.format(
+			c      = help_mod(f'{target}create'),
 			F      = help_notes('fee'),
-			x      = help_notes('txcreate_examples'),
-			n_at   = help_notes('address_types'))
+			n_at   = help_notes('address_types'),
+			x      = help_mod(f'{target}create_examples'))
 	}
 }
 

+ 12 - 10
mmgen/main_txdo.py

@@ -17,7 +17,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 """
-mmgen-txdo: Create, sign and broadcast an online MMGen transaction
+mmgen-txdo: Create, sign and send an online MMGen transaction
 """
 
 from .cfg import gc, Config
@@ -35,7 +35,7 @@ opts_data = {
 	'text': {
 		'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',
+			'swaptx': f'Create, sign and send a DEX swap transaction from one {gc.proj_name} tracking wallet to another',
 		}[target],
 		'usage':   '[opts] {u_args} [addr file ...] [seed source ...]',
 		'options': """
@@ -93,27 +93,29 @@ 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
 		""",
 		'notes': """
-{c}\n{F}
+{c}
+{n_at}
+
+{F}
 
                                  SIGNING NOTES
 {s}
 Seed source files must have the canonical extensions listed in the 'FileExt'
 column below:
 
-{n_at}
-
 {f}
 
 {x}"""
 	},
 	'code': {
 		'usage': lambda cfg, proto, help_notes, s: s.format(
-			u_args  = help_notes('txcreate_args', target)),
+			u_args  = help_notes(f'{target}create_args')),
 		'options': lambda cfg, proto, help_notes, s: s.format(
 			gc      = gc,
 			cfg     = cfg,
@@ -132,13 +134,13 @@ column below:
 			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'),
+		'notes': lambda cfg, help_mod, help_notes, s: s.format(
+			c       = help_mod(f'{target}create'),
 			F       = help_notes('fee'),
-			s       = help_notes('txsign'),
 			n_at    = help_notes('address_types'),
 			f       = help_notes('fmt_codes'),
-			x       = help_notes('txcreate_examples')),
+			s       = help_mod('txsign'),
+			x       = help_mod(f'{target}create_examples'))
 	}
 }
 

+ 7 - 2
mmgen/main_txsend.py

@@ -105,8 +105,13 @@ async def main():
 	if cfg.status:
 		if tx.coin_txid:
 			cfg._util.qmsg(f'{tx.proto.coin} txid: {tx.coin_txid.hl()}')
-		await tx.status.display(usr_req=True)
-		sys.exit(0)
+		retval = await tx.status.display(usr_req=True, return_exit_val=True)
+		if cfg.verbose:
+			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?')

+ 4 - 2
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': """
@@ -89,8 +91,8 @@ column below:
 			ss      = help_notes('dfl_subseeds'),
 			ss_max  = SubSeedIdxRange.max_idx,
 			cu      = proto.coin),
-		'notes': lambda help_notes, s: s.format(
-			t       = help_notes('txsign'),
+		'notes': lambda help_mod, help_notes, s: s.format(
+			t       = help_mod('txsign'),
 			f       = help_notes('fmt_codes')),
 	}
 }

+ 1 - 1
mmgen/main_wallet.py

@@ -138,7 +138,7 @@ opts_data = {
 		'notes': lambda cfg, help_mod, help_notes, s: s.format(
 			f       = help_notes('fmt_codes'),
 			n_ss    = ('', help_mod('seedsplit')+'\n\n')[do_ss_note],
-			n_sw    = ('', help_notes('subwallet')+'\n\n')[do_sw_note],
+			n_sw    = ('', help_mod('subwallet')+'\n\n')[do_sw_note],
 			n_pw    = help_notes('passwd'),
 			n_bw    = ('', '\n\n'+help_notes('brainwallet'))[do_bw_note]
 		)

+ 1 - 0
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)

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

+ 1 - 3
mmgen/proto/btc/rpc.py

@@ -128,9 +128,7 @@ class BitcoinRPCClient(RPCClient, metaclass=AsyncInit):
 
 		super().__init__(
 			cfg  = cfg,
-			host = (
-				'localhost' if cfg.test_suite or cfg.network == 'regtest'
-				else (proto.rpc_host or cfg.rpc_host or 'localhost')),
+			host = proto.rpc_host or cfg.rpc_host or 'localhost',
 			port = daemon.rpc_port)
 
 		self.set_auth()

+ 15 - 1
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]

+ 2 - 2
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):

+ 19 - 1
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)

+ 1 - 1
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)
 

+ 5 - 2
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):

+ 126 - 3
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')

+ 11 - 4
mmgen/proto/btc/tx/status.py

@@ -20,7 +20,14 @@ from ....util2 import format_elapsed_hr
 
 class Status(TxBase.Status):
 
-	async def display(self, usr_req=False):
+	async def display(self, *, usr_req=False, return_exit_val=False):
+
+		def do_exit(retval, message):
+			if return_exit_val:
+				msg(message)
+				return retval
+			else:
+				die(retval, message)
 
 		tx = self.tx
 
@@ -91,9 +98,9 @@ class Status(TxBase.Status):
 			else:
 				msg('Warning: transaction is in mempool!')
 		elif await is_in_wallet():
-			die(0, f'Transaction has {r.confs} confirmation{suf(r.confs)}')
+			return do_exit(0, f'Transaction has {r.confs} confirmation{suf(r.confs)}')
 		elif await is_in_utxos():
-			die(4, 'ERROR: transaction is in the blockchain (but not in the tracking wallet)!')
+			return do_exit(4, 'ERROR: transaction is in the blockchain (but not in the tracking wallet)!')
 		elif await is_replaced():
 			msg('Transaction has been replaced')
 			msg('Replacement transaction ' + (
@@ -110,4 +117,4 @@ class Status(TxBase.Status):
 						d.append({})
 				for txid, mp_entry in zip(r.replacing_txs, d):
 					msg(f'  {txid}' + (' in mempool' if 'height' in mp_entry else ''))
-			die(0, '')
+			return do_exit(0, '')

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

+ 1 - 1
mmgen/proto/eth/rpc.py

@@ -48,7 +48,7 @@ class EthereumRPCClient(RPCClient, metaclass=AsyncInit):
 
 		super().__init__(
 			cfg  = cfg,
-			host = 'localhost' if cfg.test_suite else (proto.rpc_host or cfg.rpc_host or 'localhost'),
+			host = proto.rpc_host or cfg.rpc_host or 'localhost',
 			port = daemon.rpc_port)
 
 		await self.set_backend_async(backend)

+ 2 - 2
mmgen/proto/eth/tx/bump.py

@@ -21,8 +21,8 @@ from .new import New, TokenNew
 class Bump(Completed, New, TxBase.Bump):
 	desc = 'fee-bumped transaction'
 
-	def get_orig_rel_fee(self): # disable this check for ETH
-		return 0
+	def get_orig_rel_fee(self):
+		return self.txobj['gasPrice'].to_unit('Gwei')
 
 	@property
 	def min_fee(self):

+ 3 - 0
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')

+ 10 - 3
mmgen/proto/eth/tx/status.py

@@ -17,7 +17,14 @@ from ....util import msg, die, suf, capfirst
 
 class Status(TxBase.Status):
 
-	async def display(self, usr_req=False):
+	async def display(self, *, usr_req=False, return_exit_val=False):
+
+		def do_exit(retval, message):
+			if return_exit_val:
+				msg(message)
+				return retval
+			else:
+				die(retval, message)
 
 		tx = self.tx
 
@@ -56,8 +63,8 @@ class Status(TxBase.Status):
 						msg(f'{cd} failed to execute!')
 					else:
 						msg(f'{cd} successfully executed with status {ret.exec_status}')
-				die(0, f'Transaction has {ret.confs} confirmation{suf(ret.confs)}')
-			die(1, 'Transaction is neither in mempool nor blockchain!')
+				return do_exit(0, f'Transaction has {ret.confs} confirmation{suf(ret.confs)}')
+			return do_exit(1, 'Transaction is neither in mempool nor blockchain!')
 
 class TokenStatus(Status):
 	pass

+ 25 - 0
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 <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+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)

+ 134 - 0
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 <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+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

+ 113 - 0
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 <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+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)

+ 28 - 0
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 <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+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',
+		}
+	}

+ 19 - 3
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()]
+
+		assert isinstance(mmtype, (type(None), MMGenAddrType))
 
-		res = [self.get_change_address(f'{sid}:{mmtype}', r.bot, r.top, exclude)
-				for sid, r in self.sid_ranges.items()]
+		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))

+ 4 - 0
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):

+ 37 - 7
mmgen/tx/bump.py

@@ -12,19 +12,36 @@
 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_proto',
+		'swap_quote_expiry',
+		'swap_recv_addr_mmid')
 
-	def __init__(self, check_sent, *args, **kwargs):
+	def __init__(self, *, check_sent, new_outputs, **kwargs):
 
-		super().__init__(*args, **kwargs)
+		super().__init__(**kwargs)
+
+		self.new_outputs = new_outputs
+		self.orig_rel_fee = self.get_orig_rel_fee()
+
+		if new_outputs:
+			from .base import Base
+			if self.is_swap:
+				for attr in self.swap_attrs:
+					setattr(self, attr, getattr(Base, attr))
+			self.outputs = self.OutputList(self)
+			self.cfg = kwargs['cfg'] # must use current cfg opts, not those from orig_tx
 
 		if not self.is_replaceable():
 			die(1, f'Transaction {self.txid} is not replaceable')
@@ -60,9 +77,22 @@ class Bump(Completed, New):
 		output_idx = self.choose_output()
 
 		if not silent:
-			msg(f'Minimum fee for new transaction: {self.min_fee.hl()} {self.proto.coin}')
-
-		self.usr_fee = self.get_usr_fee_interactive(fee=self.cfg.fee, desc='User-selected')
+			msg('Minimum fee for new transaction: {} {} ({} {})'.format(
+				self.min_fee.hl(),
+				self.proto.coin,
+				pink(self.fee_abs2rel(self.min_fee)),
+				self.rel_fee_disp))
+
+		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)
 

+ 6 - 2
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
@@ -269,7 +272,8 @@ class MMGenTxFile(MMGenObject):
 					k: getattr(tx, k) for k in self.attrs
 				} | {
 					'inputs':  [e._asdict() for e in tx.inputs],
-					'outputs': [e._asdict() for e in tx.outputs]
+					'outputs': [{k:v for k,v in e._asdict().items() if not (type(v) is bool and v is False)}
+									for e in tx.outputs]
 				} | {
 					k: getattr(tx, k) for k in self.extra_attrs if getattr(tx, k)
 				})

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

+ 42 - 23
mmgen/tx/new.py

@@ -83,7 +83,9 @@ class New(Base):
 	_funds_available = namedtuple('funds_available', ['is_positive', 'amt'])
 
 	def __init__(self, *args, target=None, **kwargs):
-		self.is_swap = target == 'swaptx'
+		if target == 'swaptx':
+			self.is_swap = True
+			self.swap_proto = kwargs['cfg'].swap_proto
 		super().__init__(*args, **kwargs)
 
 	def warn_insufficient_funds(self, amt, coin):
@@ -168,22 +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)
 
-		if mmid := get_obj(MMGenID, proto=proto, id_str=arg, silent=True):
+		coin_addr, mmid, is_vault = (None, None, False)
+
+		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)
@@ -191,17 +198,19 @@ class New(Base):
 			if proto.base_proto_coin != 'BTC':
 				die(2, f'Change addresses not supported for {proto.name} protocol')
 			self.chg_autoselected = True
-			coin_addr = None
 		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:
@@ -219,7 +228,7 @@ class New(Base):
 
 		parsed_args = [self.parse_cmdline_arg(self.proto, arg, ad_f, ad_w) for arg in cmd_args]
 
-		chg_args = [a for a in parsed_args if not ((a.amt and a.addr) or a.data)]
+		chg_args = [a for a in parsed_args if not (a.amt or a.data)]
 
 		if len(chg_args) > 1:
 			desc = 'requested' if self.chg_autoselected else 'listed'
@@ -229,13 +238,16 @@ class New(Base):
 			if a.data:
 				self.add_output(None, self.proto.coin_amt('0'), data=a.data)
 			else:
-				exclude = [a.mmid for a in parsed_args if a.mmid]
 				self.add_output(
-					coinaddr = a.addr or (
-						await self.get_autochg_addr(self.proto, a.arg, exclude=exclude, desc='change address')
-					).addr,
-					amt      = self.proto.coin_amt(a.amt or '0'),
-					is_chg   = not a.amt)
+					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_vault = a.is_vault)
 
 		if self.chg_idx is None:
 			die(2,
@@ -256,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')
@@ -387,13 +399,14 @@ class New(Base):
 				sel_unspent,
 				outputs_sum):
 			return False
+
 		self.copy_inputs_from_tw(sel_unspent)  # makes self.inputs
 		return True
 
-	async def get_fee(self, fee, outputs_sum):
+	async def get_fee(self, fee, outputs_sum, start_fee_desc):
 
 		if fee:
-			self.usr_fee = self.get_usr_fee_interactive(fee, 'User-selected')
+			self.usr_fee = self.get_usr_fee_interactive(fee, start_fee_desc)
 		else:
 			fee_per_kb, fe_type = await self.get_rel_fee_from_network()
 			self.usr_fee = self.get_usr_fee_interactive(
@@ -424,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
@@ -468,7 +481,10 @@ class New(Base):
 			fee_hint = None
 			if self.is_swap:
 				fee_hint = self.update_vault_output(self.vault_output.amt or self.sum_inputs())
-			if funds_left := await self.get_fee(fee_hint or self.cfg.fee, outputs_sum):
+			if funds_left := await self.get_fee(
+					self.cfg.fee or fee_hint,
+					outputs_sum,
+					'User-selected' if self.cfg.fee else 'Recommended' if fee_hint else None):
 				break
 
 		self.check_non_mmgen_inputs(caller)
@@ -480,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()

+ 21 - 1
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

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

+ 6 - 0
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')

+ 2 - 4
mmgen/tx/util.py

@@ -17,13 +17,11 @@ def get_autosign_obj(cfg):
 	from ..autosign import Autosign
 	return Autosign(
 		Config({
+			'_clone': cfg,
 			'mountpoint': cfg.autosign_mountpoint,
-			'test_suite': cfg.test_suite,
-			'test_suite_root_pfx': cfg.test_suite_root_pfx,
 			'coins': cfg.coin,
 			'online': not cfg.offline, # used only in online environment (xmrwallet, txcreate, txsend, txbump)
-		})
-	)
+		}))
 
 def mount_removable_device(cfg):
 	asi = get_autosign_obj(cfg)

+ 12 - 10
scripts/exec_wrapper.py

@@ -123,6 +123,15 @@ def exec_wrapper_tracemalloc_log():
 			s = sum(stat.size for stat in stats) / 1024,
 			w = col1w))
 
+def exec_wrapper_do_exit(e, exit_val):
+	if exit_val != 0:
+		exec_wrapper_write_traceback(e, exit_val)
+	else:
+		if exec_wrapper_os.getenv('MMGEN_TRACEMALLOC'):
+			exec_wrapper_tracemalloc_log()
+		exec_wrapper_end_msg()
+	exec_wrapper_sys.exit(exit_val)
+
 import sys as exec_wrapper_sys
 import os as exec_wrapper_os
 import time as exec_wrapper_time
@@ -145,17 +154,10 @@ try:
 	with open(exec_wrapper_execed_file) as fp:
 		exec(fp.read())
 except SystemExit as e:
-	if e.code != 0:
-		exec_wrapper_write_traceback(e, e.code)
-	else:
-		if exec_wrapper_os.getenv('MMGEN_TRACEMALLOC'):
-			exec_wrapper_tracemalloc_log()
-		exec_wrapper_end_msg()
-	exec_wrapper_sys.exit(e.code)
+	exec_wrapper_do_exit(e, e.code)
 except Exception as e:
-	exit_val = e.mmcode if hasattr(e, 'mmcode') else e.code if hasattr(e, 'code') else 1
-	exec_wrapper_write_traceback(e, exit_val)
-	exec_wrapper_sys.exit(exit_val)
+	exec_wrapper_do_exit(
+		e, e.mmcode if hasattr(e, 'mmcode') else e.code if hasattr(e, 'code') else 1)
 
 if exec_wrapper_os.getenv('MMGEN_TRACEMALLOC'):
 	exec_wrapper_tracemalloc_log()

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

+ 3 - 2
test/cmdtest.py

@@ -447,7 +447,7 @@ class CmdGroupMgr:
 				yield '  {} - {}'.format(
 					yellow(name.ljust(13)),
 					(cls.__doc__.strip() if cls.__doc__ else cls.__name__))
-				if hasattr(cls, 'cmd_subgroups'):
+				if 'cmd_subgroups' in cls.__dict__:
 					subgroups = {k:v for k, v in cls.cmd_subgroups.items() if not k.startswith('_')}
 					max_w = max(len(k) for k in subgroups)
 					for k, v in subgroups.items():
@@ -762,7 +762,8 @@ class CmdTestRunner:
 									if isinstance(e, KeyError) and e.args[0] == cmdname:
 										ret = getattr(self.tg, cmdname)()
 										if type(ret).__name__ == 'coroutine':
-											asyncio.run(ret)
+											ret = asyncio.run(ret)
+										self.process_retval(cmdname, ret)
 									else:
 										raise
 								do_between()

+ 17 - 65
test/cmdtest_d/ct_automount.py

@@ -14,14 +14,15 @@ test.cmdtest_d.ct_automount: autosigning with automount tests for the cmdtest.py
 import time
 
 from .ct_autosign import CmdTestAutosignThreaded
-from .ct_regtest import CmdTestRegtestBDBWallet, rt_pw
+from .ct_regtest import CmdTestRegtest, rt_pw
 from ..include.common import cfg, gr_uc
 
-class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet):
+class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtest):
 	'automounted transacting operations via regtest mode'
 
 	networks = ('btc', 'bch', 'ltc')
 	tmpdir_nums = [49]
+	bdb_wallet = True
 
 	rt_data = {
 		'rtFundAmt': {'btc':'500', 'bch':'500', 'ltc':'5500'},
@@ -78,59 +79,26 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
 		self.coins = [cfg.coin.lower()]
 
 		CmdTestAutosignThreaded.__init__(self, trunner, cfgs, spawn)
-		CmdTestRegtestBDBWallet.__init__(self, trunner, cfgs, spawn)
+		CmdTestRegtest.__init__(self, trunner, cfgs, spawn)
 
 		if trunner is None:
 			return
 
 		self.opts.append('--alice')
 
-	def _alice_txcreate(self, chg_addr, opts=[], exit_val=0, expect_str=None, data_arg=None, need_rbf=False):
-
-		if need_rbf and not self.proto.cap('rbf'):
-			return 'skip'
-
-		def do_return():
-			if expect_str:
-				t.expect(expect_str)
-			t.read()
-			self.remove_device_online()
-			return t
-
-		self.insert_device_online()
-
-		sid = self._user_sid('alice')
-		t = self.spawn(
-			'mmgen-txcreate',
-			opts
-			+ ['--alice', '--autosign']
-			+ ([data_arg] if data_arg else [])
-			+ [f'{self.burn_addr},1.23456', f'{sid}:{chg_addr}'],
-			exit_val = exit_val or None)
-
-		if exit_val:
-			return do_return()
-
-		t = self.txcreate_ui_common(
-			t,
-			inputs          = '1',
-			interactive_fee = '32s',
-			file_desc       = 'Unsigned automount transaction')
-
-		return do_return()
-
 	def alice_txcreate1(self):
-		return self._alice_txcreate(
+		return self._user_txcreate(
+			'alice',
 			chg_addr = 'C:5',
 			data_arg = 'data:'+gr_uc[:24])
 
 	def alice_txcreate2(self):
-		return self._alice_txcreate(chg_addr='L:5')
+		return self._user_txcreate('alice', chg_addr='L:5')
 
 	alice_txcreate3 = alice_txcreate2
 
 	def alice_txcreate4(self):
-		return self._alice_txcreate(chg_addr='L:4', need_rbf=True)
+		return self._user_txcreate('alice', chg_addr='L:4', need_rbf=True)
 
 	def _alice_txsend_abort(self, err=False, send_resp='y', expect=None, shred_expect=[]):
 		self.insert_device_online()
@@ -166,25 +134,25 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
 	alice_txsend_abort5 = alice_txsend_abort2
 
 	def alice_txcreate_bad_have_unsigned(self):
-		return self._alice_txcreate(chg_addr='C:5', exit_val=2, expect_str='already present')
+		return self._user_txcreate('alice', chg_addr='C:5', exit_val=2, expect_str='already present')
 
 	def alice_txcreate_bad_have_unsent(self):
-		return self._alice_txcreate(chg_addr='C:5', exit_val=2, expect_str='unsent transaction')
+		return self._user_txcreate('alice', chg_addr='C:5', exit_val=2, expect_str='unsent transaction')
 
 	def alice_run_autosign_setup(self):
 		return self.run_setup(mn_type='default', use_dfl_wallet=True, passwd=rt_pw)
 
 	def alice_txsend1(self):
-		return self._alice_txsend('This one’s worth a comment', no_wait=True)
+		return self._user_txsend('alice', 'This one’s worth a comment', no_wait=True)
 
 	def alice_txsend2(self):
-		return self._alice_txsend(need_rbf=True)
+		return self._user_txsend('alice', need_rbf=True)
 
 	def alice_txsend3(self):
-		return self._alice_txsend(need_rbf=True)
+		return self._user_txsend('alice', need_rbf=True)
 
 	def alice_txsend5(self):
-		return self._alice_txsend(need_rbf=True)
+		return self._user_txsend('alice', need_rbf=True)
 
 	def _alice_txstatus(self, expect, exit_val=None, need_rbf=False):
 
@@ -197,6 +165,8 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
 				['--alice', '--autosign', '--status', '--verbose'],
 				exit_val = exit_val)
 		t.expect(expect)
+		if not exit_val:
+			t.expect('view: ', 'n')
 		t.read()
 		self.remove_device_online()
 		return t
@@ -209,7 +179,7 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
 		return self._alice_txstatus('unsent', 1)
 
 	def alice_txstatus3(self):
-		return self._alice_txstatus('in mempool')
+		return self._alice_txstatus('in mempool', 0)
 
 	def alice_txstatus4(self):
 		return self._alice_txstatus('1 confirmation', 0)
@@ -217,24 +187,6 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
 	def alice_txstatus5(self):
 		return self._alice_txstatus('in mempool', need_rbf=True)
 
-	def _alice_txsend(self, comment=None, no_wait=False, need_rbf=False):
-
-		if need_rbf and not self.proto.cap('rbf'):
-			return 'skip'
-
-		if not no_wait:
-			self._wait_signed('transaction')
-
-		self.insert_device_online()
-		t = self.spawn('mmgen-txsend', ['--alice', '--quiet', '--autosign'])
-		t.view_tx('t')
-		t.do_comment(comment)
-		self._do_confirm_send(t, quiet=True)
-		t.written_to_file('Sent automount transaction')
-		t.read()
-		self.remove_device_online()
-		return t
-
 	def alice_txsend_bad_no_unsent(self):
 		self.insert_device_online()
 		t = self.spawn('mmgen-txsend', ['--quiet', '--autosign'], exit_val=2)

+ 76 - 4
test/cmdtest_d/ct_autosign.py

@@ -441,19 +441,91 @@ class CmdTestAutosignThreaded(CmdTestAutosignBase):
 	no_insert_check = False
 	threaded        = True
 
-	def _wait_loop_start(self):
+	def _user_txcreate(
+			self,
+			user,
+			progname    = 'txcreate',
+			input_handler = None,
+			chg_addr    = None,
+			opts        = [],
+			output_args = [],
+			exit_val    = 0,
+			expect_str  = None,
+			data_arg    = None,
+			need_rbf    = False):
+
+		if output_args:
+			assert not chg_addr
+
+		if chg_addr:
+			assert not output_args
+
+		if need_rbf and not self.proto.cap('rbf'):
+			return 'skip'
+
+		def do_return():
+			if expect_str:
+				t.expect(expect_str)
+			t.read()
+			self.remove_device_online()
+			return t
+
+		self.insert_device_online()
+
+		sid = self._user_sid(user)
+		t = self.spawn(
+			f'mmgen-{progname}',
+			opts
+			+ [f'--{user}', '--autosign']
+			+ ([data_arg] if data_arg else [])
+			+ (output_args or [f'{self.burn_addr},1.23456', f'{sid}:{chg_addr}']),
+			exit_val = exit_val or None)
+
+		if exit_val:
+			return do_return()
+
+		t = (input_handler or self.txcreate_ui_common)(
+			t,
+			inputs          = '1',
+			interactive_fee = '32s',
+			file_desc       = 'Unsigned automount transaction')
+
+		return do_return()
+
+	def _user_txsend(self, user, comment=None, no_wait=False, need_rbf=False):
+
+		if need_rbf and not self.proto.cap('rbf'):
+			return 'skip'
+
+		if not no_wait:
+			self._wait_signed('transaction')
+
+		self.insert_device_online()
+		t = self.spawn('mmgen-txsend', [f'--{user}', '--quiet', '--autosign'])
+		t.view_tx('t')
+		t.do_comment(comment)
+		self._do_confirm_send(t, quiet=True)
+		t.written_to_file('Sent automount transaction')
+		t.read()
+		self.remove_device_online()
+		return t
+
+	def _wait_loop_start(self, add_opts=[]):
 		t = self.spawn(
 			'mmgen-autosign',
-			self.opts + ['--full-summary', 'wait'],
+			self.opts + add_opts + ['--full-summary', 'wait'],
 			direct_exec      = True,
 			no_passthru_opts = True,
 			spawn_env_override = self.spawn_env | {'EXEC_WRAPPER_DO_RUNTIME_MSG': ''})
 		self.write_to_tmpfile('autosign_thread_pid', str(t.ep.pid))
 		return t
 
-	def wait_loop_start(self):
+	def wait_loop_start(self, add_opts=[]):
 		import threading
-		threading.Thread(target=self._wait_loop_start, name='Autosign wait loop').start()
+		threading.Thread(
+			target = self._wait_loop_start,
+			kwargs = {'add_opts': add_opts},
+			name   = 'Autosign wait loop').start()
 		time.sleep(0.1) # try to ensure test output is displayed before next test starts
 		return 'silent'
 

+ 6 - 5
test/cmdtest_d/ct_regtest.py

@@ -280,8 +280,8 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 		('bob_split1',                 'splitting Bob’s funds'),
 		('generate',                   'mining a block'),
 		('bob_bal2',                   'Bob’s balance'),
-		('bob_rbf_1output_create',     'creating RBF tx with one output'),
-		('bob_rbf_1output_bump',       'bumping RBF tx with one output'),
+		('bob_rbf_1output_create',     'creating RBF TX with one output'),
+		('bob_rbf_1output_bump',       'creating replacement TX with one output'),
 		('bob_bal2a',                  'Bob’s balance (age_fmt=confs)'),
 		('bob_bal2b',                  'Bob’s balance (showempty=1)'),
 		('bob_bal2c',                  'Bob’s balance (showempty=1 minconf=2 age_fmt=days)'),
@@ -1122,7 +1122,7 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 			one_output = True)
 
 	def bob_send_maybe_rbf(self):
-		outputs_cl = self._create_tx_outputs('alice', (('L', 1, ', 60'), ('C', 1, ', 40')))
+		outputs_cl = self._create_tx_outputs('alice', (('L', 1, ',60'), ('C', 1, ',40')))
 		outputs_cl += [self._user_sid('bob')+':'+rtBobOp3]
 		return self.user_txdo(
 			user               = 'bob',
@@ -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(
@@ -2174,4 +2174,5 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 		return 'ok'
 
 class CmdTestRegtestBDBWallet(CmdTestRegtest):
+	'transacting and tracking wallet operations via regtest mode (legacy BDB wallet)'
 	bdb_wallet = True

+ 550 - 33
test/cmdtest_d/ct_swap.py

@@ -12,46 +12,54 @@
 test.cmdtest_d.ct_swap: asset swap tests for the cmdtest.py test suite
 """
 
-from mmgen.protocol import init_proto
+from pathlib import Path
 
-from ..include.common import gr_uc
+from mmgen.protocol import init_proto
+from ..include.common import make_burn_addr, gr_uc
+from .common import dfl_bip39_file
+from .midgard import run_midgard_server
 
-from .ct_regtest import (
-	CmdTestRegtest,
-	rt_data,
-	dfl_wcls,
-	rt_pw,
-	cfg)
+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',
@@ -65,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:
@@ -81,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])
 
@@ -193,8 +306,412 @@ class CmdTestSwap(CmdTestRegtest):
 		t = self.spawn('mmgen-tool', ['--bob', 'listaddresses'])
 		return t
 
-	def bob_swaptxcreate1(self):
+	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-swaptxcreate',
-			['-d', self.tmpdir, '-B', '--bob', 'BTC', '1.234', f'{self.sid}:S:3', 'LTC'])
+			'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'

+ 89 - 0
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')

+ 82 - 2
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})')
@@ -30,10 +30,19 @@ async def do_txfile_test(desc, fns, cfg=cfg, check=False):
 		assert fn_gen == os.path.basename(fn), f'{fn_gen} != {fn}'
 
 		if check:
+			import json
+			from mmgen.tx.file import json_dumps
+			from mmgen.util import make_chksum_6
 			text = f.format()
 			with open(fpath) as fh:
 				text_chk = fh.read()
-			assert text == text_chk, f'\nformatted text:\n{text}\n  !=\noriginal file:\n{text_chk}'
+			data_chk = json.loads(text_chk)
+			outputs = data_chk['MMGenTransaction']['outputs']
+			for n, o in enumerate(outputs):
+				outputs[n] = {k:v for k,v in o.items() if not (type(v) is bool and v is False)}
+			data_chk['chksum'] = make_chksum_6(json_dumps(data_chk['MMGenTransaction']))
+			text_chk_fixed = json_dumps(data_chk)
+			assert text == text_chk_fixed, f'\nformatted text:\n{text}\n  !=\noriginal file:\n{text_chk_fixed}'
 
 	qmsg('  OK')
 	return True
@@ -160,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

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

+ 1 - 3
test/test-release.sh

@@ -217,9 +217,7 @@ do_reexec() {
 
 install_secp256k1_mod_maybe() {
 	if [[ "$repo" =~ ^mmgen[-_]wallet ]]; then
-		[ -e mmgen/proto/secp256k1/secp256k1*$(python3 --version | sed 's/.* //;s/\.//;s/\..*//')* ] || {
-			eval "python3 setup.py build_ext --inplace $STDOUT_DEVNULL"
-		}
+		eval "python3 setup.py build_ext --inplace $STDOUT_DEVNULL"
 	fi
 }