8 Commits 54ff658207 ... 2f6e52be73

Author SHA1 Message Date
  The MMGen Project 2f6e52be73 mmgen-swaptx{create,do}: add price protection via --trade-limit option 9 months ago
  The MMGen Project 7300c1ec84 update wiki documentation 9 months ago
  The MMGen Project 5135b8dbdd tx.new_swap: deduct estimated fee from quote for one-output TXs 9 months ago
  The MMGen Project 81e11f3405 tx.new_swap: add initializer, `swap_proto_mod` attribute 9 months ago
  The MMGen Project 809856c07d fixes and cleanups 9 months ago
  The MMGen Project 90d500302f swap.proto.thorchain: move `params` class to `.` 9 months ago
  The MMGen Project 4e0a6755f8 new `ExpInt` class 9 months ago
  The MMGen Project 424253b1e7 support Bitcoin Cash Node v28.0.1 9 months ago
48 changed files with 396 additions and 155 deletions
  1. 4 4
      doc/wiki/Recovering-Your-Keys-Without-the-MMGen-Wallet-Software.md
  2. 1 1
      doc/wiki/commands/command-help-addrgen.md
  3. 1 1
      doc/wiki/commands/command-help-addrimport.md
  4. 1 1
      doc/wiki/commands/command-help-autosign.md
  5. 1 1
      doc/wiki/commands/command-help-keygen.md
  6. 1 1
      doc/wiki/commands/command-help-msg.md
  7. 1 1
      doc/wiki/commands/command-help-passchg.md
  8. 1 1
      doc/wiki/commands/command-help-passgen.md
  9. 1 1
      doc/wiki/commands/command-help-regtest.md
  10. 1 1
      doc/wiki/commands/command-help-seedjoin.md
  11. 1 1
      doc/wiki/commands/command-help-seedsplit.md
  12. 1 1
      doc/wiki/commands/command-help-subwalletgen.md
  13. 22 1
      doc/wiki/commands/command-help-swaptxcreate.md
  14. 22 1
      doc/wiki/commands/command-help-swaptxdo.md
  15. 1 1
      doc/wiki/commands/command-help-tool(detail).md
  16. 1 1
      doc/wiki/commands/command-help-tool(usage).md
  17. 1 1
      doc/wiki/commands/command-help-tool.md
  18. 1 1
      doc/wiki/commands/command-help-txbump.md
  19. 1 1
      doc/wiki/commands/command-help-txcreate.md
  20. 1 1
      doc/wiki/commands/command-help-txdo.md
  21. 1 1
      doc/wiki/commands/command-help-txsend.md
  22. 1 1
      doc/wiki/commands/command-help-txsign.md
  23. 1 1
      doc/wiki/commands/command-help-walletchk.md
  24. 1 1
      doc/wiki/commands/command-help-walletconv.md
  25. 1 1
      doc/wiki/commands/command-help-walletgen.md
  26. 1 1
      doc/wiki/commands/command-help-xmrwallet.md
  27. 2 1
      mmgen/amt.py
  28. 1 1
      mmgen/data/release_date
  29. 1 1
      mmgen/data/version
  30. 19 0
      mmgen/help/swaptxcreate.py
  31. 2 0
      mmgen/main_txcreate.py
  32. 2 0
      mmgen/main_txdo.py
  33. 1 1
      mmgen/proto/btc/daemon.py
  34. 7 0
      mmgen/proto/btc/tx/base.py
  35. 13 4
      mmgen/proto/btc/tx/new_swap.py
  36. 21 3
      mmgen/swap/proto/thorchain/__init__.py
  37. 33 16
      mmgen/swap/proto/thorchain/memo.py
  38. 40 9
      mmgen/swap/proto/thorchain/midgard.py
  39. 0 28
      mmgen/swap/proto/thorchain/params.py
  40. 4 0
      mmgen/tx/bump.py
  41. 4 7
      mmgen/tx/new.py
  42. 28 6
      mmgen/tx/new_swap.py
  43. 32 0
      mmgen/util2.py
  44. 1 1
      nix/bitcoin-cash-node.nix
  45. 39 22
      test/cmdtest_d/ct_swap.py
  46. 1 1
      test/include/unit_test.py
  47. 38 0
      test/modtest_d/ut_misc.py
  48. 36 25
      test/modtest_d/ut_tx.py

+ 4 - 4
doc/wiki/Recovering-Your-Keys-Without-the-MMGen-Wallet-Software.md

@@ -19,11 +19,11 @@ I recover my coins?”
 
 Let’s take this scenario to its logical extreme and assume you’ve lost all
 backup copies of the software, the MMGen Wallet project page has disappeared
