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 decimal import Decimal
 from .objmethods import Hilite, InitErrors
 from .objmethods import Hilite, InitErrors
+from .obj import get_obj
 
 
 class CoinAmt(Decimal, Hilite, InitErrors): # abstract class
 class CoinAmt(Decimal, Hilite, InitErrors): # abstract class
 	"""
 	"""
@@ -155,6 +156,9 @@ class CoinAmt(Decimal, Hilite, InitErrors): # abstract class
 	def __mod__(self, *args, **kwargs):
 	def __mod__(self, *args, **kwargs):
 		self.method_not_implemented()
 		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):
 class BTCAmt(CoinAmt):
 	coin = 'BTC'
 	coin = 'BTC'
 	max_prec = 8
 	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 MoneroMMGenTXFileParseError(Exception): mmcode = 2
 class AutosignTXError(Exception):         mmcode = 2
 class AutosignTXError(Exception):         mmcode = 2
 class MMGenImportError(Exception):        mmcode = 2
 class MMGenImportError(Exception):        mmcode = 2
+class SwapMemoParseError(Exception):      mmcode = 2
 
 
 # 3: yellow hl, 'MMGen Error' + exception + message
 # 3: yellow hl, 'MMGen Error' + exception + message
 class RPCFailure(Exception):              mmcode = 3
 class RPCFailure(Exception):              mmcode = 3

+ 5 - 174
mmgen/help/help_notes.py

@@ -12,22 +12,21 @@
 help: help notes functions for MMGen suite commands
 help: help notes functions for MMGen suite commands
 """
 """
 
 
-from ..cfg import gc
-
 class help_notes:
 class help_notes:
 
 
 	def __init__(self, proto, cfg):
 	def __init__(self, proto, cfg):
 		self.proto = proto
 		self.proto = proto
 		self.cfg = cfg
 		self.cfg = cfg
 
 
-	def txcreate_args(self, target):
+	def txcreate_args(self):
 		return (
 		return (
-			'COIN1 [AMT CHG_ADDR] COIN2 [ADDR]'
-				if target == 'swaptx' else
 			'[ADDR,AMT ... | DATA_SPEC] ADDR'
 			'[ADDR,AMT ... | DATA_SPEC] ADDR'
 				if self.proto.base_proto == 'Bitcoin' else
 				if self.proto.base_proto == 'Bitcoin' else
 			'ADDR,AMT')
 			'ADDR,AMT')
 
 
+	def swaptxcreate_args(self):
+		return 'COIN1 [AMT CHG_ADDR] COIN2 [ADDR]'
+
 	def account_info_desc(self):
 	def account_info_desc(self):
 		return 'unspent outputs' if self.proto.base_proto == 'Bitcoin' else 'account info'
 		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
 		cu = self.proto.coin_amt.units
 		return ', '.join(cu[:-1]) + ('', ' and ')[len(cu)>1] + cu[-1] + ('', ',\nrespectively')[len(cu)>1]
 		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):
 	def dfl_twname(self):
 		from ..proto.btc.rpc import BitcoinRPCClient
 		from ..proto.btc.rpc import BitcoinRPCClient
 		return BitcoinRPCClient.dfl_twname
 		return BitcoinRPCClient.dfl_twname
@@ -128,7 +122,7 @@ as {r}, using an integer followed by '{l}', for {u}.
 	c = self.proto.coin,
 	c = self.proto.coin,
 	r = BaseTX(cfg=self.cfg, proto=self.proto).rel_fee_desc,
 	r = BaseTX(cfg=self.cfg, proto=self.proto).rel_fee_desc,
 	l = self.fee_spec_letters(use_quotes=True),
 	l = self.fee_spec_letters(use_quotes=True),
-	u = self.fee_spec_names() )
+	u = self.fee_spec_names())
 
 
 	def passwd(self):
 	def passwd(self):
 		return """
 		return """