-from all of [Github][04], [Gitlab][05], [Gitflic][06] and [mmgen.org][07] (or
+from all of [Github][04], [Gitlab][05], [Codeberg][06] and [mmgen.org][07] (or
 been hacked), and no other verifiable repositories or copies are available on
 the Internet.  The following tutorial will show you how to recover the private
-keys for your coin addresses in the event this very unlikely combination of
-circumstances ever occurs.
+keys for your coin addresses in the event this extremely unlikely combination
+of circumstances ever occurs.
 
 In addition to private keys, this tutorial can also be used to recover passwords
 generated with the `mmgen-passgen` command.
@@ -506,5 +506,5 @@ False
 [03]: https://github.com/spesmilo/electrum/blob/1.9.5/lib/mnemonic.py
 [04]: https://github.com/mmgen/mmgen-wallet
 [05]: https://gitlab.com/mmgen/mmgen-wallet
-[06]: https://gitflic.ru/project/mmgen/mmgen-wallet
+[06]: https://codeberg.org/mmgen/mmgen-wallet
 [07]: https://mmgen.org/project/mmgen/mmgen-wallet

+ 1 - 1
doc/wiki/commands/command-help-addrgen.md

@@ -120,5 +120,5 @@
     MMGenWallet        .mmdat    wallet,w
     PlainHexSeedFile   .hex      hex,rawhex,plainhex
 
-  MMGEN v15.1.dev17              February 2025                MMGEN-ADDRGEN(1)
+  MMGEN v15.1.dev18              March 2025                   MMGEN-ADDRGEN(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-addrimport.md

@@ -31,5 +31,5 @@
 
   It’s recommended to use ‘--rpc-backend=aio’ with ‘--rescan’.
 
-  MMGEN v15.1.dev17              February 2025             MMGEN-ADDRIMPORT(1)
+  MMGEN v15.1.dev18              March 2025                MMGEN-ADDRIMPORT(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-autosign.md

@@ -127,5 +127,5 @@
   Always remember to power off the signing machine when your signing session
   is over.
 
-  MMGEN v15.1.dev17              February 2025               MMGEN-AUTOSIGN(1)
+  MMGEN v15.1.dev18              March 2025                  MMGEN-AUTOSIGN(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-keygen.md

@@ -125,5 +125,5 @@
     MMGenWallet        .mmdat    wallet,w
     PlainHexSeedFile   .hex      hex,rawhex,plainhex
 
-  MMGEN v15.1.dev17              February 2025                 MMGEN-KEYGEN(1)
+  MMGEN v15.1.dev18              March 2025                    MMGEN-KEYGEN(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-msg.md

@@ -106,5 +106,5 @@
   Verify and display the exported JSON signature data:
   $ mmgen-msg verify signatures.json
 
-  MMGEN v15.1.dev17              February 2025                    MMGEN-MSG(1)
+  MMGEN v15.1.dev18              March 2025                       MMGEN-MSG(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-passchg.md

@@ -53,5 +53,5 @@
     MMGenWallet        .mmdat    wallet,w
     PlainHexSeedFile   .hex      hex,rawhex,plainhex
 
-  MMGEN v15.1.dev17              February 2025                MMGEN-PASSCHG(1)
+  MMGEN v15.1.dev18              March 2025                   MMGEN-PASSCHG(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-passgen.md

@@ -101,5 +101,5 @@
     MMGenWallet        .mmdat    wallet,w
     PlainHexSeedFile   .hex      hex,rawhex,plainhex
 
-  MMGEN v15.1.dev17              February 2025                MMGEN-PASSGEN(1)
+  MMGEN v15.1.dev18              March 2025                   MMGEN-PASSGEN(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-regtest.md

@@ -25,5 +25,5 @@
     wallet_cli      - execute a wallet RPC call with supplied arguments (wallet
                       is first argument)
 
-  MMGEN v15.1.dev17              February 2025                MMGEN-REGTEST(1)
+  MMGEN v15.1.dev18              March 2025                   MMGEN-REGTEST(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-seedjoin.md

@@ -62,5 +62,5 @@
     MMGenWallet        .mmdat    wallet,w
     PlainHexSeedFile   .hex      hex,rawhex,plainhex
 
-  MMGEN v15.1.dev17              February 2025               MMGEN-SEEDJOIN(1)
+  MMGEN v15.1.dev18              March 2025                  MMGEN-SEEDJOIN(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-seedsplit.md

@@ -144,5 +144,5 @@
     MMGenWallet        .mmdat    wallet,w
     PlainHexSeedFile   .hex      hex,rawhex,plainhex
 
-  MMGEN v15.1.dev17              February 2025              MMGEN-SEEDSPLIT(1)
+  MMGEN v15.1.dev18              March 2025                 MMGEN-SEEDSPLIT(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-subwalletgen.md

@@ -97,5 +97,5 @@
     MMGenWallet        .mmdat    wallet,w
     PlainHexSeedFile   .hex      hex,rawhex,plainhex
 
-  MMGEN v15.1.dev17              February 2025            MMGEN-SUBWALLETGEN(1)
+  MMGEN v15.1.dev18              March 2025               MMGEN-SUBWALLETGEN(1)
 ```

+ 22 - 1
doc/wiki/commands/command-help-swaptxcreate.md

@@ -23,6 +23,8 @@
   -I, --inputs       i  Specify transaction inputs (comma-separated list of
                         MMGen IDs or coin addresses).  Note that ALL unspent
                         outputs associated with each address will be included.
+  -l, --trade-limit L   Minimum swap amount, as either percentage or absolute
+                        coin amount (see TRADE LIMIT below)
   -L, --autochg-ignore-labels Ignore labels when autoselecting change addresses
   -m, --minconf      n  Minimum number of confirmations required to spend
                         outputs (default: 1)
@@ -102,6 +104,25 @@
   send value.
 
 
+                                  TRADE LIMIT
+
+  A target value for the swap may be set, known as the “trade limit”.  If
+  this target cannot be met, the network will refund the user’s coins, minus
+  transaction fees (note that the refund goes to the address associated with the
+  transaction’s first input, leading to coin reuse).  Since under certain
+  circumstances large amounts of slippage can occur, resulting in significant
+  losses, setting a trade limit is highly recommended.
+
+  The target may be given as either an absolute coin amount or percentage value.
+  In the latter case, it’s interpreted as the percentage below the “expected
+  amount out” returned by the swap quote server.  Zero or negative percentage
+  values are also accepted, but are likely to result in your coins being
+  refunded.
+
+  The trade limit is rounded to four digits of precision in order to reduce
+  transaction size.
+
+
   ADDRESS TYPES:
 
     Code Type           Description
@@ -175,5 +196,5 @@
 
       $ mmgen-tool --coin=bch --bch-rpc-host=gemini twview minconf=0
 
-  MMGEN v15.1.dev17              February 2025            MMGEN-SWAPTXCREATE(1)
+  MMGEN v15.1.dev18              March 2025               MMGEN-SWAPTXCREATE(1)
 ```

+ 22 - 1
doc/wiki/commands/command-help-swaptxdo.md

@@ -31,6 +31,8 @@
   -k, --keys-from-file f Provide additional keys for non-MMGen addresses
   -K, --keygen-backend n Use backend 'n' for public key generation.  Options
                          for BTC: 1:libsecp256k1 [default] 2:python-ecdsa
+  -l, --trade-limit L    Minimum swap amount, as either percentage or absolute
+                         coin amount (see TRADE LIMIT below)
   -L, --autochg-ignore-labels Ignore labels when autoselecting change addresses
   -m, --minconf n        Minimum number of confirmations required to spend
                          outputs (default: 1)
@@ -123,6 +125,25 @@
   send value.
 
 
+                                  TRADE LIMIT
+
+  A target value for the swap may be set, known as the “trade limit”.  If
+  this target cannot be met, the network will refund the user’s coins, minus
+  transaction fees (note that the refund goes to the address associated with the
+  transaction’s first input, leading to coin reuse).  Since under certain
+  circumstances large amounts of slippage can occur, resulting in significant
+  losses, setting a trade limit is highly recommended.
+
+  The target may be given as either an absolute coin amount or percentage value.
+  In the latter case, it’s interpreted as the percentage below the “expected
+  amount out” returned by the swap quote server.  Zero or negative percentage
+  values are also accepted, but are likely to result in your coins being
+  refunded.
+
+  The trade limit is rounded to four digits of precision in order to reduce
+  transaction size.
+
+
   ADDRESS TYPES:
 
     Code Type           Description
@@ -239,5 +260,5 @@
 
       $ mmgen-tool --coin=bch --bch-rpc-host=gemini twview minconf=0
 
-  MMGEN v15.1.dev17              February 2025               MMGEN-SWAPTXDO(1)
+  MMGEN v15.1.dev18              March 2025                  MMGEN-SWAPTXDO(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-tool(detail).md

@@ -1240,5 +1240,5 @@ Optional KEYWORD ARGS (type and default value shown in square brackets):
 ```
 
 ```text
-MMGEN v15.1.dev17              February 2025              MMGEN-TOOL(DETAIL)(1)
+MMGEN v15.1.dev18              March 2025                 MMGEN-TOOL(DETAIL)(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-tool(usage).md

@@ -187,5 +187,5 @@ EXAMPLES:
   Same as above, but supply input via STDIN:
   $ echo "deadbeefcafe" | mmgen-tool hexreverse -
 
-  MMGEN v15.1.dev17              February 2025            MMGEN-TOOL(USAGE)(1)
+  MMGEN v15.1.dev18              March 2025               MMGEN-TOOL(USAGE)(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-tool.md

@@ -198,5 +198,5 @@
 
   Type ‘mmgen-tool help <command>’ for help on a particular command
 
-  MMGEN v15.1.dev17              February 2025                   MMGEN-TOOL(1)
+  MMGEN v15.1.dev18              March 2025                      MMGEN-TOOL(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-txbump.md

@@ -116,5 +116,5 @@
     MMGenWallet        .mmdat    wallet,w
     PlainHexSeedFile   .hex      hex,rawhex,plainhex
 
-  MMGEN v15.1.dev17              February 2025                 MMGEN-TXBUMP(1)
+  MMGEN v15.1.dev18              March 2025                    MMGEN-TXBUMP(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-txcreate.md

@@ -123,5 +123,5 @@
 
       $ mmgen-txcreate B
 
-  MMGEN v15.1.dev17              February 2025               MMGEN-TXCREATE(1)
+  MMGEN v15.1.dev18              March 2025                  MMGEN-TXCREATE(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-txdo.md

@@ -187,5 +187,5 @@
 
       $ mmgen-txdo B
 
-  MMGEN v15.1.dev17              February 2025                   MMGEN-TXDO(1)
+  MMGEN v15.1.dev18              March 2025                      MMGEN-TXDO(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-txsend.md

@@ -18,5 +18,5 @@
   -v, --verbose   Be more verbose
   -y, --yes       Answer 'yes' to prompts, suppress non-essential output
 
-  MMGEN v15.1.dev17              February 2025                 MMGEN-TXSEND(1)
+  MMGEN v15.1.dev18              March 2025                    MMGEN-TXSEND(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-txsign.md

@@ -82,5 +82,5 @@
     MMGenWallet        .mmdat    wallet,w
     PlainHexSeedFile   .hex      hex,rawhex,plainhex
 
-  MMGEN v15.1.dev17              February 2025                 MMGEN-TXSIGN(1)
+  MMGEN v15.1.dev18              March 2025                    MMGEN-TXSIGN(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-walletchk.md

@@ -51,5 +51,5 @@
     MMGenWallet        .mmdat    wallet,w
     PlainHexSeedFile   .hex      hex,rawhex,plainhex
 
-  MMGEN v15.1.dev17              February 2025              MMGEN-WALLETCHK(1)
+  MMGEN v15.1.dev18              March 2025                 MMGEN-WALLETCHK(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-walletconv.md

@@ -62,5 +62,5 @@
     MMGenWallet        .mmdat    wallet,w
     PlainHexSeedFile   .hex      hex,rawhex,plainhex
 
-  MMGEN v15.1.dev17              February 2025             MMGEN-WALLETCONV(1)
+  MMGEN v15.1.dev18              March 2025                MMGEN-WALLETCONV(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-walletgen.md

@@ -54,5 +54,5 @@
     MMGenWallet        .mmdat    wallet,w
     PlainHexSeedFile   .hex      hex,rawhex,plainhex
 
-  MMGEN v15.1.dev17              February 2025              MMGEN-WALLETGEN(1)
+  MMGEN v15.1.dev18              March 2025                 MMGEN-WALLETGEN(1)
 ```

+ 1 - 1
doc/wiki/commands/command-help-xmrwallet.md

@@ -499,5 +499,5 @@
   to delete your old hot wallets, make sure to do so securely using ‘shred’,
   ‘wipe’ or some other secure deletion utility.
 
-  MMGEN v15.1.dev17              February 2025              MMGEN-XMRWALLET(1)
+  MMGEN v15.1.dev18              March 2025                 MMGEN-XMRWALLET(1)
 ```

+ 2 - 1
mmgen/amt.py

@@ -48,7 +48,7 @@ class CoinAmt(Decimal, Hilite, InitErrors): # abstract class
 		try:
 			if from_unit:
 				assert from_unit in cls.units, f'{from_unit!r}: unrecognized coin unit for {cls.__name__}'
-				assert type(num) is int, 'value is not an integer'
+				assert isinstance(num, int), 'value is not an integer'
 				me = Decimal.__new__(cls, num * getattr(cls, from_unit))
 			elif from_decimal:
 				assert isinstance(num, Decimal), f'number must be of type Decimal, not {type(num).__name__})'
@@ -157,6 +157,7 @@ class CoinAmt(Decimal, Hilite, InitErrors): # abstract class
 		self.method_not_implemented()
 
 def is_coin_amt(proto, num, from_unit=None, from_decimal=False):
+	assert proto.coin_amt, 'proto.coin_amt is None!  Did you call init_proto() with ‘need_amt’?'
 	return get_obj(proto.coin_amt, num=num, from_unit=from_unit, from_decimal=from_decimal, silent=True, return_bool=True)
 
 class BTCAmt(CoinAmt):

+ 1 - 1
mmgen/data/release_date

@@ -1 +1 @@
-February 2025
+March 2025

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-15.1.dev17
+15.1.dev18

+ 19 - 0
mmgen/help/swaptxcreate.py

@@ -79,4 +79,23 @@ transaction fees, ‘mmnode-feeview’ and ‘mmnode-blocks-info’, in addition
 ‘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.
+
+
+                                TRADE LIMIT
+
+A target value for the swap may be set, known as the “trade limit”.  If
+this target cannot be met, the network will refund the user’s coins, minus
+transaction fees (note that the refund goes to the address associated with the
+transaction’s first input, leading to coin reuse).  Since under certain
+circumstances large amounts of slippage can occur, resulting in significant
+losses, setting a trade limit is highly recommended.
+
+The target may be given as either an absolute coin amount or percentage value.
+In the latter case, it’s interpreted as the percentage below the “expected
+amount out” returned by the swap quote server.  Zero or negative percentage
+values are also accepted, but are likely to result in your coins being
+refunded.
+
+The trade limit is rounded to four digits of precision in order to reduce
+transaction size.
 """

+ 2 - 0
mmgen/main_txcreate.py

@@ -63,6 +63,8 @@ opts_data = {
 			+                        MMGen IDs or coin addresses).  Note that ALL unspent
 			+                        outputs associated with each address will be included.
 			bt -l, --locktime=    t  Lock time (block height or unix seconds) (default: 0)
+			-s -l, --trade-limit=L   Minimum swap amount, as either percentage or absolute
+			+                        coin amount (see TRADE LIMIT below)
 			b- -L, --autochg-ignore-labels Ignore labels when autoselecting change addresses
 			-- -m, --minconf=     n  Minimum number of confirmations required to spend
 			+                        outputs (default: 1)

+ 2 - 0
mmgen/main_txdo.py

@@ -70,6 +70,8 @@ opts_data = {
 			-- -k, --keys-from-file=f Provide additional keys for non-{pnm} addresses
 			-- -K, --keygen-backend=n Use backend 'n' for public key generation.  Options
 			+                         for {coin_id}: {kgs}
+			-s -l, --trade-limit=L    Minimum swap amount, as either percentage or absolute
+			+                         coin amount (see TRADE LIMIT below)
 			bt -l, --locktime=      t Lock time (block height or unix seconds) (default: 0)
 			b- -L, --autochg-ignore-labels Ignore labels when autoselecting change addresses
 			-- -m, --minconf=n        Minimum number of confirmations required to spend

+ 1 - 1
mmgen/proto/btc/daemon.py

@@ -124,7 +124,7 @@ class bitcoin_core_daemon(CoinDaemon):
 		return e.args[0]
 
 class bitcoin_cash_node_daemon(bitcoin_core_daemon):
-	daemon_data = _dd('Bitcoin Cash Node', 28000000, '28.0.0')
+	daemon_data = _dd('Bitcoin Cash Node', 28000100, '28.0.1')
 	exec_fn = 'bitcoind-bchn'
 	cli_fn = 'bitcoin-cli-bchn'
 	rpc_ports = _nw(8432, 18432, 18543) # use non-standard ports (core+100)

+ 7 - 0
mmgen/proto/btc/tx/base.py

@@ -305,6 +305,13 @@ class Base(TxBase):
 			raise ValueError(f'{res}: too many data outputs in transaction (only one allowed)')
 		return res[0] if len(res) == 1 else None
 
+	@data_output.setter
+	def data_output(self, val):
+		dbool = [bool(o.data) for o in self.outputs]
+		if dbool.count(True) != 1:
+			raise ValueError('more or less than one data output in transaction!')
+		self.outputs[dbool.index(True)] = val
+
 	@property
 	def data_outputs(self):
 		return [o for o in self.outputs if o.data]

+ 13 - 4
mmgen/proto/btc/tx/new_swap.py

@@ -41,10 +41,6 @@ class NewSwap(New, TxNewSwap):
 
 	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
@@ -102,6 +98,8 @@ class NewSwap(New, TxNewSwap):
 			if args_in: # done parsing, all args consumed
 				self.cfg._usage()
 
+		from ....protocol import init_proto
+		sp = self.swap_proto_mod
 		args_in = list(cmd_args)
 		args = CmdlineArgs()
 		parse()
@@ -130,6 +128,17 @@ class NewSwap(New, TxNewSwap):
 			[f'vault,{args.send_amt}', chg_output.mmid, f'data:{memo}'] if args.send_amt else
 			['vault', f'data:{memo}'])
 
+	def update_data_output(self, trade_limit):
+		sp = self.swap_proto_mod
+		o = self.data_output._asdict()
+		parsed_memo = sp.data.parse(o['data'].decode())
+		memo = sp.data(
+			self.recv_proto,
+			self.recv_proto.coin_addr(parsed_memo.address),
+			trade_limit = trade_limit)
+		o['data'] = f'data:{memo}'
+		self.data_output = self.Output(self.proto, **o)
+
 	def update_vault_addr(self, addr):
 		vault_idx = self.vault_idx
 		assert vault_idx == 0, f'{vault_idx}: vault index is not zero!'

+ 21 - 3
mmgen/swap/proto/thorchain/__init__.py

@@ -12,14 +12,32 @@
 swap.proto.thorchain: THORChain swap protocol implementation for the MMGen Wallet suite
 """
 
-__all__ = ['params', 'data']
+__all__ = ['data']
 
 name = 'THORChain'
 
-from .params import params
+class params:
+	exp_prec = 4
+	coins = {
+		'send': {
+			'BTC': 'Bitcoin',
+			'LTC': 'Litecoin',
+			'BCH': 'Bitcoin Cash',
+		},
+		'receive': {
+			'BTC': 'Bitcoin',
+			'LTC': 'Litecoin',
+			'BCH': 'Bitcoin Cash',
+		}
+	}
 
-from .memo import Memo as data
+from ....util2 import ExpInt
+class ExpInt4(ExpInt):
+	def __new__(cls, spec):
+		return ExpInt.__new__(cls, spec, prec=params.exp_prec)
 
 def rpc_client(tx, amt):
 	from .midgard import Midgard
 	return Midgard(tx, amt)
+
+from .memo import Memo as data

+ 33 - 16
mmgen/swap/proto/thorchain/memo.py

@@ -12,13 +12,15 @@
 swap.proto.thorchain.memo: THORChain swap protocol memo class
 """
 
+from ....util import die
+
 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
+	trade_limit = None
 
 	# Swap interval in blocks. Optional. If 0, do not stream
 	stream_interval = 1
@@ -65,14 +67,13 @@ class Memo:
 		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
+			except IndexError:
+				die('SwapMemoParseError', f'malformed {proto_name} memo (missing {desc} field)')
 
 		def get_id(data, item, desc):
 			if item in data:
@@ -80,12 +81,12 @@ class Memo:
 			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')
+			die('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')
+			die('SwapMemoParseError', 'memo must contain at least 4 comma-separated fields')
 
 		function = get_id(cls.function_abbrevs, get_item('function'), 'function')
 
@@ -98,32 +99,48 @@ class Memo:
 
 		try:
 			limit, interval, quantity = lsq.split('/')
-		except ValueError as e:
-			raise SwapMemoParseError(f'malformed memo (failed to parse {desc} field) [{lsq}]') from e
+		except ValueError:
+			die('SwapMemoParseError', f'malformed memo (failed to parse {desc} field) [{lsq}]')
+
+		from . import ExpInt4
+		try:
+			limit_int = ExpInt4(limit)
+		except Exception as e:
+			die('SwapMemoParseError', str(e))
 
-		for n in (limit, interval, quantity):
+		for n in (interval, quantity):
 			if not is_int(n):
-				raise SwapMemoParseError(f'malformed memo (non-integer in {desc} field [{lsq}])')
+				die('SwapMemoParseError', f'malformed memo (non-integer in {desc} field [{lsq}])')
 
 		if fields:
-			raise SwapMemoParseError('malformed memo (unrecognized extra data)')
+			die('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))
+		return ret(proto_name, function, chain, asset, address, limit_int, int(interval), int(quantity))
 
-	def __init__(self, proto, addr, chain=None):
+	def __init__(self, proto, addr, chain=None, trade_limit=None):
 		self.proto = proto
 		self.chain = chain or proto.coin
-		from ....addr import CoinAddr
-		assert isinstance(addr, CoinAddr)
+		if trade_limit is None:
+			self.trade_limit = self.proto.coin_amt('0')
+		else:
+			assert type(trade_limit) is self.proto.coin_amt, f'{type(trade_limit)} != {self.proto.coin_amt}'
+			self.trade_limit = trade_limit
+		from ....addr import is_coin_addr
+		assert is_coin_addr(proto, addr)
 		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))
+		from . import ExpInt4
+		try:
+			tl_enc = ExpInt4(self.trade_limit.to_unit('satoshi')).enc
+		except Exception as e:
+			die('SwapMemoParseError', str(e))
+		suf = '/'.join(str(n) for n in (tl_enc, self.stream_interval, self.stream_quantity))
 		asset = f'{self.chain}.{self.proto.coin}'
 		ret = ':'.join([
 			self.function_abbrevs[self.function],

+ 40 - 9
mmgen/swap/proto/thorchain/midgard.py

@@ -58,20 +58,50 @@ class Midgard:
 			c = self.in_amt.to_unit('satoshi'))
 		self.result = self.rpc.get(self.get_str)
 		self.data = json.loads(self.result.content)
+		if not 'expiry' in self.data:
+			from ....util import pp_fmt, die
+			die(2, pp_fmt(self.data))
 
-	def format_quote(self):
-		from ....util import make_timestr, pp_fmt, die
+	def format_quote(self, trade_limit, usr_trade_limit, *, deduct_est_fee=False):
+		from ....util import make_timestr, ymsg
 		from ....util2 import format_elapsed_hr
-		from ....color import blue, cyan, pink, orange
+		from ....color import blue, green, cyan, pink, orange, redbg, yelbg, grnbg
 		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
+		in_amt = self.in_amt
 		out_amt = tx.recv_proto.coin_amt(int(d['expected_amount_out']), from_unit='satoshi')
+
+		if trade_limit:
+			from . import ExpInt4
+			e = ExpInt4(trade_limit.to_unit('satoshi'))
+			tl_rounded = tx.recv_proto.coin_amt(e.trunc, from_unit='satoshi')
+			ratio = usr_trade_limit if type(usr_trade_limit) is float else float(tl_rounded / out_amt)
+			direction = 'ABOVE' if ratio > 1 else 'below'
+			mcolor, lblcolor = (
+				(redbg, redbg) if (ratio < 0.93 or ratio > 0.999) else
+				(yelbg, yelbg) if ratio < 0.97 else
+				(green, grnbg))
+			trade_limit_disp = f"""
+  {lblcolor('Trade limit:')}                   {tl_rounded.hl()} {out_coin} """ + mcolor(
+				f'({abs(1 - ratio) * 100:0.2f}% {direction} expected amount)')
+			tx_size_adj = len(e.enc) - 1
+		else:
+			trade_limit_disp = ''
+			tx_size_adj = 0
+
+		_amount_in_label = 'Amount in:'
+		if deduct_est_fee:
+			if d['gas_rate_units'] == 'satsperbyte':
+				in_amt -= tx.feespec2abs(d['recommended_gas_rate'] + 's', tx.estimate_size() + tx_size_adj)
+				out_amt *= (in_amt / self.in_amt)
+				_amount_in_label = 'Amount in (estimated):'
+			else:
+				ymsg('Warning: unknown gas unit ‘{}’, cannot estimate fee'.format(d['gas_rate_units']))
+
 		min_in_amt = tx.send_proto.coin_amt(int(d['recommended_min_amount_in']), from_unit='satoshi')
 		gas_unit = {
 			'satsperbyte': 'sat/byte',
@@ -82,16 +112,17 @@ class Midgard:
 		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}
+  {_amount_in_label:<22}         {in_amt.hl()} {in_coin}
+  Expected amount out:           {out_amt.hl()} {out_coin}{trade_limit_disp}
+  Rate:                          {(out_amt / in_amt).hl()} {out_coin}/{in_coin}
+  Reverse rate:                  {(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:

+ 0 - 28
mmgen/swap/proto/thorchain/params.py

@@ -1,28 +0,0 @@
-#!/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',
-		}
-	}

+ 4 - 0
mmgen/tx/bump.py

@@ -42,6 +42,9 @@ class Bump(Completed, NewSwap):
 					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
+		elif self.is_swap:
+			import importlib
+			self.swap_proto_mod = importlib.import_module(f'mmgen.swap.proto.{self.swap_proto}')
 
 		if not self.is_replaceable():
 			die(1, f'Transaction {self.txid} is not replaceable')
@@ -86,6 +89,7 @@ class Bump(Completed, NewSwap):
 		if self.is_swap:
 			self.send_proto = self.proto
 			self.recv_proto = self.check_swap_memo().proto
+			self.process_swap_options()
 			fee_hint = self.update_vault_output(self.send_amt)
 		else:
 			fee_hint = None

+ 4 - 7
mmgen/tx/new.py

@@ -82,12 +82,6 @@ class New(Base):
 	chg_autoselected = False
 	_funds_available = namedtuple('funds_available', ['is_positive', 'amt'])
 
-	def __init__(self, *args, target=None, **kwargs):
-		if target == 'swaptx':
-			self.is_swap = True
-			self.swap_proto = kwargs['cfg'].swap_proto
-		super().__init__(*args, **kwargs)
-
 	def warn_insufficient_funds(self, amt, coin):
 		msg(self.msg_insufficient_funds.format(amt.hl(), coin))
 
@@ -438,6 +432,7 @@ class New(Base):
 			cmd_args, addrfile_args = self.get_addrfiles_from_cmdline(cmd_args)
 			if self.is_swap:
 				cmd_args = await self.process_swap_cmdline_args(cmd_args, addrfile_args)
+				self.process_swap_options()
 				self.proto = self.send_proto # updating self.proto!
 			from ..rpc import rpc_init
 			self.rpc = await rpc_init(self.cfg, self.proto)
@@ -480,7 +475,9 @@ class New(Base):
 				continue
 			fee_hint = None
 			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(),
+					deduct_est_fee = self.vault_output == self.chg_output)
 			if funds_left := await self.get_fee(
 					self.cfg.fee or fee_hint,
 					outputs_sum,

+ 28 - 6
mmgen/tx/new_swap.py

@@ -17,21 +17,42 @@ from .new import New
 class NewSwap(New):
 	desc = 'swap transaction'
 
-	async def process_swap_cmdline_args(self, cmd_args, addrfiles):
-		raise NotImplementedError(f'Swap not implemented for protocol {self.proto.__name__}')
-
-	def update_vault_output(self, amt):
+	def __init__(self, *args, **kwargs):
 		import importlib
-		sp = importlib.import_module(f'mmgen.swap.proto.{self.swap_proto}')
+		self.is_swap = True
+		self.swap_proto = kwargs['cfg'].swap_proto
+		self.swap_proto_mod = importlib.import_module(f'mmgen.swap.proto.{self.swap_proto}')
+		New.__init__(self, *args, **kwargs)
+
+	def process_swap_options(self):
+		if s := self.cfg.trade_limit:
+			self.usr_trade_limit = (
+				1 - float(s[:-1]) / 100 if s.endswith('%') else
+				self.recv_proto.coin_amt(self.cfg.trade_limit))
+		else:
+			self.usr_trade_limit = None
+
+	def update_vault_output(self, amt, *, deduct_est_fee=False):
+		sp = self.swap_proto_mod
 		c = sp.rpc_client(self, amt)
 
 		from ..util import msg
 		from ..term import get_char
+
+		def get_trade_limit():
+			if type(self.usr_trade_limit) is self.recv_proto.coin_amt:
+				return self.usr_trade_limit
+			elif type(self.usr_trade_limit) is float:
+				return (
+					self.recv_proto.coin_amt(int(c.data['expected_amount_out']), from_unit='satoshi')
+					* self.usr_trade_limit)
+
 		while True:
 			self.cfg._util.qmsg(f'Retrieving data from {c.rpc.host}...')
 			c.get_quote()
+			trade_limit = get_trade_limit()
 			self.cfg._util.qmsg('OK')
-			msg(c.format_quote())
+			msg(c.format_quote(trade_limit, self.usr_trade_limit, deduct_est_fee=deduct_est_fee))
 			ch = get_char('Press ‘r’ to refresh quote, any other key to continue: ')
 			msg('')
 			if ch not in 'Rr':
@@ -39,4 +60,5 @@ class NewSwap(New):
 
 		self.swap_quote_expiry = c.data['expiry']
 		self.update_vault_addr(c.inbound_address)
+		self.update_data_output(trade_limit)
 		return c.rel_fee_hint

+ 32 - 0
mmgen/util2.py

@@ -196,3 +196,35 @@ def decode_pretty_hexdump(data):
 	except:
 		msg('Data not in hexdump format')
 		return False
+
+class ExpInt(int):
+	'encode or parse an integer in exponential notation with specified precision'
+
+	max_prec = 10
+
+	def __new__(cls, spec, *, prec):
+		assert 0 < prec < cls.max_prec
+		cls.prec = prec
+
+		from .util import is_int
+		if is_int(spec):
+			return int.__new__(cls, spec)
+		else:
+			assert isinstance(spec, str), f'ExpInt: {spec!r}: not a string!'
+			assert len(spec) >= 3, f'ExpInt: {spec!r}: invalid specifier'
+			val, exp = spec.split('e')
+			assert is_int(val) and is_int(exp)
+			return int.__new__(cls, val + '0' * int(exp))
+
+	@property
+	def trunc(self):
+		s = str(self)
+		return int(s[:self.prec] + '0' * (len(s) - self.prec))
+
+	@property
+	def enc(self):
+		s = str(self)
+		s_len = len(s)
+		digits = s[:min(s_len, self.prec)].rstrip('0')
+		ret = '{}e{}'.format(digits, s_len - len(digits))
+		return ret if len(ret) < s_len else s

+ 1 - 1
nix/bitcoin-cash-node.nix

@@ -2,7 +2,7 @@
 
 pkgs.stdenv.mkDerivation rec {
     pname = "bitcoin-cash-node";
-    version = "v28.0.0";
+    version = "v28.0.1";
     src = fetchGit {
         url = "https://gitlab.com/bitcoin-cash-node/bitcoin-cash-node";
         ref = "refs/tags/${version}";

+ 39 - 22
test/cmdtest_d/ct_swap.py

@@ -317,10 +317,10 @@ class CmdTestSwap(CmdTestRegtest, CmdTestAutosignThreaded):
 		return self.addrimport('bob', mmtypes=['C'], proto=self.protos[2])
 
 	def fund_bob_send(self):
-		return self._fund_bob(2, 'C', '500')
+		return self._fund_bob(2, 'C', '5')
 
 	def bob_bal_send(self):
-		return self._bob_bal(2, '500')
+		return self._bob_bal(2, '5')
 
 	def setup_recv_coin(self):
 		return self._setup(proto=self.protos[1], remove_datadir=False)
@@ -332,10 +332,10 @@ class CmdTestSwap(CmdTestRegtest, CmdTestAutosignThreaded):
 		return self._addrimport_bob(1)
 
 	def fund_bob_recv1(self):
-		return self._fund_bob(1, 'S', '500')
+		return self._fund_bob(1, 'S', '5')
 
 	def fund_bob_recv2(self):
-		return self._fund_bob(1, 'B', '500')
+		return self._fund_bob(1, 'B', '5')
 
 	def addrgen_bob_recv_subwallet(self):
 		return self._addrgen_bob(1, ['C', 'B'], subseed_idx='29L')
@@ -343,7 +343,7 @@ class CmdTestSwap(CmdTestRegtest, CmdTestAutosignThreaded):
 	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'):
+	def fund_bob_recv_subwallet(self, proto_idx=1, amt='5'):
 		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]
@@ -351,7 +351,7 @@ class CmdTestSwap(CmdTestRegtest, CmdTestAutosignThreaded):
 		return t
 
 	def bob_bal_recv(self):
-		return self._bob_bal(1, '1500')
+		return self._bob_bal(1, '15')
 
 	def _swaptxcreate_ui_common(
 			self,
@@ -361,7 +361,8 @@ class CmdTestSwap(CmdTestRegtest, CmdTestAutosignThreaded):
 			interactive_fee = None,
 			file_desc       = 'Unsigned transaction',
 			reload_quote    = False,
-			sign_and_send   = False):
+			sign_and_send   = False,
+			expect         = None):
 		t.expect('abel:\b', 'q')
 		t.expect('to spend: ', f'{inputs}\n')
 		if reload_quote:
@@ -374,6 +375,8 @@ class CmdTestSwap(CmdTestRegtest, CmdTestAutosignThreaded):
 			t.expect('to continue: ', 'r')  # reload swap quote
 		t.expect('to continue: ', '\n')     # exit swap quote view
 		t.expect('view: ', 'y')             # view TX
+		if expect:
+			t.expect(expect)
 		t.expect('to continue: ', '\n')
 		if sign_and_send:
 			t.passphrase(dfl_wcls.desc, rt_pw)
@@ -393,44 +396,58 @@ class CmdTestSwap(CmdTestRegtest, CmdTestAutosignThreaded):
 
 	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']))
+			self._swaptxcreate(
+				['BCH', '1.234', f'{self.sid}:C:{idx}', 'LTC', f'{self.sid}:B:3'],
+				add_opts = ['--trade-limit=0%']),
+			expect = ':3541e5/1/0')
 
 	def swaptxcreate2(self):
-		t = self._swaptxcreate(['BCH', 'LTC'], add_opts=['--no-quiet'])
+		t = self._swaptxcreate(
+			['BCH', 'LTC'],
+			add_opts = ['--no-quiet', '--trade-limit=3.337%'])
 		t.expect('Enter a number> ', '1')
 		t.expect('OK? (Y/n): ', 'y')
-		return self._swaptxcreate_ui_common(t, reload_quote=True)
+		return self._swaptxcreate_ui_common(t, reload_quote=True, expect=':1386e6/1/0')
 
 	def swaptxcreate3(self):
 		return self._swaptxcreate_ui_common(
-			self._swaptxcreate(['BCH', 'LTC', f'{self.sid}:B:3']))
+			self._swaptxcreate(
+				['BCH', 'LTC', f'{self.sid}:B:3'],
+				add_opts = ['--trade-limit=10.1%']),
+			expect = ':1289e6/1/0')
 
 	def swaptxcreate4(self):
-		t = self._swaptxcreate(['BCH', '1.234', 'C', 'LTC', 'B'])
+		t = self._swaptxcreate(
+			['BCH', '1.234', 'C', 'LTC', 'B'],
+			add_opts = ['--trade-limit=-1.123%'])
 		t.expect('OK? (Y/n): ', 'y')
 		t.expect('Enter a number> ', '1')
 		t.expect('OK? (Y/n): ', 'y')
-		return self._swaptxcreate_ui_common(t)
+		return self._swaptxcreate_ui_common(t, expect=':358e6/1/0')
 
 	def swaptxcreate5(self):
-		t = self._swaptxcreate(['BCH', '1.234', f'{self.sid}:C', 'LTC', f'{self.sid}:B'])
+		t = self._swaptxcreate(
+			['BCH', '1.234', f'{self.sid}:C', 'LTC', f'{self.sid}:B'],
+			add_opts = ['--trade-limit=3.6'])
 		t.expect('OK? (Y/n): ', 'y')
 		t.expect('OK? (Y/n): ', 'y')
-		return self._swaptxcreate_ui_common(t)
+		return self._swaptxcreate_ui_common(t, expect=':36e7/1/0')
 
 	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 = self._swaptxcreate(
+			['BCH', '1.234', f'{self.sid}:C', 'LTC', addr],
+			add_opts = ['--trade-limit=2.7%'])
 		t.expect('OK? (Y/n): ', 'y')
 		t.expect('to confirm: ', 'YES\n')
-		return self._swaptxcreate_ui_common(t)
+		return self._swaptxcreate_ui_common(t, expect=':3445e5/1/0')
 
 	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)
+		return self._swaptxcreate_ui_common(t, expect=':0/1/0')
 
 	def _swaptxcreate_bad(self, args, *, exit_val=1, expect1=None, expect2=None):
 		t = self._swaptxcreate(args, exit_val=exit_val)
@@ -487,7 +504,7 @@ class CmdTestSwap(CmdTestRegtest, CmdTestAutosignThreaded):
 	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']))
+			self._swaptxcreate(['LTC', '4.321', f'{self.sid}:S:2', 'BCH', f'{self.sid}:C:2']))
 
 	def swaptxsign1(self):
 		return self._swaptxsign()
@@ -605,17 +622,17 @@ class CmdTestSwap(CmdTestRegtest, CmdTestAutosignThreaded):
 		return self._generate_for_proto(2)
 
 	def swap_bal1(self):
-		return self._bob_bal(1, '1494.56784238')
+		return self._bob_bal(1, '10.67894238')
 
 	def swap_bal2(self):
-		return self._bob_bal(1, '1382.79038152')
+		return self._bob_bal(1, '8.90148152')
 
 	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'),
+			self._swaptxcreate(['LTC', '1.777444', f'{self.sid}:B:2', 'BCH', f'{self.sid}:C:2'], action='txdo'),
 			sign_and_send = True,
 			file_desc = 'Sent transaction')
 

+ 1 - 1
test/include/unit_test.py

@@ -144,7 +144,7 @@ class UnitTestHelpers:
 					asyncio.run(ret)
 			except Exception as e:
 				exc = type(e).__name__
-				emsg = e.args[0]
+				emsg = e.args[0] if e.args else '(unspecified error)'
 				cfg._util.vmsg(f' {exc:{exc_w}} [{emsg}]')
 				assert exc == exc_chk, m_exc.format(exc, exc_chk)
 				assert re.search(emsg_chk, emsg), m_err.format(emsg, emsg_chk)

+ 38 - 0
test/modtest_d/ut_misc.py

@@ -154,3 +154,41 @@ class unit_tests:
 		assert '4.3' == ver['4.3'] == '4.3'
 
 		return True
+
+	def exp_int(self, name, ut, desc='ExpInt() class'):
+		from mmgen.util2 import ExpInt
+
+		for num, trunc, chk, out in (
+				('0e0',              0,                  0,                  '0'),
+				('0e1',              0,                  0,                  '0'),
+				('3e0',              3,                  3,                  '3'),
+				('3e1',              30,                 30,                 '30'),
+				('3e2',              300,                300,                '300'),
+				('3e3',              3000,               3000,               '3e3'),
+				(3000,               3000,               3000,               '3e3'),
+				(1,                  1,                  1,                  '1'),
+				('123e0',            123,                123,                '123'),
+				(123,                123,                123,                '123'),
+				(12345,              12340,              12345,              '12345'),
+				(123456,             123400,             123456,             '123456'),
+				(1234567,            1234000,            1234000,            '1234e3'),
+				(123456789,          123400000,          123400000,          '1234e5'),
+				(998877665544332211, 998800000000000000, 998800000000000000, '9988e14'),
+			):
+			e = ExpInt(num, prec=4)
+			enc = e.enc
+			assert enc == out, f'{enc} != {out}'
+			assert e.trunc == trunc, f'{e.trunc} != {trunc}'
+			vmsg('')
+			vmsg(f'num        {num}')
+			vmsg(f'enc        {enc}')
+			dec = ExpInt(enc, prec=4)
+			vmsg(f'enc -> dec {dec}')
+			vmsg(f'chk        {chk}')
+			assert dec == chk, f'{dec} != {chk}'
+			dec_enc = dec.enc
+			vmsg(f'dec -> enc {dec_enc}')
+			vmsg(f'out        {out}')
+			assert dec_enc == out, f'{dec_enc} != {out}'
+
+		return True

+ 36 - 25
test/modtest_d/ut_tx.py

@@ -177,26 +177,37 @@ class unit_tests:
 			('ltc', 'bech32'),
 			('bch', 'compressed'),
 		):
-			proto = init_proto(cfg, coin)
+			proto = init_proto(cfg, coin, need_amt=True)
 			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
+			for limit, limit_chk in (
+				('123.4567',   12340000000),
+				('1.234567',   123400000),
+				('0.01234567', 1234000),
+				('0.00012345', 12345),
+				(None, 0),
+			):
+				vmsg('\nTesting memo initialization:')
+				m = Memo(proto, addr, trade_limit=proto.coin_amt(limit) if limit else None)
+				vmsg(f'str(memo):  {m}')
+				vmsg(f'repr(memo): {m!r}')
+				vmsg(f'limit:      {limit}')
+
+				p = Memo.parse(m)
+				limit_dec = proto.coin_amt(p.trade_limit, from_unit='satoshi')
+				vmsg(f'limit_dec:  {limit_dec.hl()}')
+
+				vmsg('\nTesting memo parsing:')
+				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 == limit_chk
+				assert p.stream_interval == 1
+				assert p.stream_quantity == 0 # auto
 
 			vmsg('\nTesting is_partial_memo():')
 			for vec in (
@@ -230,13 +241,13 @@ class unit_tests:
 				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')),
+				('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', 'invalid specifier', bad('=:l:foobar:x/1/0')),
+				('bad7', 'SwapMemoParseError', 'extra',             bad('=:l:foobar:0/1/0:x')),
 			), pfx='')
 
 		return True