@@ -146,167 +140,4 @@ BRAINWALLET NOTE:
 To thwart dictionary attacks, it’s recommended to use a strong hash preset
 To thwart dictionary attacks, it’s recommended to use a strong hash preset
 with brainwallets.  For a brainwallet passphrase to generate the correct
 with brainwallets.  For a brainwallet passphrase to generate the correct
 seed, the same seed length and hash preset parameters must always be used.
 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()
 """.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
 #   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):
 def help(proto, cfg):

+ 2 - 2
mmgen/main_addrgen.py

@@ -111,9 +111,9 @@ range(s).
 			cfg       = cfg,
 			cfg       = cfg,
 			gc        = gc,
 			gc        = gc,
 		),
 		),
-		'notes': lambda help_notes, s: s.format(
+		'notes': lambda help_mod, help_notes, s: s.format(
 			n_addrkey = note_addrkey,
 			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_pw      = help_notes('passwd')+'\n\n',
 			n_bw      = help_notes('brainwallet'),
 			n_bw      = help_notes('brainwallet'),
 			n_fmt     = help_notes('fmt_codes'),
 			n_fmt     = help_notes('fmt_codes'),

+ 2 - 0
mmgen/main_autosign.py

@@ -57,6 +57,8 @@ opts_data = {
 -v, --verbose         Produce more verbose output
 -v, --verbose         Produce more verbose output
 -w, --wallet-dir=D    Specify an alternate wallet dir
 -w, --wallet-dir=D    Specify an alternate wallet dir
                       (default: {asi.dfl_wallet_dir!r})
                       (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
 -x, --xmrwallets=L    Range or list of wallets to be used for XMR autosigning
 """,
 """,
 	'notes': """
 	'notes': """

+ 18 - 17
mmgen/main_txbump.py

@@ -79,6 +79,9 @@ opts_data = {
 			-- -s, --send             Sign and send the transaction (the default if seed
 			-- -s, --send             Sign and send the transaction (the default if seed
 			+                         data is provided)
 			+                         data is provided)
 			-- -v, --verbose          Produce more verbose output
 			-- -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
 			-- -y, --yes              Answer 'yes' to prompts, suppress non-essential output
 			-- -z, --show-hash-presets Show information on available hash presets
 			-- -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
 The user should take care to select a fee sufficient to ensure the original
 transaction is replaced in the mempool.
 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'
 Seed source files must have the canonical extensions listed in the 'FileExt'
 column below:
 column below:
 
 
@@ -104,7 +113,7 @@ column below:
 	},
 	},
 	'code': {
 	'code': {
 		'usage': lambda cfg, proto, help_notes, s: s.format(
 		'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(
 		'options': lambda cfg, help_notes, proto, s: s.format(
 			cfg     = cfg,
 			cfg     = cfg,
 			gc      = gc,
 			gc      = gc,
@@ -116,9 +125,9 @@ column below:
 			coin_id = help_notes('coin_id'),
 			coin_id = help_notes('coin_id'),
 			dsl     = help_notes('dfl_seed_len'),
 			dsl     = help_notes('dfl_seed_len'),
 			cu      = proto.coin),
 			cu      = proto.coin),
-		'notes': lambda help_notes, s: s.format(
+		'notes': lambda help_mod, help_notes, s: s.format(
 			e       = help_notes('fee'),
 			e       = help_notes('fee'),
-			s       = help_notes('txsign'),
+			s       = help_mod('txsign'),
 			f       = help_notes('fmt_codes')),
 			f       = help_notes('fmt_codes')),
 	}
 	}
 }
 }
@@ -164,34 +173,26 @@ async def main():
 		kal = kl = sign_and_send = None
 		kal = kl = sign_and_send = None
 	else:
 	else:
 		orig_tx = await CompletedTX(cfg=cfg, filename=tx_file)
 		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:
 	if not silent:
 		msg(green('ORIGINAL TRANSACTION'))
 		msg(green('ORIGINAL TRANSACTION'))
 		msg(orig_tx.info.format(terse=True))
 		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
 	from .tw.ctl import TwCtl
 	tx = await BumpTX(
 	tx = await BumpTX(
 		cfg  = cfg,
 		cfg  = cfg,
 		data = orig_tx.__dict__,
 		data = orig_tx.__dict__,
 		automount = cfg.autosign,
 		automount = cfg.autosign,
 		check_sent = cfg.autosign or sign_and_send,
 		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)
 		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')
 		await tx.create(cfg._args, caller='txdo' if sign_and_send else 'txcreate')
 	else:
 	else:
-		tx.new_outputs = False
 		await tx.create_feebump(silent=silent)
 		await tx.create_feebump(silent=silent)
 
 
 	if not silent:
 	if not silent:

+ 7 - 6
mmgen/main_txcreate.py

@@ -35,7 +35,7 @@ opts_data = {
 	'text': {
 	'text': {
 		'desc': {
 		'desc': {
 			'tx':     f'Create a transaction with outputs to specified coin or {gc.proj_name} addresses',
 			'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],
 		}[target],
 		'usage':   '[opts] {u_args} [addr file ...]',
 		'usage':   '[opts] {u_args} [addr file ...]',
 		'options': """
 		'options': """
@@ -73,6 +73,7 @@ opts_data = {
 			+                        Choices: {x_all})
 			+                        Choices: {x_all})
 			-- -v, --verbose         Produce more verbose output
 			-- -v, --verbose         Produce more verbose output
 			b- -V, --vsize-adj=   f  Adjust transaction's estimated vsize by factor 'f'
 			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
 			-- -y, --yes             Answer 'yes' to prompts, suppress non-essential output
 			e- -X, --cached-balances Use cached balances
 			e- -X, --cached-balances Use cached balances
 		""",
 		""",
@@ -80,7 +81,7 @@ opts_data = {
 	},
 	},
 	'code': {
 	'code': {
 		'usage': lambda cfg, proto, help_notes, s: s.format(
 		'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(
 		'options': lambda cfg, proto, help_notes, s: s.format(
 			cfg    = cfg,
 			cfg    = cfg,
 			cu     = proto.coin,
 			cu     = proto.coin,
@@ -91,11 +92,11 @@ opts_data = {
 			fe_dfl = cfg._autoset_opts['fee_estimate_mode'].choices[0],
 			fe_dfl = cfg._autoset_opts['fee_estimate_mode'].choices[0],
 			x_all = fmt_list(cfg._autoset_opts['swap_proto'].choices, fmt='no_spc'),
 			x_all = fmt_list(cfg._autoset_opts['swap_proto'].choices, fmt='no_spc'),
 			x_dfl = cfg._autoset_opts['swap_proto'].choices[0]),
 			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'),
 			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/>.
 # 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
 from .cfg import gc, Config
@@ -35,7 +35,7 @@ opts_data = {
 	'text': {
 	'text': {
 		'desc': {
 		'desc': {
 			'tx':     f'Create, sign and send an {gc.proj_name} transaction',
 			'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],
 		}[target],
 		'usage':   '[opts] {u_args} [addr file ...] [seed source ...]',
 		'usage':   '[opts] {u_args} [addr file ...] [seed source ...]',
 		'options': """
 		'options': """
@@ -93,27 +93,29 @@ opts_data = {
 			+                         wallet is scanned for subseeds.
 			+                         wallet is scanned for subseeds.
 			-- -v, --verbose          Produce more verbose output
 			-- -v, --verbose          Produce more verbose output
 			b- -V, --vsize-adj=     f Adjust transaction's estimated vsize by factor 'f'
 			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
 			e- -X, --cached-balances  Use cached balances
 			-- -y, --yes              Answer 'yes' to prompts, suppress non-essential output
 			-- -y, --yes              Answer 'yes' to prompts, suppress non-essential output
 			-- -z, --show-hash-presets Show information on available hash presets
 			-- -z, --show-hash-presets Show information on available hash presets
 		""",
 		""",
 		'notes': """
 		'notes': """
-{c}\n{F}
+{c}
+{n_at}
+
+{F}
 
 
                                  SIGNING NOTES
                                  SIGNING NOTES
 {s}
 {s}
 Seed source files must have the canonical extensions listed in the 'FileExt'
 Seed source files must have the canonical extensions listed in the 'FileExt'
 column below:
 column below:
 
 
-{n_at}
-
 {f}
 {f}
 
 
 {x}"""
 {x}"""
 	},
 	},
 	'code': {
 	'code': {
 		'usage': lambda cfg, proto, help_notes, s: s.format(
 		'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(
 		'options': lambda cfg, proto, help_notes, s: s.format(
 			gc      = gc,
 			gc      = gc,
 			cfg     = cfg,
 			cfg     = cfg,
@@ -132,13 +134,13 @@ column below:
 			fe_dfl  = cfg._autoset_opts['fee_estimate_mode'].choices[0],
 			fe_dfl  = cfg._autoset_opts['fee_estimate_mode'].choices[0],
 			x_all   = fmt_list(cfg._autoset_opts['swap_proto'].choices, fmt='no_spc'),
 			x_all   = fmt_list(cfg._autoset_opts['swap_proto'].choices, fmt='no_spc'),
 			x_dfl   = cfg._autoset_opts['swap_proto'].choices[0]),
 			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'),
 			F       = help_notes('fee'),
-			s       = help_notes('txsign'),
 			n_at    = help_notes('address_types'),
 			n_at    = help_notes('address_types'),
 			f       = help_notes('fmt_codes'),
 			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 cfg.status:
 		if tx.coin_txid:
 		if tx.coin_txid:
 			cfg._util.qmsg(f'{tx.proto.coin} txid: {tx.coin_txid.hl()}')
 			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:
 	if not cfg.yes:
 		tx.info.view_with_prompt('View transaction details?')
 		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.
                       wallet is scanned for subseeds.
 -v, --verbose         Produce more verbose output
 -v, --verbose         Produce more verbose output
 -V, --vsize-adj=   f  Adjust transaction's estimated vsize by factor 'f'
 -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
 -y, --yes             Answer 'yes' to prompts, suppress non-essential output
 """,
 """,
 	'notes': """
 	'notes': """
@@ -89,8 +91,8 @@ column below:
 			ss      = help_notes('dfl_subseeds'),
 			ss      = help_notes('dfl_subseeds'),
 			ss_max  = SubSeedIdxRange.max_idx,
 			ss_max  = SubSeedIdxRange.max_idx,
 			cu      = proto.coin),
 			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')),
 			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(
 		'notes': lambda cfg, help_mod, help_notes, s: s.format(
 			f       = help_notes('fmt_codes'),
 			f       = help_notes('fmt_codes'),
 			n_ss    = ('', help_mod('seedsplit')+'\n\n')[do_ss_note],
 			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_pw    = help_notes('passwd'),
 			n_bw    = ('', '\n\n'+help_notes('brainwallet'))[do_bw_note]
 			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):
 class mainnet(mainnet):
 	is_fork_of      = 'Bitcoin'
 	is_fork_of      = 'Bitcoin'
 	mmtypes         = ('L', 'C')
 	mmtypes         = ('L', 'C')
+	preferred_mmtypes = ('C',)
 	sighash_type    = 'ALL|FORKID'
 	sighash_type    = 'ALL|FORKID'
 	forks = [
 	forks = [
 		_finfo(478559, '000000000000000000651ef99cb9fcbe0dadde1d424bd9f15ff20136191a5eec', 'BTC', False)
 		_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
 	addr_len        = 20
 	wif_ver_num     = {'std': '80'}
 	wif_ver_num     = {'std': '80'}
 	mmtypes         = ('L', 'C', 'S', 'B')
 	mmtypes         = ('L', 'C', 'S', 'B')
+	preferred_mmtypes  = ('B', 'S', 'C')
 	dfl_mmtype      = 'L'
 	dfl_mmtype      = 'L'
 	coin_amt        = 'BTCAmt'
 	coin_amt        = 'BTCAmt'
 	max_tx_fee      = 0.003
 	max_tx_fee      = 0.003

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

@@ -128,9 +128,7 @@ class BitcoinRPCClient(RPCClient, metaclass=AsyncInit):
 
 
 		super().__init__(
 		super().__init__(
 			cfg  = cfg,
 			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)
 			port = daemon.rpc_port)
 
 
 		self.set_auth()
 		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
 			#     DATA: opcode_byte ('6a') + push_byte + nulldata_bytes
 			return sum(
 			return sum(
 				{'p2pkh':34, 'p2sh':32, 'bech32':31}[o.addr.addr_fmt] if o.addr else
 				{'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)
 					for o in self.outputs)
 
 
 		# https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki
 		# https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki
@@ -295,6 +298,17 @@ class Base(TxBase):
 			getattr(self.proto.coin_amt, to_unit) /
 			getattr(self.proto.coin_amt, to_unit) /
 			self.estimate_size()))
 			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
 	@property
 	def nondata_outputs(self):
 	def nondata_outputs(self):
 		return [o for o in self.outputs if not o.data]
 		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 ....tx import bump as TxBase
 from ....util import msg
 from ....util import msg
-from .new import New
+from .new_swap import NewSwap
 from .completed import Completed
 from .completed import Completed
 from .unsigned import AutomountUnsigned
 from .unsigned import AutomountUnsigned
 
 
-class Bump(Completed, New, TxBase.Bump):
+class Bump(Completed, NewSwap, TxBase.Bump):
 	desc = 'fee-bumped transaction'
 	desc = 'fee-bumped transaction'
 
 
 	def get_orig_rel_fee(self):
 	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 ....tx import completed as TxBase
 from ....obj import HexStr
 from ....obj import HexStr
-from ....util import msg, die
+from ....util import msg, ymsg, die
 from .base import Base, decodeScriptPubKey
 from .base import Base, decodeScriptPubKey
 
 
 class Completed(Base, TxBase.Completed):
 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
 				assert (200 < len(ti['scriptSig']) < 300), 'malformed scriptSig' # VERY rough check
 		return True
 		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):
 	def check_pubkey_scripts(self):
 		for n, i in enumerate(self.inputs, 1):
 		for n, i in enumerate(self.inputs, 1):
 			ds = decodeScriptPubKey(self.proto, i.scriptPubKey)
 			ds = decodeScriptPubKey(self.proto, i.scriptPubKey)

+ 1 - 1
mmgen/proto/btc/tx/info.py

@@ -65,7 +65,7 @@ class TxInfo(TxInfo):
 					append_color='green')
 					append_color='green')
 			else:
 			else:
 				return MMGenID.fmtc(
 				return MMGenID.fmtc(
-					nonmm_str,
+					'[vault address]' if not is_input and e.is_vault else nonmm_str,
 					width = max_mmwid,
 					width = max_mmwid,
 					color = True)
 					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):
 	def final_inputs_ok_msg(self, funds_left):
 		return 'Transaction produces {} {} in change'.format(funds_left.hl(), self.coin)
 		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():
 		def do_err():
 			from ....ui import confirm_or_raise
 			from ....ui import confirm_or_raise
 			confirm_or_raise(
 			confirm_or_raise(
 				cfg = self.cfg,
 				cfg = self.cfg,
 				message = yellow(message),
 				message = yellow(message),
 				action = 'Are you sure this is what you want?')
 				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()
 			do_err()
 
 
 	async def create_serialized(self, locktime=None):
 	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
 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 ....tx.new_swap import NewSwap as TxNewSwap
 from .new import New
 from .new import New
 
 
 class NewSwap(New, TxNewSwap):
 class NewSwap(New, TxNewSwap):
 	desc = 'Bitcoin swap transaction'
 	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):
 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
 		tx = self.tx
 
 
@@ -91,9 +98,9 @@ class Status(TxBase.Status):
 			else:
 			else:
 				msg('Warning: transaction is in mempool!')
 				msg('Warning: transaction is in mempool!')
 		elif await is_in_wallet():
 		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():
 		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():
 		elif await is_replaced():
 			msg('Transaction has been replaced')
 			msg('Transaction has been replaced')
 			msg('Replacement transaction ' + (
 			msg('Replacement transaction ' + (
@@ -110,4 +117,4 @@ class Status(TxBase.Status):
 						d.append({})
 						d.append({})
 				for txid, mp_entry in zip(r.replacing_txs, d):
 				for txid, mp_entry in zip(r.replacing_txs, d):
 					msg(f'  {txid}' + (' in mempool' if 'height' in mp_entry else ''))
 					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')
 	network_names = _nw('mainnet', 'testnet', 'devnet')
 	addr_len      = 20
 	addr_len      = 20
 	mmtypes       = ('E',)
 	mmtypes       = ('E',)
+	preferred_mmtypes  = ('E',)
 	dfl_mmtype    = 'E'
 	dfl_mmtype    = 'E'
 	mod_clsname   = 'Ethereum'
 	mod_clsname   = 'Ethereum'
 	pubkey_type   = 'std' # required by DummyWIF
 	pubkey_type   = 'std' # required by DummyWIF

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

@@ -48,7 +48,7 @@ class EthereumRPCClient(RPCClient, metaclass=AsyncInit):
 
 
 		super().__init__(
 		super().__init__(
 			cfg  = cfg,
 			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)
 			port = daemon.rpc_port)
 
 
 		await self.set_backend_async(backend)
 		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):
 class Bump(Completed, New, TxBase.Bump):
 	desc = 'fee-bumped transaction'
 	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
 	@property
 	def min_fee(self):
 	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.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')
 		self.start_gas = self.proto.coin_amt(self.dfl_start_gas, from_unit='wei')
 
 
+	def check_swap_memo(self):
+		pass
+
 	@property
 	@property
 	def send_amt(self):
 	def send_amt(self):
 		return self.outputs[0].amt if self.outputs else self.proto.coin_amt('0')
 		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):
 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
 		tx = self.tx
 
 
@@ -56,8 +63,8 @@ class Status(TxBase.Status):
 						msg(f'{cd} failed to execute!')
 						msg(f'{cd} failed to execute!')
 					else:
 					else:
 						msg(f'{cd} successfully executed with status {ret.exec_status}')
 						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):
 class TokenStatus(Status):
 	pass
 	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,
 		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.
 		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:
 		Return values on failure:
 		    None:  no addresses in wallet of requested address type
 		    None:  no addresses in wallet of requested address type
 		    False: no unused 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]
 					return addrs[int(res)-1]
 				msg(f'{res}: invalid entry')
 				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):
 		if any(res):
 			res = list(filter(None, res))
 			res = list(filter(None, res))

+ 4 - 0
mmgen/tx/base.py

@@ -81,6 +81,9 @@ class Base(MMGenObject):
 	signed       = False
 	signed       = False
 	is_bump      = False
 	is_bump      = False
 	is_swap      = False
 	is_swap      = False
+	swap_proto   = None
+	swap_quote_expiry = None
+	swap_recv_addr_mmid = None
 	file_format  = 'json'
 	file_format  = 'json'
 	non_mmgen_inputs_msg = f"""
 	non_mmgen_inputs_msg = f"""
 		This transaction includes inputs with non-{gc.proj_name} addresses.  When
 		This transaction includes inputs with non-{gc.proj_name} addresses.  When
@@ -100,6 +103,7 @@ class Base(MMGenObject):
 
 
 	class Output(MMGenTxIO):
 	class Output(MMGenTxIO):
 		is_chg   = ListItemAttr(bool, typeconv=False)
 		is_chg   = ListItemAttr(bool, typeconv=False)
+		is_vault = ListItemAttr(bool, typeconv=False)
 		data     = ListItemAttr(None, typeconv=False) # placeholder
 		data     = ListItemAttr(None, typeconv=False) # placeholder
 
 
 	class InputList(MMGenTxIOList):
 	class InputList(MMGenTxIOList):

+ 37 - 7
mmgen/tx/bump.py

@@ -12,19 +12,36 @@
 tx.bump: transaction bump class
 tx.bump: transaction bump class
 """
 """
 
 
-from .new import New
+from .new_swap import NewSwap
 from .completed import Completed
 from .completed import Completed
 from ..util import msg, ymsg, is_int, die
 from ..util import msg, ymsg, is_int, die
+from ..color import pink
 
 
-class Bump(Completed, New):
+class Bump(Completed, NewSwap):
 	desc = 'fee-bumped transaction'
 	desc = 'fee-bumped transaction'
 	ext  = 'rawtx'
 	ext  = 'rawtx'
 	bump_output_idx = None
 	bump_output_idx = None
 	is_bump = True
 	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():
 		if not self.is_replaceable():
 			die(1, f'Transaction {self.txid} is not replaceable')
 			die(1, f'Transaction {self.txid} is not replaceable')
@@ -60,9 +77,22 @@ class Bump(Completed, New):
 		output_idx = self.choose_output()
 		output_idx = self.choose_output()
 
 
 		if not silent:
 		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)
 		self.bump_fee(output_idx, self.usr_fee)
 
 

+ 6 - 2
mmgen/tx/file.py

@@ -70,7 +70,10 @@ class MMGenTxFile(MMGenObject):
 		'comment': MMGenTxComment,
 		'comment': MMGenTxComment,
 		'coin_txid': CoinTxID,
 		'coin_txid': CoinTxID,
 		'sent_timestamp': None,
 		'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):
 	def __init__(self, tx):
 		self.tx       = tx
 		self.tx       = tx
@@ -269,7 +272,8 @@ class MMGenTxFile(MMGenObject):
 					k: getattr(tx, k) for k in self.attrs
 					k: getattr(tx, k) for k in self.attrs
 				} | {
 				} | {
 					'inputs':  [e._asdict() for e in tx.inputs],
 					'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)
 					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
 import importlib
 
 
 from ..cfg import gc
 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 ..util import msg, msg_r, decode_timestamp, make_timestr
 from ..util2 import format_elapsed_hr
 from ..util2 import format_elapsed_hr
 
 
@@ -51,7 +51,7 @@ class TxInfo:
 
 
 		def gen_view():
 		def gen_view():
 			yield (self.txinfo_hdr_fs_short if terse else self.txinfo_hdr_fs).format(
 			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(),
 				i = tx.txid.hl(),
 				a = tx.send_amt.hl(),
 				a = tx.send_amt.hl(),
 				c = tx.dcoin,
 				c = tx.dcoin,
@@ -72,6 +72,18 @@ class TxInfo:
 			if tx.coin_txid:
 			if tx.coin_txid:
 				yield f'  {tx.coin} TxID: {tx.coin_txid.hl()}\n'
 				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)]
 			enl = ('\n', '')[bool(terse)]
 			yield enl
 			yield enl
 
 

+ 42 - 23
mmgen/tx/new.py

@@ -83,7 +83,9 @@ class New(Base):
 	_funds_available = namedtuple('funds_available', ['is_positive', 'amt'])
 	_funds_available = namedtuple('funds_available', ['is_positive', 'amt'])
 
 
 	def __init__(self, *args, target=None, **kwargs):
 	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)
 		super().__init__(*args, **kwargs)
 
 
 	def warn_insufficient_funds(self, amt, coin):
 	def warn_insufficient_funds(self, amt, coin):
@@ -168,22 +170,27 @@ class New(Base):
 			return False
 			return False
 		return True
 		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):
 	def process_data_output_arg(self, arg):
 		return None
 		return None
 
 
 	def parse_cmdline_arg(self, proto, arg_in, ad_f, ad_w):
 	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):
 		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)
 		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)
 			coin_addr = mmaddr2coinaddr(self.cfg, arg, ad_w, ad_f, proto)
 		elif is_coin_addr(proto, arg):
 		elif is_coin_addr(proto, arg):
 			coin_addr = CoinAddr(proto, arg)
 			coin_addr = CoinAddr(proto, arg)
@@ -191,17 +198,19 @@ class New(Base):
 			if proto.base_proto_coin != 'BTC':
 			if proto.base_proto_coin != 'BTC':
 				die(2, f'Change addresses not supported for {proto.name} protocol')
 				die(2, f'Change addresses not supported for {proto.name} protocol')
 			self.chg_autoselected = True
 			self.chg_autoselected = True
-			coin_addr = None
 		else:
 		else:
 			die(2, f'{arg_in}: invalid command-line argument')
 			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
 		from ..tw.addresses import TwAddresses
 		al = await TwAddresses(self.cfg, proto, get_data=True)
 		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)
 			res = al.get_change_address_by_addrtype(obj, exclude=exclude, desc=desc)
 			req_desc = f'of address type {arg!r}'
 			req_desc = f'of address type {arg!r}'
 		else:
 		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]
 		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:
 		if len(chg_args) > 1:
 			desc = 'requested' if self.chg_autoselected else 'listed'
 			desc = 'requested' if self.chg_autoselected else 'listed'
@@ -229,13 +238,16 @@ class New(Base):
 			if a.data:
 			if a.data:
 				self.add_output(None, self.proto.coin_amt('0'), data=a.data)
 				self.add_output(None, self.proto.coin_amt('0'), data=a.data)
 			else:
 			else:
-				exclude = [a.mmid for a in parsed_args if a.mmid]
 				self.add_output(
 				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:
 		if self.chg_idx is None:
 			die(2,
 			die(2,
@@ -256,7 +268,7 @@ class New(Base):
 		self.check_dup_addrs('outputs')
 		self.check_dup_addrs('outputs')
 
 
 		if self.chg_output is not None:
 		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')
 				self.confirm_autoselected_addr(self.chg_output.mmid, 'change address')
 			elif len(self.nondata_outputs) > 1:
 			elif len(self.nondata_outputs) > 1:
 				await self.warn_addr_used(self.proto, self.chg_output, 'change address')
 				await self.warn_addr_used(self.proto, self.chg_output, 'change address')
@@ -387,13 +399,14 @@ class New(Base):
 				sel_unspent,
 				sel_unspent,
 				outputs_sum):
 				outputs_sum):
 			return False
 			return False
+
 		self.copy_inputs_from_tw(sel_unspent)  # makes self.inputs
 		self.copy_inputs_from_tw(sel_unspent)  # makes self.inputs
 		return True
 		return True
 
 
-	async def get_fee(self, fee, outputs_sum):
+	async def get_fee(self, fee, outputs_sum, start_fee_desc):
 
 
 		if fee:
 		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:
 		else:
 			fee_per_kb, fe_type = await self.get_rel_fee_from_network()
 			fee_per_kb, fe_type = await self.get_rel_fee_from_network()
 			self.usr_fee = self.get_usr_fee_interactive(
 			self.usr_fee = self.get_usr_fee_interactive(
@@ -424,8 +437,8 @@ class New(Base):
 		if not do_info:
 		if not do_info:
 			cmd_args, addrfile_args = self.get_addrfiles_from_cmdline(cmd_args)
 			cmd_args, addrfile_args = self.get_addrfiles_from_cmdline(cmd_args)
 			if self.is_swap:
 			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
 			from ..rpc import rpc_init
 			self.rpc = await rpc_init(self.cfg, self.proto)
 			self.rpc = await rpc_init(self.cfg, self.proto)
 			from ..addrdata import TwAddrData
 			from ..addrdata import TwAddrData
@@ -468,7 +481,10 @@ class New(Base):
 			fee_hint = None
 			fee_hint = None
 			if self.is_swap:
 			if self.is_swap:
 				fee_hint = self.update_vault_output(self.vault_output.amt or self.sum_inputs())
 				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
 				break
 
 
 		self.check_non_mmgen_inputs(caller)
 		self.check_non_mmgen_inputs(caller)
@@ -480,6 +496,9 @@ class New(Base):
 		if not self.cfg.yes:
 		if not self.cfg.yes:
 			self.add_comment()  # edits an existing comment
 			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
 		await self.create_serialized(locktime=locktime) # creates self.txid too
 
 
 		self.add_timestamp()
 		self.add_timestamp()

+ 21 - 1
mmgen/tx/new_swap.py

@@ -16,7 +16,27 @@ from .new import New
 
 
 class NewSwap(New):
 class NewSwap(New):
 	desc = 'swap transaction'
 	desc = 'swap transaction'
-	is_swap = True
 
 
 	async def process_swap_cmdline_args(self, cmd_args, addrfiles):
 	async def process_swap_cmdline_args(self, cmd_args, addrfiles):
 		raise NotImplementedError(f'Swap not implemented for protocol {self.proto.__name__}')
 		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
 		from . import _base_proto_subclass
 		return _base_proto_subclass('Status', 'status', self.proto)(self)
 		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):
 	def confirm_send(self):
 		from ..util import msg
 		from ..util import msg
 		from ..ui import confirm_or_raise
 		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)))
 				sep + sep.join(missing)))
 		keys += tmp.data
 		keys += tmp.data
 
 
+	sm_output = tx.check_swap_memo() # do this for non-swap transactions too!
+
 	if cfg.mmgen_keys_from_file:
 	if cfg.mmgen_keys_from_file:
 		keys += add_keys('inputs', tx.inputs, keyaddr_list=kal)
 		keys += add_keys('inputs', tx.inputs, keyaddr_list=kal)
 		add_keys('outputs', tx.outputs, 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)
 	keys += add_keys('inputs', tx.inputs, seed_files, saved_seeds)
 	add_keys('outputs', tx.outputs, 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
 	# this (boolean) attr isn't needed in transaction file
 	tx.delete_attrs('inputs', 'have_wif')
 	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
 	from ..autosign import Autosign
 	return Autosign(
 	return Autosign(
 		Config({
 		Config({
+			'_clone': cfg,
 			'mountpoint': cfg.autosign_mountpoint,
 			'mountpoint': cfg.autosign_mountpoint,
-			'test_suite': cfg.test_suite,
-			'test_suite_root_pfx': cfg.test_suite_root_pfx,
 			'coins': cfg.coin,
 			'coins': cfg.coin,
 			'online': not cfg.offline, # used only in online environment (xmrwallet, txcreate, txsend, txbump)
 			'online': not cfg.offline, # used only in online environment (xmrwallet, txcreate, txsend, txbump)
-		})
-	)
+		}))
 
 
 def mount_removable_device(cfg):
 def mount_removable_device(cfg):
 	asi = get_autosign_obj(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,
 			s = sum(stat.size for stat in stats) / 1024,
 			w = col1w))
 			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 sys as exec_wrapper_sys
 import os as exec_wrapper_os
 import os as exec_wrapper_os
 import time as exec_wrapper_time
 import time as exec_wrapper_time
@@ -145,17 +154,10 @@ try:
 	with open(exec_wrapper_execed_file) as fp:
 	with open(exec_wrapper_execed_file) as fp:
 		exec(fp.read())
 		exec(fp.read())
 except SystemExit as e:
 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:
 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'):
 if exec_wrapper_os.getenv('MMGEN_TRACEMALLOC'):
 	exec_wrapper_tracemalloc_log()
 	exec_wrapper_tracemalloc_log()

+ 3 - 0
setup.cfg

@@ -86,6 +86,9 @@ packages =
 	mmgen.proto.secp256k1
 	mmgen.proto.secp256k1
 	mmgen.proto.xmr
 	mmgen.proto.xmr
 	mmgen.proto.zec
 	mmgen.proto.zec
+	mmgen.swap
+	mmgen.swap.proto
+	mmgen.swap.proto.thorchain
 	mmgen.tool
 	mmgen.tool
 	mmgen.tx
 	mmgen.tx
 	mmgen.tw
 	mmgen.tw

+ 3 - 2
test/cmdtest.py

@@ -447,7 +447,7 @@ class CmdGroupMgr:
 				yield '  {} - {}'.format(
 				yield '  {} - {}'.format(
 					yellow(name.ljust(13)),
 					yellow(name.ljust(13)),
 					(cls.__doc__.strip() if cls.__doc__ else cls.__name__))
 					(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('_')}
 					subgroups = {k:v for k, v in cls.cmd_subgroups.items() if not k.startswith('_')}
 					max_w = max(len(k) for k in subgroups)
 					max_w = max(len(k) for k in subgroups)
 					for k, v in subgroups.items():
 					for k, v in subgroups.items():
@@ -762,7 +762,8 @@ class CmdTestRunner:
 									if isinstance(e, KeyError) and e.args[0] == cmdname:
 									if isinstance(e, KeyError) and e.args[0] == cmdname:
 										ret = getattr(self.tg, cmdname)()
 										ret = getattr(self.tg, cmdname)()
 										if type(ret).__name__ == 'coroutine':
 										if type(ret).__name__ == 'coroutine':
-											asyncio.run(ret)
+											ret = asyncio.run(ret)
+										self.process_retval(cmdname, ret)
 									else:
 									else:
 										raise
 										raise
 								do_between()
 								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
 import time
 
 
 from .ct_autosign import CmdTestAutosignThreaded
 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
 from ..include.common import cfg, gr_uc
 
 
-class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet):
+class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtest):
 	'automounted transacting operations via regtest mode'
 	'automounted transacting operations via regtest mode'
 
 
 	networks = ('btc', 'bch', 'ltc')
 	networks = ('btc', 'bch', 'ltc')
 	tmpdir_nums = [49]
 	tmpdir_nums = [49]
+	bdb_wallet = True
 
 
 	rt_data = {
 	rt_data = {
 		'rtFundAmt': {'btc':'500', 'bch':'500', 'ltc':'5500'},
 		'rtFundAmt': {'btc':'500', 'bch':'500', 'ltc':'5500'},
@@ -78,59 +79,26 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
 		self.coins = [cfg.coin.lower()]
 		self.coins = [cfg.coin.lower()]
 
 
 		CmdTestAutosignThreaded.__init__(self, trunner, cfgs, spawn)
 		CmdTestAutosignThreaded.__init__(self, trunner, cfgs, spawn)
-		CmdTestRegtestBDBWallet.__init__(self, trunner, cfgs, spawn)
+		CmdTestRegtest.__init__(self, trunner, cfgs, spawn)
 
 
 		if trunner is None:
 		if trunner is None:
 			return
 			return
 
 
 		self.opts.append('--alice')
 		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):
 	def alice_txcreate1(self):
-		return self._alice_txcreate(
+		return self._user_txcreate(
+			'alice',
 			chg_addr = 'C:5',
 			chg_addr = 'C:5',
 			data_arg = 'data:'+gr_uc[:24])
 			data_arg = 'data:'+gr_uc[:24])
 
 
 	def alice_txcreate2(self):
 	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
 	alice_txcreate3 = alice_txcreate2
 
 
 	def alice_txcreate4(self):
 	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=[]):
 	def _alice_txsend_abort(self, err=False, send_resp='y', expect=None, shred_expect=[]):
 		self.insert_device_online()
 		self.insert_device_online()
@@ -166,25 +134,25 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
 	alice_txsend_abort5 = alice_txsend_abort2
 	alice_txsend_abort5 = alice_txsend_abort2
 
 
 	def alice_txcreate_bad_have_unsigned(self):
 	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):
 	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):
 	def alice_run_autosign_setup(self):
 		return self.run_setup(mn_type='default', use_dfl_wallet=True, passwd=rt_pw)
 		return self.run_setup(mn_type='default', use_dfl_wallet=True, passwd=rt_pw)
 
 
 	def alice_txsend1(self):
 	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):
 	def alice_txsend2(self):
-		return self._alice_txsend(need_rbf=True)
+		return self._user_txsend('alice', need_rbf=True)
 
 
 	def alice_txsend3(self):
 	def alice_txsend3(self):
-		return self._alice_txsend(need_rbf=True)
+		return self._user_txsend('alice', need_rbf=True)
 
 
 	def alice_txsend5(self):
 	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):
 	def _alice_txstatus(self, expect, exit_val=None, need_rbf=False):
 
 
@@ -197,6 +165,8 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
 				['--alice', '--autosign', '--status', '--verbose'],
 				['--alice', '--autosign', '--status', '--verbose'],
 				exit_val = exit_val)
 				exit_val = exit_val)
 		t.expect(expect)
 		t.expect(expect)
+		if not exit_val:
+			t.expect('view: ', 'n')
 		t.read()
 		t.read()
 		self.remove_device_online()
 		self.remove_device_online()
 		return t
 		return t
@@ -209,7 +179,7 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
 		return self._alice_txstatus('unsent', 1)
 		return self._alice_txstatus('unsent', 1)
 
 
 	def alice_txstatus3(self):
 	def alice_txstatus3(self):
-		return self._alice_txstatus('in mempool')
+		return self._alice_txstatus('in mempool', 0)
 
 
 	def alice_txstatus4(self):
 	def alice_txstatus4(self):
 		return self._alice_txstatus('1 confirmation', 0)
 		return self._alice_txstatus('1 confirmation', 0)
@@ -217,24 +187,6 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtestBDBWallet)
 	def alice_txstatus5(self):
 	def alice_txstatus5(self):
 		return self._alice_txstatus('in mempool', need_rbf=True)
 		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):
 	def alice_txsend_bad_no_unsent(self):
 		self.insert_device_online()
 		self.insert_device_online()
 		t = self.spawn('mmgen-txsend', ['--quiet', '--autosign'], exit_val=2)
 		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
 	no_insert_check = False
 	threaded        = True
 	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(
 		t = self.spawn(
 			'mmgen-autosign',
 			'mmgen-autosign',
-			self.opts + ['--full-summary', 'wait'],
+			self.opts + add_opts + ['--full-summary', 'wait'],
 			direct_exec      = True,
 			direct_exec      = True,
 			no_passthru_opts = True,
 			no_passthru_opts = True,
 			spawn_env_override = self.spawn_env | {'EXEC_WRAPPER_DO_RUNTIME_MSG': ''})
 			spawn_env_override = self.spawn_env | {'EXEC_WRAPPER_DO_RUNTIME_MSG': ''})
 		self.write_to_tmpfile('autosign_thread_pid', str(t.ep.pid))
 		self.write_to_tmpfile('autosign_thread_pid', str(t.ep.pid))
 		return t
 		return t
 
 
-	def wait_loop_start(self):
+	def wait_loop_start(self, add_opts=[]):
 		import threading
 		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
 		time.sleep(0.1) # try to ensure test output is displayed before next test starts
 		return 'silent'
 		return 'silent'
 
 

+ 6 - 5
test/cmdtest_d/ct_regtest.py

@@ -280,8 +280,8 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 		('bob_split1',                 'splitting Bob’s funds'),
 		('bob_split1',                 'splitting Bob’s funds'),
 		('generate',                   'mining a block'),
 		('generate',                   'mining a block'),
 		('bob_bal2',                   'Bob’s balance'),
 		('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_bal2a',                  'Bob’s balance (age_fmt=confs)'),
 		('bob_bal2b',                  'Bob’s balance (showempty=1)'),
 		('bob_bal2b',                  'Bob’s balance (showempty=1)'),
 		('bob_bal2c',                  'Bob’s balance (showempty=1 minconf=2 age_fmt=days)'),
 		('bob_bal2c',                  'Bob’s balance (showempty=1 minconf=2 age_fmt=days)'),
@@ -1122,7 +1122,7 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 			one_output = True)
 			one_output = True)
 
 
 	def bob_send_maybe_rbf(self):
 	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]
 		outputs_cl += [self._user_sid('bob')+':'+rtBobOp3]
 		return self.user_txdo(
 		return self.user_txdo(
 			user               = 'bob',
 			user               = 'bob',
@@ -1194,8 +1194,8 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 		t.expect(f'Mined {num_blocks} block')
 		t.expect(f'Mined {num_blocks} block')
 		return t
 		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):
 	def _do_mmgen_regtest(self, cmd_args, decode_json=False):
 		ret = self.spawn(
 		ret = self.spawn(
@@ -2174,4 +2174,5 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 		return 'ok'
 		return 'ok'
 
 
 class CmdTestRegtestBDBWallet(CmdTestRegtest):
 class CmdTestRegtestBDBWallet(CmdTestRegtest):
+	'transacting and tracking wallet operations via regtest mode (legacy BDB wallet)'
 	bdb_wallet = True
 	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
 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]
 sample1 = gr_uc[:24]
 sample2 = '00010203040506'
 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
 	bdb_wallet = True
 	networks = ('btc',)
 	networks = ('btc',)
 	tmpdir_nums = [37]
 	tmpdir_nums = [37]
 	passthru_opts = ('rpc_backend',)
 	passthru_opts = ('rpc_backend',)
+	coins = ('btc',)
+	need_daemon = True
 
 
 	cmd_group_in = (
 	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 = {
 	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': (
 		'data': (
 			'OP_RETURN data operations',
 			'OP_RETURN data operations',
@@ -65,15 +73,101 @@ class CmdTestSwap(CmdTestRegtest):
 			('generate3',        'Generate 3 blocks'),
 			('generate3',        'Generate 3 blocks'),
 			('bob_listaddrs',    'Display Bob’s addresses'),
 			('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):
 	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()
 		globals_dict = globals()
 		for k in rt_data:
 		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')]
 		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
 	@property
 	def sid(self):
 	def sid(self):
 		return self._user_sid('bob')
 		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):
 	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])
 		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'])
 		t = self.spawn('mmgen-tool', ['--bob', 'listaddresses'])
 		return t
 		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(
 		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
 		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.tx.file import MMGenTxFile
 from mmgen.cfg import Config
 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):
 async def do_txfile_test(desc, fns, cfg=cfg, check=False):
 	qmsg(f'\n  Testing CompletedTX initializer ({desc})')
 	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}'
 		assert fn_gen == os.path.basename(fn), f'{fn_gen} != {fn}'
 
 
 		if check:
 		if check:
+			import json
+			from mmgen.tx.file import json_dumps
+			from mmgen.util import make_chksum_6
 			text = f.format()
 			text = f.format()
 			with open(fpath) as fh:
 			with open(fpath) as fh:
 				text_chk = fh.read()
 				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')
 	qmsg('  OK')
 	return True
 	return True
@@ -160,3 +169,74 @@ class unit_tests:
 		), pfx='')
 		), pfx='')
 
 
 		return True
 		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() {
 install_secp256k1_mod_maybe() {
 	if [[ "$repo" =~ ^mmgen[-_]wallet ]]; then
 	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
 	fi
 }
 }