22 Commits 1e422b2c2b ... 1eb0de7938

Author SHA1 Message Date
  The MMGen Project 1eb0de7938 ETH: transaction sending via Etherscan 9 months ago
  The MMGen Project 7e5ee50d0b tx.new: minor fixes 9 months ago
  The MMGen Project 56f730ef7c proto.eth.rpc: remove Reth mainnet warning 9 months ago
  The MMGen Project 1f166ce458 mmgen-txsend: add --test option 9 months ago
  The MMGen Project 1cab2f9d6d tx.new: support relative fees < 1 unit; add test 9 months ago
  The MMGen Project c7b2626d6e amt.py: reimplement CoinAmt.to_unit(), add test 9 months ago
  The MMGen Project 2c3fa1c49a minor cleanups 9 months ago
  The MMGen Project 6967456f8f mmgen-txsend: add --dump-hex and --mark-sent options 9 months ago
  The MMGen Project 886f8e3029 make --daemon-id a coin-specific cfg file option 9 months ago
  The MMGen Project e972e4f93e add --list-daemon-ids global config opt 9 months ago
  The MMGen Project 81e9889766 add --test-suite global config opt 9 months ago
  The MMGen Project 8ad00b5053 main_txsend.py: cleanups 9 months ago
  The MMGen Project f305bdd491 cmdtest.py regtest: cleanups 9 months ago
  The MMGen Project 70022b04b2 use keyword-only parameters where practicable (103 files changed) 9 months ago
  The MMGen Project 168e6bbc56 whitespace, comments, minor cleanups 9 months ago
  The MMGen Project 1bbb1816e1 tw,tx: make parameters `width` and `iwidth` positional-only 9 months ago
  The MMGen Project 89ad0fd29b keyword-only parameters throughout 9 months ago
  The MMGen Project 0563916c96 Addrlist(): `addrfile` -> `infile` 9 months ago
  The MMGen Project a50e9383a9 minor whitespace 9 months ago
  The MMGen Project 915473cc2d test suite: minor fixes 9 months ago
  The MMGen Project ef2d8ae82d M test/daemontest_d/ut_rpc.py 9 months ago
  The MMGen Project 5252e8d691 test-release.py: restore Pylint test 9 months ago
180 changed files with 1605 additions and 772 deletions
  1. 26 16
      doc/wiki/commands/command-help-txsend.md
  2. 7 7
      mmgen/addr.py
  3. 4 4
      mmgen/addrdata.py
  4. 4 3
      mmgen/addrfile.py
  5. 10 7
      mmgen/addrlist.py
  6. 2 2
      mmgen/altcoin/params.py
  7. 1 1
      mmgen/altcoin/util.py
  8. 13 6
      mmgen/amt.py
  9. 13 13
      mmgen/autosign.py
  10. 7 7
      mmgen/baseconv.py
  11. 7 7
      mmgen/bip39.py
  12. 36 22
      mmgen/bip_hd/__init__.py
  13. 14 9
      mmgen/cfg.py
  14. 1 1
      mmgen/cfgfile.py
  15. 18 12
      mmgen/crypto.py
  16. 18 12
      mmgen/daemon.py
  17. 4 0
      mmgen/data/mmgen.cfg
  18. 1 1
      mmgen/data/version
  19. 1 1
      mmgen/devtools.py
  20. 8 8
      mmgen/filename.py
  21. 15 8
      mmgen/fileutil.py
  22. 9 0
      mmgen/help/__init__.py
  23. 5 1
      mmgen/help/help_notes.py
  24. 1 1
      mmgen/help/txsign.py
  25. 2 2
      mmgen/key.py
  26. 3 3
      mmgen/keygen.py
  27. 10 3
      mmgen/led.py
  28. 2 2
      mmgen/main_addrgen.py
  29. 2 2
      mmgen/main_addrimport.py
  30. 1 1
      mmgen/main_autosign.py
  31. 5 5
      mmgen/main_msg.py
  32. 2 2
      mmgen/main_passgen.py
  33. 1 1
      mmgen/main_seedjoin.py
  34. 7 4
      mmgen/main_tool.py
  35. 84 26
      mmgen/main_txsend.py
  36. 1 1
      mmgen/main_txsign.py
  37. 2 2
      mmgen/main_wallet.py
  38. 1 1
      mmgen/main_xmrwallet.py
  39. 4 4
      mmgen/mn_entry.py
  40. 5 5
      mmgen/msg.py
  41. 12 9
      mmgen/obj.py
  42. 10 8
      mmgen/objmethods.py
  43. 7 3
      mmgen/opts.py
  44. 9 12
      mmgen/passwdlist.py
  45. 3 3
      mmgen/platform/darwin/util.py
  46. 1 1
      mmgen/proto/btc/addrdata.py
  47. 2 1
      mmgen/proto/btc/params.py
  48. 6 6
      mmgen/proto/btc/regtest.py
  49. 7 2
      mmgen/proto/btc/rpc.py
  50. 1 1
      mmgen/proto/btc/tw/addresses.py
  51. 2 2
      mmgen/proto/btc/tw/bal.py
  52. 1 1
      mmgen/proto/btc/tw/ctl.py
  53. 1 1
      mmgen/proto/btc/tw/rpc.py
  54. 10 10
      mmgen/proto/btc/tw/txhistory.py
  55. 3 3
      mmgen/proto/btc/tx/base.py
  56. 0 4
      mmgen/proto/btc/tx/completed.py
  57. 12 12
      mmgen/proto/btc/tx/info.py
  58. 10 5
      mmgen/proto/btc/tx/new.py
  59. 23 2
      mmgen/proto/btc/tx/online.py
  60. 1 1
      mmgen/proto/btc/tx/op_return_data.py
  61. 1 1
      mmgen/proto/eth/addrdata.py
  62. 7 4
      mmgen/proto/eth/contract.py
  63. 3 3
      mmgen/proto/eth/daemon.py
  64. 1 1
      mmgen/proto/eth/misc.py
  65. 1 0
      mmgen/proto/eth/params.py
  66. 5 7
      mmgen/proto/eth/rpc.py
  67. 1 1
      mmgen/proto/eth/tw/addresses.py
  68. 2 2
      mmgen/proto/eth/tw/bal.py
  69. 7 6
      mmgen/proto/eth/tw/ctl.py
  70. 1 1
      mmgen/proto/eth/tw/json.py
  71. 2 2
      mmgen/proto/eth/tw/unspent.py
  72. 9 10
      mmgen/proto/eth/tx/base.py
  73. 1 1
      mmgen/proto/eth/tx/bump.py
  74. 3 3
      mmgen/proto/eth/tx/info.py
  75. 7 8
      mmgen/proto/eth/tx/new.py
  76. 4 1
      mmgen/proto/eth/tx/online.py
  77. 2 2
      mmgen/proto/secp256k1/keygen.py
  78. 1 0
      mmgen/proto/xmr/daemon.py
  79. 5 4
      mmgen/proto/xmr/rpc.py
  80. 4 2
      mmgen/protocol.py
  81. 10 9
      mmgen/rpc.py
  82. 2 2
      mmgen/seed.py
  83. 13 10
      mmgen/seedsplit.py
  84. 1 1
      mmgen/sha2.py
  85. 5 5
      mmgen/subseed.py
  86. 1 1
      mmgen/swap/proto/thorchain/memo.py
  87. 2 2
      mmgen/swap/proto/thorchain/thornode.py
  88. 8 8
      mmgen/term.py
  89. 1 1
      mmgen/tool/coin.py
  90. 1 1
      mmgen/tool/common.py
  91. 2 2
      mmgen/tool/file.py
  92. 8 8
      mmgen/tool/filecrypt.py
  93. 5 4
      mmgen/tool/fileutil.py
  94. 1 1
      mmgen/tool/help.py
  95. 4 2
      mmgen/tool/mnemonic.py
  96. 15 8
      mmgen/tool/rpc.py
  97. 16 12
      mmgen/tool/util.py
  98. 19 16
      mmgen/tool/wallet.py
  99. 14 13
      mmgen/tw/addresses.py
  100. 3 3
      mmgen/tw/bal.py
  101. 8 6
      mmgen/tw/ctl.py
  102. 5 2
      mmgen/tw/json.py
  103. 2 2
      mmgen/tw/shared.py
  104. 4 4
      mmgen/tw/txhistory.py
  105. 20 19
      mmgen/tw/unspent.py
  106. 6 5
      mmgen/tw/view.py
  107. 5 4
      mmgen/tx/base.py
  108. 9 9
      mmgen/tx/file.py
  109. 8 8
      mmgen/tx/info.py
  110. 25 19
      mmgen/tx/new.py
  111. 2 3
      mmgen/tx/online.py
  112. 6 6
      mmgen/tx/sign.py
  113. 223 0
      mmgen/tx/tx_proxy.py
  114. 11 10
      mmgen/ui.py
  115. 17 16
      mmgen/util.py
  116. 24 8
      mmgen/util2.py
  117. 3 0
      mmgen/wallet/__init__.py
  118. 5 5
      mmgen/wallet/base.py
  119. 1 1
      mmgen/wallet/brain.py
  120. 2 2
      mmgen/wallet/dieroll.py
  121. 3 3
      mmgen/wallet/enc.py
  122. 3 3
      mmgen/wallet/incog_base.py
  123. 5 5
      mmgen/wallet/mmgen.py
  124. 1 1
      mmgen/wallet/mmhex.py
  125. 6 6
      mmgen/wallet/mnemonic.py
  126. 1 1
      mmgen/wallet/plainhex.py
  127. 1 1
      mmgen/wallet/seed.py
  128. 2 2
      mmgen/xmrseed.py
  129. 1 1
      mmgen/xmrwallet/__init__.py
  130. 4 4
      mmgen/xmrwallet/file/__init__.py
  131. 5 5
      mmgen/xmrwallet/file/outputs.py
  132. 6 6
      mmgen/xmrwallet/file/tx.py
  133. 2 2
      mmgen/xmrwallet/include.py
  134. 2 2
      mmgen/xmrwallet/ops/__init__.py
  135. 1 1
      mmgen/xmrwallet/ops/create.py
  136. 1 1
      mmgen/xmrwallet/ops/import.py
  137. 1 1
      mmgen/xmrwallet/ops/label.py
  138. 1 1
      mmgen/xmrwallet/ops/relay.py
  139. 1 1
      mmgen/xmrwallet/ops/sign.py
  140. 1 1
      mmgen/xmrwallet/ops/txview.py
  141. 6 6
      mmgen/xmrwallet/ops/wallet.py
  142. 5 5
      mmgen/xmrwallet/rpc.py
  143. 1 0
      nix/packages.nix
  144. 9 0
      pyproject.toml
  145. 1 0
      setup.cfg
  146. 4 0
      test/cmdtest_d/common.py
  147. 14 4
      test/cmdtest_d/ct_automount.py
  148. 30 7
      test/cmdtest_d/ct_autosign.py
  149. 3 2
      test/cmdtest_d/ct_base.py
  150. 21 3
      test/cmdtest_d/ct_ethdev.py
  151. 3 3
      test/cmdtest_d/ct_input.py
  152. 11 5
      test/cmdtest_d/ct_main.py
  153. 97 21
      test/cmdtest_d/ct_regtest.py
  154. 17 7
      test/cmdtest_d/ct_shared.py
  155. 1 1
      test/cmdtest_d/ct_swap.py
  156. 1 1
      test/cmdtest_d/ct_xmr_autosign.py
  157. 3 11
      test/cmdtest_d/ct_xmrwallet.py
  158. 32 0
      test/cmdtest_d/etherscan.py
  159. 2 2
      test/daemontest_d/ut_exec.py
  160. 3 3
      test/daemontest_d/ut_msg.py
  161. 7 4
      test/daemontest_d/ut_rpc.py
  162. 1 1
      test/daemontest_d/ut_tx.py
  163. 3 3
      test/gentest.py
  164. 1 1
      test/include/common.py
  165. 2 2
      test/modtest_d/ut_addrlist.py
  166. 81 0
      test/modtest_d/ut_amt.py
  167. 1 1
      test/modtest_d/ut_bip39.py
  168. 1 1
      test/modtest_d/ut_bip_hd.py
  169. 1 1
      test/modtest_d/ut_gen.py
  170. 1 1
      test/modtest_d/ut_misc.py
  171. 6 6
      test/modtest_d/ut_seedsplit.py
  172. 8 8
      test/modtest_d/ut_subseed.py
  173. 2 2
      test/objattrtest_d/oat_btc_mainnet.py
  174. 16 9
      test/objtest_d/ot_btc_mainnet.py
  175. 1 1
      test/overlay/fakemods/mmgen/crypto.py
  176. 10 0
      test/overlay/fakemods/mmgen/tx/tx_proxy.py
  177. 34 0
      test/ref/ethereum/etherscan-form.html
  178. 33 0
      test/ref/ethereum/etherscan-result.html
  179. 16 2
      test/test-release.d/cfg.sh
  180. 6 3
      test/test-release.sh

+ 26 - 16
doc/wiki/commands/command-help-txsend.md

@@ -2,21 +2,31 @@
   MMGEN-TXSEND: Send a signed MMGen cryptocoin transaction
   USAGE:        mmgen-txsend [opts] [signed transaction file]
   OPTIONS:
-  -h, --help      Print this help message
-      --longhelp  Print help message for long (global) options
-  -a, --autosign  Send an autosigned transaction created by ‘mmgen-txcreate
-                  --autosign’.  The removable device is mounted and unmounted
-                  automatically. The transaction file argument must be omitted
-                  when using this option
-  -A, --abort     Abort an unsent transaction created by ‘mmgen-txcreate
-                  --autosign’ and delete it from the removable device.  The
-                  transaction may be signed or unsigned.
-  -d, --outdir  d Specify an alternate directory 'd' for output
-  -q, --quiet     Suppress warnings; overwrite files without prompting
-  -s, --status    Get status of a sent transaction (or the current transaction,
-                  whether sent or unsent, when used with --autosign)
-  -v, --verbose   Be more verbose
-  -y, --yes       Answer 'yes' to prompts, suppress non-essential output
+  -h, --help       Print this help message
+      --longhelp   Print help message for long (global) options
+  -a, --autosign   Send an autosigned transaction created by ‘mmgen-txcreate
+                   --autosign’.  The removable device is mounted and unmounted
+                   automatically. The transaction file argument must be omitted
+                   when using this option
+  -A, --abort      Abort an unsent transaction created by ‘mmgen-txcreate
+                   --autosign’ and delete it from the removable device.  The
+                   transaction may be signed or unsigned.
+  -d, --outdir d   Specify an alternate directory 'd' for output
+  -H, --dump-hex F Instead of sending to the network, dump the transaction hex
+                   to file ‘F’.  Use filename ‘-’ to dump to standard output.
+  -m, --mark-sent  Mark the transaction as sent by adding it to the removable
+                   device.  Used in combination with --autosign when a trans-
+                   action has been successfully sent out-of-band.
+  -n, --tx-proxy P Send transaction via public TX proxy ‘P’ (supported proxies:
+                   ‘etherscan’).  This is done via a publicly accessible web
+                   page, so no API key or registration is required
+  -q, --quiet      Suppress warnings; overwrite files without prompting
+  -s, --status     Get status of a sent transaction (or current transaction,
+                   whether sent or unsent, when used with --autosign)
+  -t, --test       Test whether the transaction can be sent without sending it
+  -v, --verbose    Be more verbose
+  -x, --proxy P    Connect to TX proxy via SOCKS5 proxy ‘P’ (host:port)
+  -y, --yes        Answer 'yes' to prompts, suppress non-essential output
 
-  MMGEN v15.1.dev18              March 2025                    MMGEN-TXSEND(1)
+  MMGEN v15.1.dev20              March 2025                    MMGEN-TXSEND(1)
 ```

+ 7 - 7
mmgen/addr.py

@@ -54,7 +54,7 @@ class MMGenAddrType(HiliteStr, InitErrors, MMGenObject):
 		'Z': ati('zcash_z','zcash_z',False,'zcash_z', 'zcash_z', 'wif',     ('viewkey',),      'Zcash z-address'),
 		'M': ati('monero', 'monero', False,'monero',  'monero',  'spendkey',('viewkey','wallet_passwd'),'Monero address'),
 	}
-	def __new__(cls, proto, id_str, errmsg=None):
+	def __new__(cls, proto, id_str, *, errmsg=None):
 		if isinstance(id_str, cls):
 			return id_str
 		try:
@@ -99,7 +99,7 @@ class AddrListID(HiliteStr, InitErrors, MMGenObject):
 	width = 10
 	trunc_ok = False
 	color = 'yellow'
-	def __new__(cls, sid=None, mmtype=None, proto=None, id_str=None):
+	def __new__(cls, *, sid=None, mmtype=None, proto=None, id_str=None):
 		try:
 			if id_str:
 				a, b = id_str.split(':')
@@ -182,14 +182,14 @@ class CoinAddr(HiliteStr, InitErrors, MMGenObject):
 
 	# reimplement some HiliteStr methods:
 	@classmethod
-	def fmtc(cls, s, width, color=False):
-		return super().fmtc(s=s[:width-2]+'..' if len(s) > width else s, width=width, color=color)
+	def fmtc(cls, s, width, /, *, color=False):
+		return super().fmtc(s[:width-2]+'..' if len(s) > width else s, width, color=color)
 
-	def fmt(self, view_pref, width, color=False):
+	def fmt(self, view_pref, width, /, *, color=False):
 		s = self.views[view_pref]
-		return super().fmtc(f'{s[:width-2]}..' if len(s) > width else s, width=width, color=color)
+		return super().fmtc(f'{s[:width-2]}..' if len(s) > width else s, width, color=color)
 
-	def hl(self, view_pref, color=True):
+	def hl(self, view_pref, /, *, color=True):
 		return getattr(color_mod, self.color)(self.views[view_pref]) if color else self.views[view_pref]
 
 def is_coin_addr(proto, s):

+ 4 - 4
mmgen/addrdata.py

@@ -29,7 +29,7 @@ from .addrlist import AddrListEntry, AddrListData, AddrList
 
 class AddrData(MMGenObject):
 
-	def __init__(self, proto, *args, **kwargs):
+	def __init__(self, proto):
 		self.al_ids = {}
 		self.proto = proto
 		self.rpc = None
@@ -68,10 +68,10 @@ class AddrData(MMGenObject):
 
 class TwAddrData(AddrData, metaclass=AsyncInit):
 
-	def __new__(cls, cfg, proto, *args, **kwargs):
+	def __new__(cls, cfg, proto, *, twctl=None):
 		return MMGenObject.__new__(proto.base_proto_subclass(cls, 'addrdata'))
 
-	async def __init__(self, cfg, proto, twctl=None):
+	async def __init__(self, cfg, proto, *, twctl=None):
 		from .rpc import rpc_init
 		from .tw.shared import TwLabel
 		from .seed import SeedID
@@ -79,7 +79,7 @@ class TwAddrData(AddrData, metaclass=AsyncInit):
 		self.proto = proto
 		self.rpc = await rpc_init(cfg, proto)
 		self.al_ids = {}
-		twd = await self.get_tw_data(twctl)
+		twd = await self.get_tw_data(twctl=twctl)
 		out, i = {}, 0
 		for acct, addr_array in twd:
 			l = get_obj(TwLabel, proto=self.proto, text=acct, silent=True)

+ 4 - 3
mmgen/addrfile.py

@@ -66,6 +66,7 @@ class AddrFile(MMGenObject):
 	def write(
 			self,
 			fn            = None,
+			*,
 			binary        = False,
 			desc          = None,
 			ask_overwrite = True,
@@ -92,7 +93,7 @@ class AddrFile(MMGenObject):
 		)
 		return self.parent.al_id.sid + (' ' if lbl_p2 else '') + lbl_p2
 
-	def format(self, add_comments=False):
+	def format(self, *, add_comments=False):
 		p = self.parent
 		if p.gen_passwds and p.pw_fmt in ('bip39', 'xmrseed'):
 			desc_pfx = f'{p.pw_fmt.upper()} '
@@ -200,7 +201,7 @@ class AddrFile(MMGenObject):
 
 		return ret
 
-	def parse_file(self, fn, buf=[], exit_on_error=True):
+	def parse_file(self, fn, *, buf=[], exit_on_error=True):
 
 		def parse_addrfile_label(lbl):
 			"""
@@ -249,7 +250,7 @@ class AddrFile(MMGenObject):
 		p = self.parent
 
 		from .fileutil import get_lines_from_file
-		lines = get_lines_from_file(p.cfg, fn, p.desc+' data', trim_comments=True)
+		lines = get_lines_from_file(p.cfg, fn, desc=f'{p.desc} data', trim_comments=True)
 
 		try:
 			assert len(lines) >= 3, f'Too few lines in address file ({len(lines)})'

+ 10 - 7
mmgen/addrlist.py

@@ -30,7 +30,7 @@ class AddrIdxList(tuple, InitErrors, MMGenObject):
 
 	max_len = 1000000
 
-	def __new__(cls, fmt_str=None, idx_list=None, sep=','):
+	def __new__(cls, *, fmt_str=None, idx_list=None, sep=','):
 		try:
 			if fmt_str:
 				def gen():
@@ -103,7 +103,7 @@ class AddrListIDStr(HiliteStr):
 	color = 'green'
 	trunc_ok = False
 
-	def __new__(cls, addrlist, fmt_str=None):
+	def __new__(cls, addrlist, *, fmt_str=None):
 		idxs = [e.idx for e in addrlist.data]
 		prev = idxs[0]
 		ret = [prev]
@@ -159,7 +159,8 @@ class AddrList(MMGenObject): # Address info for a single seed ID
 			self,
 			cfg,
 			proto,
-			addrfile  = '',
+			*,
+			infile    = '',
 			al_id     = '',
 			adata     = [],
 			seed      = '',
@@ -185,11 +186,13 @@ class AddrList(MMGenObject): # Address info for a single seed ID
 		if seed and addr_idxs:   # data from seed + idxs
 			self.al_id = AddrListID(sid=seed.sid, mmtype=MMGenAddrType(proto, mmtype or proto.dfl_mmtype))
 			src = 'gen'
-			adata = self.generate(seed, addr_idxs if isinstance(addr_idxs, AddrIdxList) else AddrIdxList(addr_idxs))
+			adata = self.generate(
+				seed,
+				addr_idxs if isinstance(addr_idxs, AddrIdxList) else AddrIdxList(fmt_str=addr_idxs))
 			do_chksum = True
-		elif addrfile:           # data from MMGen address file
-			self.infile = addrfile
-			adata = self.file.parse_file(addrfile) # sets self.al_id
+		elif infile:             # data from MMGen address file
+			self.infile = infile
+			adata = self.file.parse_file(infile) # sets self.al_id
 			do_chksum = True
 		elif al_id and adata:    # data from tracking wallet
 			self.al_id = al_id

+ 2 - 2
mmgen/altcoin/params.py

@@ -267,7 +267,7 @@ class CoinInfo:
 			return None
 		return cls.coin_constants[network][idx]
 
-def make_proto(e, testnet=False):
+def make_proto(e, *, testnet=False):
 
 	proto = ('X_' if e.name[0] in '0123456789' else '') + e.name + ('Testnet' if testnet else '')
 
@@ -297,7 +297,7 @@ def make_proto(e, testnet=False):
 		)
 	)
 
-def init_genonly_altcoins(usr_coin=None, testnet=False):
+def init_genonly_altcoins(usr_coin=None, *, testnet=False):
 	"""
 	Initialize altcoin protocol class or classes for current network.
 	If usr_coin is a core coin, initialization is skipped.

+ 1 - 1
mmgen/altcoin/util.py

@@ -14,7 +14,7 @@ altcoin.util: various altcoin-related utilities
 
 from ..util import die
 
-def decrypt_keystore(data, passwd, mac_algo=None, mac_params={}):
+def decrypt_keystore(data, passwd, *, mac_algo=None, mac_params={}):
 	"""
 	Decrypt the encrypted data in a cross-chain keystore
 	Returns the decrypted data as a bytestring

+ 13 - 6
mmgen/amt.py

@@ -40,7 +40,7 @@ class CoinAmt(Decimal, Hilite, InitErrors): # abstract class
 	max_amt  = None   # coin supply if known, otherwise None
 	units    = ()     # defined unit names, e.g. ('satoshi',...)
 
-	def __new__(cls, num, from_unit=None, from_decimal=False):
+	def __new__(cls, num, *, from_unit=None, from_decimal=False):
 
 		if isinstance(num, CoinAmt):
 			raise TypeError(f'CoinAmt: {num} is instance of {cls.__name__}')
@@ -65,13 +65,18 @@ class CoinAmt(Decimal, Hilite, InitErrors): # abstract class
 			return cls.init_fail(e, num)
 
 	def to_unit(self, unit):
-		return int(Decimal(self) // getattr(self, unit))
+		if (u := getattr(self, unit)) == self.atomic:
+			return int(Decimal(self) // u)
+		else:
+			return Decimal('{:0.{w}f}'.format(
+				self / u,
+				w = (u/self.atomic).as_tuple().exponent))
 
 	@classmethod
 	def fmtc(cls, *args, **kwargs):
 		cls.method_not_implemented()
 
-	def fmt(self, color=False, iwidth=1, prec=None): # iwidth: width of the integer part
+	def fmt(self, iwidth=1, /, *, color=False, prec=None): # iwidth: width of the integer part
 		prec = prec or self.max_prec
 		if '.' in (s := str(self)):
 			a, b = s.split('.', 1)
@@ -83,11 +88,11 @@ class CoinAmt(Decimal, Hilite, InitErrors): # abstract class
 				s.rjust(iwidth).ljust(iwidth+prec+1),
 				color = color)
 
-	def hl(self, color=True):
+	def hl(self, *, color=True):
 		return self.colorize(str(self), color=color)
 
 	# fancy highlighting with coin unit, enclosure, formatting
-	def hl2(self, color=True, unit=False, fs='{}', encl=''):
+	def hl2(self, *, color=True, unit=False, fs='{}', encl=''):
 		res = fs.format(self)
 		return (
 			encl[:-1]
@@ -156,7 +161,7 @@ class CoinAmt(Decimal, Hilite, InitErrors): # abstract class
 	def __mod__(self, *args, **kwargs):
 		self.method_not_implemented()
 
-def is_coin_amt(proto, num, from_unit=None, from_decimal=False):
+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)
 
@@ -165,6 +170,7 @@ class BTCAmt(CoinAmt):
 	max_prec = 8
 	max_amt = 21000000
 	satoshi = Decimal('0.00000001')
+	atomic = satoshi
 	units = ('satoshi',)
 
 class BCHAmt(BTCAmt):
@@ -190,6 +196,7 @@ class ETHAmt(CoinAmt):
 	Gwei    = Decimal('0.000000001')
 	szabo   = Decimal('0.000001')
 	finney  = Decimal('0.001')
+	atomic  = wei
 	units   = ('wei', 'Kwei', 'Mwei', 'Gwei', 'szabo', 'finney')
 
 	def toWei(self):

+ 13 - 13
mmgen/autosign.py

@@ -33,12 +33,12 @@ def SwapMgr(*args, **kwargs):
 
 class SwapMgrBase:
 
-	def __init__(self, cfg, ignore_zram=False):
+	def __init__(self, cfg, *, ignore_zram=False):
 		self.cfg = cfg
 		self.ignore_zram = ignore_zram
 		self.desc = 'disk swap' if ignore_zram else 'swap'
 
-	def enable(self, quiet=False):
+	def enable(self, *, quiet=False):
 		ret = self.do_enable()
 		if not quiet:
 			self.cfg._util.qmsg(
@@ -47,7 +47,7 @@ class SwapMgrBase:
 				f'Could not enable {self.desc}')
 		return ret
 
-	def disable(self, quiet=False):
+	def disable(self, *, quiet=False):
 		self.cfg._util.qmsg_r(f'Attempting to disable {self.desc}...')
 		ret = self.do_disable()
 		self.cfg._util.qmsg('success')
@@ -189,7 +189,7 @@ class Signable:
 				b = '  {}\n'.format('\n  '.join(self.gen_bad_list(sorted(bad_files, key=lambda f: f.name))))
 			))
 
-		def die_wrong_num_txs(self, tx_type, msg=None, desc=None, show_dir=False):
+		def die_wrong_num_txs(self, tx_type, *, msg=None, desc=None, show_dir=False):
 			num_txs = len(getattr(self, tx_type))
 			die('AutosignTXError', "{m}{a} {b} transaction{c} {d} {e}!".format(
 				m = msg + '\n' if msg else '',
@@ -310,8 +310,8 @@ class Signable:
 					for tx, non_mmgen in body:
 						for nm in non_mmgen:
 							yield fs.format(
-								tx.txid.fmt(width=t_wid, color=True) if nm is non_mmgen[0] else ' '*t_wid,
-								nm.addr.fmt(nm.addr.view_pref, width=a_wid, color=True),
+								tx.txid.fmt(t_wid, color=True) if nm is non_mmgen[0] else ' '*t_wid,
+								nm.addr.fmt(nm.addr.view_pref, a_wid, color=True),
 								nm.amt.hl() + ' ' + yellow(tx.coin))
 
 				msg('\n' + '\n'.join(gen()))
@@ -459,7 +459,7 @@ class Autosign:
 	def init_fixup(self): # see test/overlay/fakemods/mmgen/autosign.py
 		pass
 
-	def __init__(self, cfg, cmd=None):
+	def __init__(self, cfg, *, cmd=None):
 
 		if cfg.mnemonic_fmt:
 			if cfg.mnemonic_fmt not in self.mn_fmts:
@@ -581,7 +581,7 @@ class Autosign:
 
 		return self._wallet_files
 
-	def do_mount(self, silent=False, verbose=False):
+	def do_mount(self, *, silent=False, verbose=False):
 
 		def check_or_create(dirname):
 			path = getattr(self, dirname)
@@ -615,7 +615,7 @@ class Autosign:
 		for dirname in self.dirs:
 			check_or_create(dirname)
 
-	def do_umount(self, silent=False, verbose=False):
+	def do_umount(self, *, silent=False, verbose=False):
 		if self.mountpoint.is_mount():
 			run(['sync'], check=True)
 			if not silent:
@@ -630,7 +630,7 @@ class Autosign:
 		fails = 0
 		for wf in self.wallet_files:
 			try:
-				Wallet(self.cfg, wf, ignore_in_fmt=True, passwd_file=str(self.keyfile))
+				Wallet(self.cfg, fn=wf, ignore_in_fmt=True, passwd_file=str(self.keyfile))
 			except SystemExit as e:
 				if e.code != 0:
 					fails += 1
@@ -710,7 +710,7 @@ class Autosign:
 			die(2, 'Unable to write ' + desc)
 		msg('Wrote ' + desc)
 
-	def gen_key(self, no_unmount=False):
+	def gen_key(self, *, no_unmount=False):
 		if not self.device_inserted:
 			die(1, 'Removable device not present!')
 		self.do_mount()
@@ -728,7 +728,7 @@ class Autosign:
 	def _get_macOS_ramdisk_size(self):
 		from .platform.darwin.util import MacOSRamDisk, warn_ramdisk_too_small
 		# allow 1MB for each Monero wallet
-		xmr_size = len(AddrIdxList(self.cfg.xmrwallets)) if self.cfg.xmrwallets else 0
+		xmr_size = len(AddrIdxList(fmt_str=self.cfg.xmrwallets)) if self.cfg.xmrwallets else 0
 		calc_size = xmr_size + 1
 		usr_size = self.cfg.macos_ramdisk_size or self.cfg.macos_autosign_ramdisk_size
 		if is_int(usr_size):
@@ -776,7 +776,7 @@ class Autosign:
 				cfg         = self.cfg,
 				prompt      = f"Default wallet '{wf}' found.\nUse default wallet for autosigning?",
 				default_yes = True):
-			ss_in = Wallet(Config(), wf)
+			ss_in = Wallet(Config(), fn=wf)
 		else:
 			ss_in = Wallet(self.cfg, in_fmt=self.mn_fmts[self.cfg.mnemonic_fmt or self.dfl_mn_fmt])
 		ss_out = Wallet(self.cfg, ss=ss_in, passwd_file=str(self.keyfile))

+ 7 - 7
mmgen/baseconv.py

@@ -113,7 +113,7 @@ class baseconv:
 			die(3, 'ERROR: List is not sorted!')
 
 	@staticmethod
-	def get_pad(pad, seed_pad_func):
+	def get_pad(pad, /, seed_pad_func):
 		"""
 		'pad' argument to baseconv conversion methods must be either None, 'seed' or an integer.
 		If None, output of minimum (but never zero) length will be produced.
@@ -129,11 +129,11 @@ class baseconv:
 		else:
 			die('BaseConversionPadError', f"{pad!r}: illegal value for 'pad' (must be None, 'seed' or int)")
 
-	def tohex(self, words_arg, pad=None):
+	def tohex(self, words_arg, /, *, pad=None):
 		"convert string or list data of instance base to a hexadecimal string"
-		return self.tobytes(words_arg, pad//2 if type(pad) is int else pad).hex()
+		return self.tobytes(words_arg, pad=pad//2 if type(pad) is int else pad).hex()
 
-	def tobytes(self, words_arg, pad=None):
+	def tobytes(self, words_arg, /, *, pad=None):
 		"convert string or list data of instance base to byte string"
 
 		words = words_arg if isinstance(words_arg, (list, tuple)) else tuple(words_arg.strip())
@@ -163,7 +163,7 @@ class baseconv:
 		bl = ret.bit_length()
 		return ret.to_bytes(max(pad_val, bl//8+bool(bl%8)), 'big')
 
-	def fromhex(self, hexstr, pad=None, tostr=False):
+	def fromhex(self, hexstr, /, *, pad=None, tostr=False):
 		"convert a hexadecimal string to a list or string data of instance base"
 
 		from .util import is_hex_str
@@ -172,9 +172,9 @@ class baseconv:
 				('seed data' if pad == 'seed' else f'{hexstr!r}:') +
 				' not a hexadecimal string')
 
-		return self.frombytes(bytes.fromhex(hexstr), pad, tostr)
+		return self.frombytes(bytes.fromhex(hexstr), pad=pad, tostr=tostr)
 
-	def frombytes(self, bytestr, pad=None, tostr=False):
+	def frombytes(self, bytestr, /, *, pad=None, tostr=False):
 		"convert byte string to list or string data of instance base"
 
 		if not bytestr:

+ 7 - 7
mmgen/bip39.py

@@ -54,24 +54,24 @@ class bip39(baseconv):
 		self.wl_id = 'bip39'
 
 	@classmethod
-	def nwords2seedlen(cls, nwords, in_bytes=False, in_hex=False):
+	def nwords2seedlen(cls, nwords, /, *, in_bytes=False, in_hex=False):
 		for k, v in cls.constants.items():
 			if v.mn_len == nwords:
 				return k//8 if in_bytes else k//4 if in_hex else k
 		die('MnemonicError', f'{nwords!r}: invalid word length for BIP39 mnemonic')
 
 	@classmethod
-	def seedlen2nwords(cls, seed_len, in_bytes=False, in_hex=False):
+	def seedlen2nwords(cls, seed_len, /, *, in_bytes=False, in_hex=False):
 		seed_bits = seed_len * 8 if in_bytes else seed_len * 4 if in_hex else seed_len
 		try:
 			return cls.constants[seed_bits].mn_len
 		except Exception as e:
 			raise ValueError(f'{seed_bits!r}: invalid seed length for BIP39 mnemonic') from e
 
-	def tohex(self, words_arg, pad=None):
+	def tohex(self, words_arg, /, *, pad=None):
 		return self.tobytes(words_arg, pad=pad).hex()
 
-	def tobytes(self, words_arg, pad=None):
+	def tobytes(self, words_arg, /, *, pad=None):
 		assert isinstance(words_arg, (list, tuple)), 'words_arg must be list or tuple'
 		assert pad in (None, 'seed'), f"{pad}: invalid 'pad' argument (must be None or 'seed')"
 
@@ -105,11 +105,11 @@ class bip39(baseconv):
 
 		return seed_bytes
 
-	def fromhex(self, hexstr, pad=None, tostr=False):
+	def fromhex(self, hexstr, /, *, pad=None, tostr=False):
 		assert is_hex_str(hexstr), 'seed data not a hexadecimal string'
 		return self.frombytes(bytes.fromhex(hexstr), pad=pad, tostr=tostr)
 
-	def frombytes(self, seed_bytes, pad=None, tostr=False):
+	def frombytes(self, seed_bytes, /, *, pad=None, tostr=False):
 		assert tostr is False, "'tostr' must be False for 'bip39'"
 		assert pad in (None, 'seed'), f"{pad}: invalid 'pad' argument (must be None or 'seed')"
 
@@ -128,7 +128,7 @@ class bip39(baseconv):
 
 		return tuple(wl[int(res[i*11:(i+1)*11], 2)] for i in range(c.mn_len))
 
-	def generate_seed(self, words_arg, passwd=''):
+	def generate_seed(self, words_arg, /, *, passwd=''):
 
 		self.tohex(words_arg) # validate
 

+ 36 - 22
mmgen/bip_hd/__init__.py

@@ -137,7 +137,7 @@ class BipHDConfig(Lockable):
 
 	supported_coins = ('btc', 'eth', 'doge', 'ltc', 'bch')
 
-	def __init__(self, base_cfg, coin, network, addr_type, from_path, no_path_checks):
+	def __init__(self, base_cfg, coin, *, network, addr_type, from_path, no_path_checks):
 
 		if not coin.lower() in self.supported_coins:
 			raise ValueError(f'bip_hd: coin {coin.upper()} not supported')
@@ -182,11 +182,24 @@ class MasterNode(Lockable):
 
 		check_privkey(int.from_bytes(self.key, byteorder='big'))
 
-	def init_cfg(self, coin=None, network=None, addr_type=None, from_path=False, no_path_checks=False):
+	def init_cfg(
+			self,
+			coin           = None,
+			*,
+			network        = None,
+			addr_type      = None,
+			from_path      = False,
+			no_path_checks = False):
 
 		new = BipHDNodeMaster()
 
-		new.cfg       = BipHDConfig(self.base_cfg, coin, network, addr_type, from_path, no_path_checks)
+		new.cfg = BipHDConfig(
+			self.base_cfg,
+			coin,
+			network = network,
+			addr_type = addr_type,
+			from_path = from_path,
+			no_path_checks = no_path_checks)
 		new.par_print = self.par_print
 		new.depth     = self.depth
 		new.key       = self.key
@@ -198,11 +211,11 @@ class MasterNode(Lockable):
 		new._lock()
 		return new
 
-	def to_coin_type(self, coin=None, network=None, addr_type=None):
-		return self.init_cfg(coin, network, addr_type).to_coin_type()
+	def to_coin_type(self, *, coin=None, network=None, addr_type=None):
+		return self.init_cfg(coin, network=network, addr_type=addr_type).to_coin_type()
 
-	def to_chain(self, idx, coin=None, network=None, addr_type=None, hardened=False, public=False):
-		return self.init_cfg(coin, network, addr_type).to_chain(
+	def to_chain(self, idx, *, coin=None, network=None, addr_type=None, hardened=False, public=False):
+		return self.init_cfg(coin, network=network, addr_type=addr_type).to_chain(
 			idx      = idx,
 			hardened = hardened,
 			public   = public)
@@ -224,7 +237,7 @@ class BipHDNode(Lockable):
 					'None' if getattr(cls, name) is None else f'None or {getattr(cls, name)}')
 			)
 
-	def set_params(self, cfg, idx, hardened):
+	def set_params(self, cfg, idx, *, hardened):
 		self.check_param('idx', idx)
 		self.check_param('hardened', hardened)
 		return (
@@ -288,7 +301,7 @@ class BipHDNode(Lockable):
 	def xprv(self):
 		return self.key_extended(public=False, as_str=True)
 
-	def key_extended(self, public, as_str=False):
+	def key_extended(self, public, *, as_str=False):
 		if self.public and not public:
 			raise ValueError('cannot create extended private key for public node!')
 		ret = b58chk_encode(
@@ -310,7 +323,7 @@ class BipHDNode(Lockable):
 	def derive_private(self, idx=None, hardened=None):
 		return self.derive(idx=idx, hardened=hardened, public=False)
 
-	def derive(self, idx, hardened, public):
+	def derive(self, idx, *, hardened, public):
 
 		if self.public and not public:
 			raise ValueError('cannot derive private node from public node!')
@@ -328,7 +341,7 @@ class BipHDNode(Lockable):
 			if new.public and type(new).hardened:
 				raise ValueError(
 					f'‘public’ requested, but node of depth {new.depth} ({new.desc}) must be hardened!')
-			new.idx, new.hardened = new.set_params(new.cfg, idx, hardened)
+			new.idx, new.hardened = new.set_params(new.cfg, idx, hardened=hardened)
 
 		key_in = b'\x00' + self.key if new.hardened else self.pubkey_bytes
 
@@ -357,6 +370,7 @@ class BipHDNode(Lockable):
 			base_cfg,
 			seed,
 			path_str,
+			*,
 			coin           = None,
 			addr_type      = None,
 			no_path_checks = False):
@@ -384,14 +398,14 @@ class BipHDNode(Lockable):
 			if not is_int(idx):
 				raise ValueError(f'invalid path component {s!r}')
 
-			res = res.derive(int(idx), hardened, public=False)
+			res = res.derive(int(idx), hardened=hardened, public=False)
 
 		return res
 
 	@staticmethod
 	# ‘addr_type’ is required for broken coins with duplicate version bytes across BIP protocols
 	# (i.e. Dogecoin)
-	def from_extended_key(base_cfg, coin, xkey_b58, addr_type=None):
+	def from_extended_key(base_cfg, coin, xkey_b58, *, addr_type=None):
 		xk = Bip32ExtendedKey(xkey_b58)
 
 		if xk.public:
@@ -410,10 +424,10 @@ class BipHDNode(Lockable):
 		new.cfg = BipHDConfig(
 			base_cfg,
 			coin,
-			xk.network,
-			addr_type or addr_types[xk.bip_proto],
-			False,
-			False)
+			network = xk.network,
+			addr_type = addr_type or addr_types[xk.bip_proto],
+			from_path = False,
+			no_path_checks = False)
 
 		new.par_print  = xk.par_print
 		new.depth      = xk.depth
@@ -435,7 +449,7 @@ class BipHDNodeMaster(BipHDNode):
 		#           purpose          coin_type
 		return self.derive_private().derive_private()
 
-	def to_chain(self, idx, hardened=False, public=False):
+	def to_chain(self, idx, *, hardened=False, public=False):
 		#           purpose          coin_type        account #0            chain
 		return self.derive_private().derive_private().derive_private(idx=0).derive(
 			idx      = idx,
@@ -446,7 +460,7 @@ class BipHDNodePurpose(BipHDNode):
 	desc = 'Purpose'
 	hardened = True
 
-	def set_params(self, cfg, idx, hardened):
+	def set_params(self, cfg, idx, *, hardened):
 		self.check_param('hardened', hardened)
 		if idx not in (None, cfg.bip_proto):
 			raise ValueError(
@@ -458,7 +472,7 @@ class BipHDNodeCoinType(BipHDNode):
 	desc = 'Coin Type'
 	hardened = True
 
-	def set_params(self, cfg, idx, hardened):
+	def set_params(self, cfg, idx, *, hardened):
 		self.check_param('hardened', hardened)
 		chain_idx = get_chain_params(
 			bipnum = get_bip_by_addr_type(cfg.addr_type),
@@ -469,7 +483,7 @@ class BipHDNodeCoinType(BipHDNode):
 				f'chain index {chain_idx} for coin {cfg.base_cfg.coin!r}')
 		return (chain_idx, type(self).hardened)
 
-	def to_chain(self, idx, hardened=False, public=False):
+	def to_chain(self, idx, *, hardened=False, public=False):
 		#           account #0            chain
 		return self.derive_private(idx=0).derive(
 			idx      = idx,
@@ -484,7 +498,7 @@ class BipHDNodeChain(BipHDNode):
 	desc = 'Chain'
 	hardened = False
 
-	def set_params(self, cfg, idx, hardened):
+	def set_params(self, cfg, idx, *, hardened):
 		self.check_param('hardened', hardened)
 		if idx not in (0, 1):
 			raise ValueError(

+ 14 - 9
mmgen/cfg.py

@@ -99,7 +99,7 @@ class GlobalConstants(Lockable):
 	else:
 		die2(2, '$HOME is not set!  Unable to determine home directory')
 
-	def get_mmgen_data_file(self, filename, package='mmgen'):
+	def get_mmgen_data_file(self, *, filename, package='mmgen'):
 		"""
 		this is an expensive import, so do only when required
 		"""
@@ -292,6 +292,7 @@ class Config(Lockable):
 		'autosign',
 		'color',
 		'daemon_data_dir',
+		'daemon_id', # also coin-specific
 		'debug',
 		'fee_adjust',
 		'force_256_color',
@@ -380,7 +381,9 @@ class Config(Lockable):
 		'fee_estimate_mode': _ov('nocase_pfx', ['conservative', 'economical']),
 		'rpc_backend':       _ov('nocase_pfx', ['auto', 'httplib', 'curl', 'aiohttp', 'requests']),
 		'swap_proto':        _ov('nocase_pfx', ['thorchain']),
+		'tx_proxy':          _ov('nocase_pfx', ['etherscan']) # , 'blockchair'
 	}
+	_dfl_none_autoset_opts = ('tx_proxy',)
 
 	_auto_typeset_opts = {
 		'seed_len': int,
@@ -438,6 +441,7 @@ class Config(Lockable):
 	def __init__(
 			self,
 			cfg          = None,
+			*,
 			opts_data    = None,
 			init_opts    = None,
 			parse_only   = False,
@@ -521,7 +525,7 @@ class Config(Lockable):
 		# Step 4: set cfg from cfgfile, skipping already-set opts and auto opts; save set opts and auto
 		#         opts to be set:
 		# requires ‘data_dir_root’, ‘test_suite_cfgtest’
-		self._cfgfile_opts = self._set_cfg_from_cfg_file(self._envopts, need_proto)
+		self._cfgfile_opts = self._set_cfg_from_cfg_file(self._envopts, need_proto=need_proto)
 
 		# Step 5: set autoset opts from user-supplied data, cfgfile data, or default values, in that order:
 		self._set_autoset_opts(self._cfgfile_opts.autoset)
@@ -614,7 +618,7 @@ class Config(Lockable):
 			else:
 				raise ValueError(f'{name!r} is not a valid MMGen environment variable')
 
-	def _set_cfg_from_cfg_file(self, env_cfg, need_proto):
+	def _set_cfg_from_cfg_file(self, env_cfg, *, need_proto):
 
 		_ret = namedtuple('cfgfile_opts', ['non_auto', 'autoset', 'auto_typeset'])
 
@@ -717,7 +721,8 @@ class Config(Lockable):
 				val = None
 
 			if val is None:
-				setattr(self, key, self._autoset_opts[key].choices[0])
+				if key not in self._dfl_none_autoset_opts:
+					setattr(self, key, self._autoset_opts[key].choices[0])
 			else:
 				setattr(self, key, get_autoset_opt(key, val, src=src))
 
@@ -760,7 +765,7 @@ def check_opts(cfg): # Raises exception if any check fails
 			+ (f' in {cfg._cfgfile_fn!r}' if name in cfg._cfgfile_opts.non_auto else '')
 		)
 
-	def display_opt(name, val='', beg='For selected', end=':\n'):
+	def display_opt(name, val='', *, beg='For selected', end=':\n'):
 		from .util import msg_r
 		msg_r('{} option {!r}{}'.format(
 			beg,
@@ -778,11 +783,11 @@ def check_opts(cfg): # Raises exception if any check fails
 		}[op_str](val, target):
 			die('UserOptError', f'{val}: invalid {get_desc()} (not {op_str} {target})')
 
-	def opt_is_int(val, desc_pfx=''):
+	def opt_is_int(val, *, desc_pfx=''):
 		if not is_int(val):
 			die('UserOptError', f'{val!r}: invalid {get_desc(desc_pfx)} (not an integer)')
 
-	def opt_is_in_list(val, tlist, desc_pfx=''):
+	def opt_is_in_list(val, tlist, *, desc_pfx=''):
 		if val not in tlist:
 			q, sep = (('', ','), ("'", "','"))[isinstance(tlist[0], str)]
 			die('UserOptError', '{q}{v}{q}: invalid {w}\nValid choices: {q}{o}{q}'.format(
@@ -845,7 +850,7 @@ def check_opts(cfg): # Raises exception if any check fails
 			if hasattr(cfg, key2):
 				val2 = getattr(cfg, key2)
 				from .wallet import get_wallet_data
-				wd = get_wallet_data('incog_hidden')
+				wd = get_wallet_data(wtype='incog_hidden')
 				if val2 and val2 not in wd.fmt_codes:
 					die('UserOptError', f'Option conflict:\n  {fmt_opt(name)}, with\n  {fmt_opt(key2)}={val2}')
 
@@ -929,7 +934,7 @@ def opt_postproc_debug(cfg):
 	Msg('        {}\n'.format('\n        '.join(none_opts)))
 	Msg('\n=== end opts.py debug ===\n')
 
-def conv_type(name, val, refval, src, invert_bool=False):
+def conv_type(name, val, refval, *, src, invert_bool=False):
 
 	def do_fail():
 		desc = {

+ 1 - 1
mmgen/cfgfile.py

@@ -190,7 +190,7 @@ class CfgFileSampleSys(cfg_file_sample):
 		else:
 			# self.fn is used for error msgs only, so file need not exist on filesystem
 			self.fn = os.path.join(os.path.dirname(__file__), 'data', self.fn_base)
-			self.data = gc.get_mmgen_data_file(self.fn_base).splitlines()
+			self.data = gc.get_mmgen_data_file(filename=self.fn_base).splitlines()
 
 	def make_metadata(self):
 		return [f'# Version {self.cur_ver} {self.computed_chksum}']

+ 18 - 12
mmgen/crypto.py

@@ -89,10 +89,10 @@ class Crypto:
 			msg(f'Seed:  {seed.hex()!r}\nScramble key: {scramble_key}\nScrambled seed: {step1.hex()}\n')
 		return self.sha256_rounds(step1)
 
-	def encrypt_seed(self, data, key, desc='seed'):
-		return self.encrypt_data(data, key, desc=desc)
+	def encrypt_seed(self, data, key, *, desc='seed'):
+		return self.encrypt_data(data, key=key, desc=desc)
 
-	def decrypt_seed(self, enc_seed, key, seed_id, key_id):
+	def decrypt_seed(self, enc_seed, key, *, seed_id, key_id):
 		self.util.vmsg_r('Checking key...')
 		chk1 = make_chksum_8(key)
 		if key_id:
@@ -121,6 +121,7 @@ class Crypto:
 	def encrypt_data(
 			self,
 			data,
+			*,
 			key,
 			iv     = aesctr_dfl_iv,
 			desc   = 'data',
@@ -151,6 +152,7 @@ class Crypto:
 			self,
 			enc_data,
 			key,
+			*,
 			iv   = aesctr_dfl_iv,
 			desc = 'data'):
 
@@ -166,6 +168,7 @@ class Crypto:
 			passwd,
 			salt,
 			hash_preset,
+			*,
 			buflen = 32):
 
 		# Buflen arg is for brainwallets only, which use this function to generate
@@ -214,6 +217,7 @@ class Crypto:
 			passwd,
 			salt,
 			hash_preset,
+			*,
 			desc      = 'encryption key',
 			from_what = 'passphrase',
 			verbose   = False):
@@ -226,7 +230,7 @@ class Crypto:
 		self.util.dmsg(f'Key: {key.hex()}')
 		return key
 
-	def _get_random_data_from_user(self, uchars=None, desc='data'):
+	def _get_random_data_from_user(self, uchars=None, *, desc='data'):
 
 		if uchars is None:
 			uchars = self.cfg.usr_randchars
@@ -295,6 +299,7 @@ class Crypto:
 	def add_user_random(
 			self,
 			rand_bytes,
+			*,
 			desc,
 			urand = {'data':b'', 'counter':0}):
 
@@ -327,6 +332,7 @@ class Crypto:
 	def get_hash_preset_from_user(
 			self,
 			old_preset = gc.dfl_hash_preset,
+			*,
 			data_desc  = 'data',
 			prompt     = None):
 
@@ -345,7 +351,7 @@ class Crypto:
 			else:
 				return old_preset
 
-	def get_new_passphrase(self, data_desc, hash_preset, passwd_file, pw_desc='passphrase'):
+	def get_new_passphrase(self, data_desc, hash_preset, passwd_file, *, pw_desc='passphrase'):
 		message = f"""
 				You must choose a passphrase to encrypt your {data_desc} with.
 				A key will be generated from your passphrase using a hash preset of '{hash_preset}'.
@@ -381,7 +387,7 @@ class Crypto:
 
 		return pw
 
-	def get_passphrase(self, data_desc, passwd_file, pw_desc='passphrase'):
+	def get_passphrase(self, data_desc, passwd_file, *, pw_desc='passphrase'):
 		if passwd_file:
 			from .fileutil import get_words_from_file
 			return ' '.join(get_words_from_file(
@@ -393,7 +399,7 @@ class Crypto:
 			from .ui import get_words_from_user
 			return ' '.join(get_words_from_user(self.cfg, f'Enter {pw_desc} for {data_desc}: '))
 
-	def mmgen_encrypt(self, data, desc='data', hash_preset=None):
+	def mmgen_encrypt(self, data, *, desc='data', hash_preset=None):
 		salt  = self.get_random(self.mmenc_salt_len)
 		iv    = self.get_random(self.aesctr_iv_len)
 		nonce = self.get_random(self.mmenc_nonce_len)
@@ -407,10 +413,10 @@ class Crypto:
 			passwd_file = self.cfg.passwd_file)
 		key    = self.make_key(passwd, salt, hp)
 		from hashlib import sha256
-		enc_d  = self.encrypt_data(sha256(nonce+data).digest() + nonce + data, key, iv, desc=desc)
+		enc_d  = self.encrypt_data(sha256(nonce+data).digest() + nonce + data, key=key, iv=iv, desc=desc)
 		return salt+iv+enc_d
 
-	def mmgen_decrypt(self, data, desc='data', hash_preset=None):
+	def mmgen_decrypt(self, data, *, desc='data', hash_preset=None):
 		self.util.vmsg(f'Preparing to decrypt {desc}')
 		dstart = self.mmenc_salt_len + self.aesctr_iv_len
 		salt   = data[:self.mmenc_salt_len]
@@ -423,7 +429,7 @@ class Crypto:
 			data_desc = desc,
 			passwd_file = self.cfg.passwd_file)
 		key    = self.make_key(passwd, salt, hp)
-		dec_d  = self.decrypt_data(enc_d, key, iv, desc)
+		dec_d  = self.decrypt_data(enc_d, key, iv=iv, desc=desc)
 		sha256_len = 32
 		from hashlib import sha256
 		if dec_d[:sha256_len] == sha256(dec_d[sha256_len:]).digest():
@@ -433,9 +439,9 @@ class Crypto:
 			msg('Incorrect passphrase or hash preset')
 			return False
 
-	def mmgen_decrypt_retry(self, d, desc='data'):
+	def mmgen_decrypt_retry(self, d, *, desc='data'):
 		while True:
-			d_dec = self.mmgen_decrypt(d, desc)
+			d_dec = self.mmgen_decrypt(d, desc=desc)
 			if d_dec:
 				return d_dec
 			msg('Trying again...')

+ 18 - 12
mmgen/daemon.py

@@ -50,7 +50,7 @@ class Daemon(Lockable):
 	_reset_ok = ('debug', 'wait', 'pids')
 	version_info_arg = '--version'
 
-	def __init__(self, cfg, opts=None, flags=None):
+	def __init__(self, cfg, *, opts=None, flags=None):
 
 		self.cfg = cfg
 		self.platform = sys.platform
@@ -78,7 +78,7 @@ class Daemon(Lockable):
 		p = Popen(cmd, creationflags=CREATE_NEW_CONSOLE, startupinfo=si)
 		p.wait()
 
-	def exec_cmd(self, cmd, is_daemon=False, check_retcode=False):
+	def exec_cmd(self, cmd, *, is_daemon=False, check_retcode=False):
 		out = (PIPE, None)[is_daemon and self.opt.no_daemonize]
 		try:
 			cp = run(cmd, check=False, stdout=out, stderr=out)
@@ -91,7 +91,7 @@ class Daemon(Lockable):
 			print(cp)
 		return cp
 
-	def run_cmd(self, cmd, silent=False, is_daemon=False, check_retcode=False):
+	def run_cmd(self, cmd, *, silent=False, is_daemon=False, check_retcode=False):
 
 		if self.debug:
 			msg('\n\n')
@@ -105,7 +105,7 @@ class Daemon(Lockable):
 		if self.use_threads and is_daemon and not self.opt.no_daemonize:
 			ret = self.exec_cmd_thread(cmd)
 		else:
-			ret = self.exec_cmd(cmd, is_daemon, check_retcode)
+			ret = self.exec_cmd(cmd, is_daemon=is_daemon, check_retcode=check_retcode)
 
 		if isinstance(ret, CompletedProcess):
 			if ret.stdout and (self.debug or not silent):
@@ -166,7 +166,7 @@ class Daemon(Lockable):
 	def cli(self, *cmds, silent=False):
 		return self.run_cmd(self.cli_cmd(*cmds), silent=silent)
 
-	def state_msg(self, extra_text=None):
+	def state_msg(self, *, extra_text=None):
 		try:
 			pid = self.pid
 		except:
@@ -181,7 +181,7 @@ class Daemon(Lockable):
 	def pre_start(self):
 		pass
 
-	def start(self, quiet=False, silent=False):
+	def start(self, *, quiet=False, silent=False):
 		if self.state == 'ready':
 			if not (quiet or silent):
 				msg(self.state_msg(extra_text='already'))
@@ -200,7 +200,7 @@ class Daemon(Lockable):
 
 		return ret
 
-	def stop(self, quiet=False, silent=False):
+	def stop(self, *, quiet=False, silent=False):
 		if self.state == 'ready':
 			if not silent:
 				msg(f'Stopping {self.desc} on port {self.bind_port}')
@@ -221,11 +221,11 @@ class Daemon(Lockable):
 				msg(f'{self.desc} on port {self.bind_port} not running')
 			return True
 
-	def restart(self, silent=False):
+	def restart(self, *, silent=False):
 		self.stop(silent=silent)
 		return self.start(silent=silent)
 
-	def test_socket(self, host, port, timeout=10):
+	def test_socket(self, host, port, *, timeout=10):
 		import socket
 		try:
 			socket.create_connection((host, port), timeout=timeout).close()
@@ -258,7 +258,7 @@ class RPCDaemon(Daemon):
 
 	avail_opts = ('no_daemonize',)
 
-	def __init__(self, cfg, opts=None, flags=None):
+	def __init__(self, cfg, *, opts=None, flags=None):
 		super().__init__(cfg, opts=opts, flags=flags)
 		self.desc = '{} {} {}RPC daemon'.format(
 			self.rpc_type,
@@ -317,7 +317,7 @@ class CoinDaemon(Daemon):
 		return ret
 
 	@classmethod
-	def get_daemon(cls, cfg, coin, daemon_id, proto=None):
+	def get_daemon(cls, cfg, coin, daemon_id, *, proto=None):
 		if proto:
 			proto_cls = type(proto)
 		else:
@@ -339,6 +339,7 @@ class CoinDaemon(Daemon):
 
 	def __new__(cls,
 			cfg,
+			*,
 			network_id = None,
 			proto      = None,
 			opts       = None,
@@ -366,7 +367,11 @@ class CoinDaemon(Daemon):
 		daemon_ids = cls.get_daemon_ids(cfg, coin)
 		if not daemon_ids:
 			die(1, f'No configured daemons for coin {coin}!')
-		daemon_id = daemon_id or cfg.daemon_id or daemon_ids[0]
+		daemon_id = (
+			daemon_id
+			or getattr(cfg, f'{coin.lower()}_daemon_id', None)
+			or cfg.daemon_id
+			or daemon_ids[0])
 
 		if daemon_id not in daemon_ids:
 			die(1, f'{daemon_id!r}: invalid daemon_id - valid choices: {fmt_list(daemon_ids)}')
@@ -384,6 +389,7 @@ class CoinDaemon(Daemon):
 
 	def __init__(self,
 			cfg,
+			*,
 			network_id = None,
 			proto      = None,
 			opts       = None,

+ 4 - 0
mmgen/data/mmgen.cfg

@@ -77,6 +77,10 @@
 # setups with unusually large Monero wallets:
 # macos_autosign_ramdisk_size 10
 
+# Specify the daemon ID.  This option also has coin-specific variants (see
+# below):
+# daemon_id bitcoin_core
+
 # Ignore coin daemon version. This option also has coin-specific variants
 # (see below):
 # ignore_daemon_version false

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-15.1.dev19
+15.1.dev20

+ 1 - 1
mmgen/devtools.py

@@ -131,7 +131,7 @@ class MMGenObjectMethods: # mixin class for MMGenObject
 		def isScalar(obj):
 			return isinstance(obj, scalars)
 
-		def do_list(out, e, lvl=0, is_dict=False):
+		def do_list(out, e, *, lvl=0, is_dict=False):
 			out.append('\n')
 			for i in e:
 				el = i if not is_dict else e[i]

+ 8 - 8
mmgen/filename.py

@@ -25,7 +25,7 @@ from .util import die, get_extension
 
 class File:
 
-	def __init__(self, fn, write=False):
+	def __init__(self, fn, *, write=False):
 
 		self.name     = fn
 		self.dirname  = os.path.dirname(fn)
@@ -66,21 +66,21 @@ class File:
 
 class FileList(list):
 
-	def __init__(self, fns, write=False):
+	def __init__(self, fns, *, write=False):
 		list.__init__(
 			self,
-			[File(fn, write) for fn in fns])
+			[File(fn, write=write) for fn in fns])
 
 	def names(self):
 		return [f.name for f in self]
 
-	def sort_by_age(self, key='mtime', reverse=False):
+	def sort_by_age(self, *, key='mtime', reverse=False):
 		assert key in ('atime', 'ctime', 'mtime'), f'{key!r}: invalid sort key'
 		self.sort(key=lambda a: getattr(a, key), reverse=reverse)
 
 class MMGenFile(File):
 
-	def __init__(self, fn, base_class=None, subclass=None, proto=None, write=False):
+	def __init__(self, fn, *, base_class=None, subclass=None, proto=None, write=False):
 		"""
 		'base_class' - a base class with an 'ext_to_cls' method
 		'subclass'   - a subclass with an 'ext' attribute
@@ -91,7 +91,7 @@ class MMGenFile(File):
 		attribute to True.
 		"""
 
-		super().__init__(fn, write)
+		super().__init__(fn, write=write)
 
 		assert (subclass or base_class) and not (subclass and base_class), 'MMGenFile chk1'
 
@@ -107,12 +107,12 @@ class MMGenFile(File):
 
 class MMGenFileList(FileList):
 
-	def __init__(self, fns, base_class, proto=None, write=False):
+	def __init__(self, fns, base_class, *, proto=None, write=False):
 		list.__init__(
 			self,
 			[MMGenFile(fn, base_class=base_class, proto=proto, write=write) for fn in fns])
 
-def find_files_in_dir(subclass, fdir, no_dups=False):
+def find_files_in_dir(subclass, fdir, *, no_dups=False):
 
 	assert isinstance(subclass, type), f'{subclass}: not a class'
 

+ 15 - 8
mmgen/fileutil.py

@@ -57,7 +57,7 @@ def check_binary(args):
 		die(2, f'{args[0]!r} binary missing, not in path, or not executable')
 	set_vt100()
 
-def shred_file(fn, verbose=False):
+def shred_file(fn, *, verbose=False):
 	check_binary(['shred', '--version'])
 	from subprocess import run
 	run(
@@ -67,7 +67,7 @@ def shred_file(fn, verbose=False):
 		check=True)
 	set_vt100()
 
-def _check_file_type_and_access(fname, ftype, blkdev_ok=False):
+def _check_file_type_and_access(fname, ftype, *, blkdev_ok=False):
 
 	import stat
 
@@ -103,16 +103,20 @@ def _check_file_type_and_access(fname, ftype, blkdev_ok=False):
 
 	return True
 
-def check_infile(f, blkdev_ok=False):
+def check_infile(f, *, blkdev_ok=False):
 	return _check_file_type_and_access(f, 'input file', blkdev_ok=blkdev_ok)
 
-def check_outfile(f, blkdev_ok=False):
+def check_outfile(f, *, blkdev_ok=False):
 	return _check_file_type_and_access(f, 'output file', blkdev_ok=blkdev_ok)
 
+def check_outfile_dir(fn, *, blkdev_ok=False):
+	return _check_file_type_and_access(
+		os.path.dirname(os.path.abspath(fn)), 'output directory', blkdev_ok=blkdev_ok)
+
 def check_outdir(f):
 	return _check_file_type_and_access(f, 'output directory')
 
-def get_seed_file(cfg, nargs, wallets=None, invoked_as=None):
+def get_seed_file(cfg, *, nargs, wallets=None, invoked_as=None):
 
 	wallets = wallets or cfg._args
 
@@ -137,7 +141,7 @@ def get_seed_file(cfg, nargs, wallets=None, invoked_as=None):
 
 	return str(wallets[0]) if wallets else (wf, None)[wd_from_opt] # could be a Path instance
 
-def _open_or_die(filename, mode, silent=False):
+def _open_or_die(filename, mode, *, silent=False):
 	try:
 		return open(filename, mode)
 	except:
@@ -152,6 +156,7 @@ def write_data_to_file(
 		cfg,
 		outfile,
 		data,
+		*,
 		desc                  = 'data',
 		ask_write             = False,
 		ask_write_prompt      = '',
@@ -279,7 +284,7 @@ def write_data_to_file(
 	else:
 		do_file(outfile, ask_write_prompt)
 
-def get_words_from_file(cfg, infile, desc, quiet=False):
+def get_words_from_file(cfg, infile, *, desc, quiet=False):
 
 	if not quiet:
 		cfg._util.qmsg(f'Getting {desc} from file ‘{infile}’')
@@ -299,6 +304,7 @@ def get_words_from_file(cfg, infile, desc, quiet=False):
 def get_data_from_file(
 		cfg,
 		infile,
+		*,
 		desc   = 'data',
 		dash   = False,
 		silent = False,
@@ -326,6 +332,7 @@ def get_data_from_file(
 def get_lines_from_file(
 		cfg,
 		fn,
+		*,
 		desc          = 'data',
 		trim_comments = False,
 		quiet         = False,
@@ -338,7 +345,7 @@ def get_lines_from_file(
 		if have_enc_ext or not is_utf8(data):
 			m = ('Attempting to decrypt', 'Decrypting')[have_enc_ext]
 			cfg._util.qmsg(f'{m} {desc} ‘{fn}’')
-			data = Crypto(cfg).mmgen_decrypt_retry(data, desc)
+			data = Crypto(cfg).mmgen_decrypt_retry(data, desc=desc)
 		return data
 
 	lines = decrypt_file_maybe().decode().splitlines()

+ 9 - 0
mmgen/help/__init__.py

@@ -33,6 +33,15 @@ def version(cfg):
 	""", indent='  ').rstrip())
 	sys.exit(0)
 
+def list_daemon_ids(cfg):
+	from ..daemon import CoinDaemon
+	from ..util import msg, fmt_list
+	msg('  {} {}'.format('Coin', 'Daemon IDs'))
+	msg('  {} {}'.format('----', '----------'))
+	for k, v in CoinDaemon.coins.items():
+		msg('  {}  {}'.format(k, fmt_list(v.daemon_ids, fmt='barest')))
+	sys.exit(0)
+
 def show_hash_presets(cfg):
 	fs = '      {:<6} {:<3} {:<2} {}'
 	from ..util import msg

+ 5 - 1
mmgen/help/help_notes.py

@@ -30,7 +30,7 @@ class help_notes:
 	def account_info_desc(self):
 		return 'unspent outputs' if self.proto.base_proto == 'Bitcoin' else 'account info'
 
-	def fee_spec_letters(self, use_quotes=False):
+	def fee_spec_letters(self, *, use_quotes=False):
 		cu = self.proto.coin_amt.units
 		sep, conj = ((',', ' or '), ("','", "' or '"))[use_quotes]
 		return sep.join(u[0] for u in cu[:-1]) + ('', conj)[len(cu)>1] + cu[-1][0]
@@ -106,6 +106,10 @@ FMT CODES:
 		from ..util import fmt_list
 		return fmt_list(CoinDaemon.get_network_ids(self.cfg), fmt='bare')
 
+	def tx_proxies(self):
+		from ..util import fmt_list
+		return fmt_list(self.cfg._autoset_opts['tx_proxy'].choices, fmt='fancy')
+
 	def rel_fee_desc(self):
 		from ..tx import BaseTX
 		return BaseTX(cfg=self.cfg, proto=self.proto).rel_fee_desc

+ 1 - 1
mmgen/help/txsign.py

@@ -19,7 +19,7 @@ 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 CoinDaemon(cfg, network_id=proto.coin).exec_fn if proto.coin in CoinDaemon.coins else 'bitcoind'
 
 	return """
 Transactions may contain both {pnm} or non-{pnm} input addresses.

+ 2 - 2
mmgen/key.py

@@ -69,7 +69,7 @@ class PrivKey(bytes, InitErrors, MMGenObject):
 	wif        = ImmutableAttr(WifKey, typeconv=False)
 
 	# initialize with (priv_bin, compressed), WIF or self
-	def __new__(cls, proto, s=None, compressed=None, wif=None, pubkey_type=None):
+	def __new__(cls, proto, s=None, *, compressed=None, wif=None, pubkey_type=None):
 		if isinstance(s, cls):
 			return s
 		if wif:
@@ -103,7 +103,7 @@ class PrivKey(bytes, InitErrors, MMGenObject):
 					assert type(compressed) is bool, (
 						f"'compressed' must be of type bool, not {type(compressed).__name__}")
 					me = bytes.__new__(cls, proto.preprocess_key(s, pubkey_type))
-					me.wif = WifKey(proto, proto.encode_wif(me, pubkey_type, compressed))
+					me.wif = WifKey(proto, proto.encode_wif(me, pubkey_type, compressed=compressed))
 					me.compressed = compressed
 				me.pubkey_type = pubkey_type
 				me.orig_bytes = s # save the non-preprocessed key

+ 3 - 3
mmgen/keygen.py

@@ -51,7 +51,7 @@ class keygen_base:
 		return None
 
 	@classmethod
-	def get_clsname(cls, cfg, silent=False):
+	def get_clsname(cls, cfg, *, silent=False):
 		return cls.__name__
 
 backend_data = {
@@ -78,7 +78,7 @@ def get_pubkey_type_cls(pubkey_type):
 		importlib.import_module(f'mmgen.proto.{backend_data[pubkey_type]["package"]}.keygen'),
 		'backend')
 
-def _check_backend(cfg, backend, pubkey_type, desc='keygen backend'):
+def _check_backend(cfg, backend, pubkey_type, *, desc='keygen backend'):
 
 	from .util import is_int, die
 
@@ -104,7 +104,7 @@ def check_backend(cfg, proto, backend, addr_type):
 
 	return  _check_backend(cfg, backend, pubkey_type, desc='--keygen-backend parameter')
 
-def KeyGenerator(cfg, proto, pubkey_type, backend=None, silent=False):
+def KeyGenerator(cfg, proto, pubkey_type, *, backend=None, silent=False):
 	"""
 	factory function returning a key generator backend for the specified pubkey type
 	"""

+ 10 - 3
mmgen/led.py

@@ -33,7 +33,14 @@ class LEDControl:
 	class binfo(Lockable):
 		_reset_ok = ('trigger_reset',)
 
-		def __init__(self, name, control, trigger=None, trigger_dfl='heartbeat', trigger_disable='none'):
+		def __init__(
+				self,
+				*,
+				name,
+				control,
+				trigger         = None,
+				trigger_dfl     = 'heartbeat',
+				trigger_disable = 'none'):
 			self.name = name
 			self.control = control
 			self.trigger = trigger
@@ -71,7 +78,7 @@ class LEDControl:
 			trigger = '/tmp/led_trigger'),
 	}
 
-	def __init__(self, enabled, simulate=False, debug=False):
+	def __init__(self, *, enabled, simulate=False, debug=False):
 
 		self.enabled = enabled
 		self.debug = debug or simulate
@@ -119,7 +126,7 @@ class LEDControl:
 				))
 				sys.exit(1)
 
-		def init_state(fn, desc, init_val=None):
+		def init_state(fn, *, desc, init_val=None):
 			try:
 				write_init_val(fn, init_val)
 			except PermissionError:

+ 2 - 2
mmgen/main_addrgen.py

@@ -141,12 +141,12 @@ if cfg.keygen_backend:
 idxs = addrlist.AddrIdxList(fmt_str=cfg._args.pop())
 
 from .fileutil import get_seed_file
-sf = get_seed_file(cfg, 1)
+sf = get_seed_file(cfg, nargs=1)
 
 from .ui import do_license_msg
 do_license_msg(cfg)
 
-ss = Wallet(cfg, sf)
+ss = Wallet(cfg, fn=sf)
 
 ss_seed = ss.seed if cfg.subwallet is None else ss.seed.subseed(cfg.subwallet, print_msg=True)
 

+ 2 - 2
mmgen/main_addrimport.py

@@ -84,7 +84,7 @@ addrimport_msgs = {
 def parse_cmd_args(rpc, cmd_args):
 
 	def import_mmgen_list(infile):
-		return (AddrList, KeyAddrList)[bool(cfg.keyaddr_file)](cfg, proto, infile)
+		return (AddrList, KeyAddrList)[bool(cfg.keyaddr_file)](cfg, proto, infile=infile)
 
 	if len(cmd_args) == 1:
 		infile = cmd_args[0]
@@ -97,7 +97,7 @@ def parse_cmd_args(rpc, cmd_args):
 				addrlist = get_lines_from_file(
 					cfg,
 					infile,
-					f'non-{gc.proj_name} addresses',
+					desc = f'non-{gc.proj_name} addresses',
 					trim_comments = True))
 		else:
 			al = import_mmgen_list(infile)

+ 1 - 1
mmgen/main_autosign.py

@@ -211,7 +211,7 @@ if cmd not in ('sign', 'wait'):
 		if getattr(cfg, opt):
 			die(1, f'--{opt.replace("_", "-")} makes no sense for the ‘{cmd}’ operation')
 
-asi = Autosign(cfg, cmd)
+asi = Autosign(cfg, cmd=cmd)
 
 cfg._post_init()
 

+ 5 - 5
mmgen/main_msg.py

@@ -61,13 +61,13 @@ class MsgOps:
 
 	class verify(sign):
 
-		async def __init__(self, msgfile, addr=None):
+		async def __init__(self, msgfile, *, addr=None):
 			try:
 				m = SignedOnlineMsg(cfg, infile=msgfile)
 			except:
 				m = ExportedMsgSigs(cfg, infile=msgfile)
 
-			nSigs = await m.verify(addr)
+			nSigs = await m.verify(addr=addr)
 
 			summary = f'{nSigs} signature{suf(nSigs)} verified'
 
@@ -81,13 +81,13 @@ class MsgOps:
 
 	class export(sign):
 
-		async def __init__(self, msgfile, addr=None):
+		async def __init__(self, msgfile, *, addr=None):
 
 			from .fileutil import write_data_to_file
 			write_data_to_file(
 				cfg     = cfg,
 				outfile = 'signatures.json',
-				data    = SignedOnlineMsg(cfg, infile=msgfile).get_json_for_export(addr),
+				data    = SignedOnlineMsg(cfg, infile=msgfile).get_json_for_export(addr=addr),
 				desc    = 'signature data')
 
 opts_data = {
@@ -223,7 +223,7 @@ async def main():
 	elif op in ('verify', 'export'):
 		if len(cmd_args) not in (1, 2):
 			cfg._usage()
-		await getattr(MsgOps, op)(cmd_args[0], cmd_args[1] if len(cmd_args) == 2 else None)
+		await getattr(MsgOps, op)(cmd_args[0], addr=cmd_args[1] if len(cmd_args) == 2 else None)
 	else:
 		die(1, f'{op!r}: unrecognized operation')
 

+ 2 - 2
mmgen/main_passgen.py

@@ -146,7 +146,7 @@ pw_idxs = AddrIdxList(fmt_str=cfg._args.pop())
 pw_id_str = cfg._args.pop()
 
 from .fileutil import get_seed_file
-sf = get_seed_file(cfg, 1)
+sf = get_seed_file(cfg, nargs=1)
 
 pw_fmt = cfg.passwd_fmt or PasswordList.dfl_pw_fmt
 pw_len = pwi[pw_fmt].dfl_len // 2 if cfg.passwd_len in ('h', 'H') else cfg.passwd_len
@@ -165,7 +165,7 @@ PasswordList(
 from .ui import do_license_msg
 do_license_msg(cfg)
 
-ss = Wallet(cfg, sf)
+ss = Wallet(cfg, fn=sf)
 
 al = PasswordList(
 	cfg       = cfg,

+ 1 - 1
mmgen/main_seedjoin.py

@@ -131,7 +131,7 @@ do_license_msg(cfg)
 cfg._util.qmsg('Input files:\n  {}\n'.format('\n  '.join(cfg._args)))
 
 shares = [Wallet(cfg).seed] if cfg.hidden_incog_input_params else []
-shares += [Wallet(cfg,fn).seed for fn in cfg._args]
+shares += [Wallet(cfg, fn=fn).seed for fn in cfg._args]
 
 if cfg.master_share:
 	share1 = SeedShareMasterJoining(cfg, master_idx, shares[0], id_str, len(shares)).derived_seed

+ 7 - 4
mmgen/main_tool.py

@@ -179,7 +179,7 @@ mods = {
 def get_cmds():
 	return [cmd for mod, cmds in mods.items() if mod != 'help' for cmd in cmds]
 
-def create_call_sig(cmd, cls, as_string=False):
+def create_call_sig(cmd, cls, *, as_string=False):
 
 	m = getattr(cls, cmd)
 
@@ -189,8 +189,11 @@ def create_call_sig(cmd, cls, as_string=False):
 		args, dfls, ann = va['args'], va['dfls'], va['annots']
 	else:
 		flag = None
-		args = m.__code__.co_varnames[1:m.__code__.co_argcount]
-		dfls = m.__defaults__ or ()
+		c = m.__code__
+		args = c.co_varnames[1:c.co_argcount + c.co_posonlyargcount + c.co_kwonlyargcount]
+		dfls = (
+			(m.__defaults__ or ()) +
+			tuple(m.__kwdefaults__[k] for k in args if k in (m.__kwdefaults__ or ())))
 		ann  = m.__annotations__
 
 	nargs = len(args) - len(dfls)
@@ -295,7 +298,7 @@ def process_args(cmd, cmd_args, cls):
 
 	return (args, kwargs)
 
-def process_result(ret, pager=False, print_result=False):
+def process_result(ret, *, pager=False, print_result=False):
 	"""
 	Convert result to something suitable for output to screen and return it.
 	If result is bytes and not convertible to utf8, output as binary using os.write().

+ 84 - 26
mmgen/main_txsend.py

@@ -34,22 +34,36 @@ opts_data = {
 		'desc':    f'Send a signed {gc.proj_name} cryptocoin transaction',
 		'usage':   '[opts] [signed transaction file]',
 		'options': """
--h, --help      Print this help message
---, --longhelp  Print help message for long (global) options
--a, --autosign  Send an autosigned transaction created by ‘mmgen-txcreate
-                --autosign’.  The removable device is mounted and unmounted
-                automatically. The transaction file argument must be omitted
-                when using this option
--A, --abort     Abort an unsent transaction created by ‘mmgen-txcreate
-                --autosign’ and delete it from the removable device.  The
-                transaction may be signed or unsigned.
--d, --outdir= d Specify an alternate directory 'd' for output
--q, --quiet     Suppress warnings; overwrite files without prompting
--s, --status    Get status of a sent transaction (or the current transaction,
-                whether sent or unsent, when used with --autosign)
--v, --verbose   Be more verbose
--y, --yes       Answer 'yes' to prompts, suppress non-essential output
+-h, --help       Print this help message
+--, --longhelp   Print help message for long (global) options
+-a, --autosign   Send an autosigned transaction created by ‘mmgen-txcreate
+                 --autosign’.  The removable device is mounted and unmounted
+                 automatically. The transaction file argument must be omitted
+                 when using this option
+-A, --abort      Abort an unsent transaction created by ‘mmgen-txcreate
+                 --autosign’ and delete it from the removable device.  The
+                 transaction may be signed or unsigned.
+-d, --outdir=d   Specify an alternate directory 'd' for output
+-H, --dump-hex=F Instead of sending to the network, dump the transaction hex
+                 to file ‘F’.  Use filename ‘-’ to dump to standard output.
+-m, --mark-sent  Mark the transaction as sent by adding it to the removable
+                 device.  Used in combination with --autosign when a trans-
+                 action has been successfully sent out-of-band.
+-n, --tx-proxy=P Send transaction via public TX proxy ‘P’ (supported proxies:
+                 {tx_proxies}).  This is done via a publicly accessible web
+                 page, so no API key or registration is required
+-q, --quiet      Suppress warnings; overwrite files without prompting
+-s, --status     Get status of a sent transaction (or current transaction,
+                 whether sent or unsent, when used with --autosign)
+-t, --test       Test whether the transaction can be sent without sending it
+-v, --verbose    Be more verbose
+-x, --proxy=P    Connect to TX proxy via SOCKS5 proxy ‘P’ (host:port)
+-y, --yes        Answer 'yes' to prompts, suppress non-essential output
 """
+	},
+	'code': {
+		'options': lambda cfg, proto, help_notes, s: s.format(
+			tx_proxies = help_notes('tx_proxies'))
 	}
 }
 
@@ -58,6 +72,20 @@ cfg = Config(opts_data=opts_data)
 if cfg.autosign and cfg.outdir:
 	die(1, '--outdir cannot be used in combination with --autosign')
 
+if cfg.mark_sent and not cfg.autosign:
+	die(1, '--mark-sent is used only in combination with --autosign')
+
+if cfg.test and cfg.dump_hex:
+	die(1, '--test cannot be used in combination with --dump-hex')
+
+if cfg.tx_proxy:
+	from .tx.tx_proxy import check_client
+	check_client(cfg)
+
+if cfg.dump_hex and cfg.dump_hex != '-':
+	from .fileutil import check_outfile_dir
+	check_outfile_dir(cfg.dump_hex)
+
 if len(cfg._args) == 1:
 	infile = cfg._args[0]
 	from .fileutil import check_infile
@@ -84,9 +112,18 @@ if not cfg.status:
 	from .ui import do_license_msg
 	do_license_msg(cfg)
 
-async def main():
+from .tx import OnlineSignedTX, SentTX
+from .ui import keypress_confirm
+
+async def post_send(tx):
+	tx2 = await SentTX(cfg=cfg, data=tx.__dict__, automount=cfg.autosign)
+	tx2.file.write(
+		outdir        = asi.txauto_dir if cfg.autosign else None,
+		ask_overwrite = False,
+		ask_write     = False)
+	tx2.post_write()
 
-	from .tx import OnlineSignedTX, SentTX
+async def main():
 
 	if cfg.status and cfg.autosign:
 		tx = await si.get_last_created()
@@ -102,6 +139,10 @@ async def main():
 
 	cfg._util.vmsg(f'Getting {tx.desc} ‘{tx.infile}’')
 
+	if cfg.mark_sent:
+		await post_send(tx)
+		sys.exit(0)
+
 	if cfg.status:
 		if tx.coin_txid:
 			cfg._util.qmsg(f'{tx.proto.coin} txid: {tx.coin_txid.hl()}')
@@ -110,8 +151,8 @@ async def main():
 			tx.info.view_with_prompt('View transaction details?', pause=False)
 		sys.exit(retval)
 
-	if tx.is_swap:
-		tx.check_swap_expiry()
+	if tx.is_swap and not tx.check_swap_expiry():
+		die(1, 'Swap quote has expired. Please re-create the transaction')
 
 	if not cfg.yes:
 		tx.info.view_with_prompt('View transaction details?')
@@ -119,12 +160,29 @@ async def main():
 			if not cfg.autosign:
 				tx.file.write(ask_write_default_yes=True)
 
-	if await tx.send():
-		tx2 = await SentTX(cfg=cfg, data=tx.__dict__, automount=cfg.autosign)
-		tx2.file.write(
-			outdir        = asi.txauto_dir if cfg.autosign else None,
-			ask_overwrite = False,
-			ask_write     = False)
-		tx2.post_write()
+	if cfg.dump_hex:
+		from .fileutil import write_data_to_file
+		write_data_to_file(
+				cfg,
+				cfg.dump_hex,
+				tx.serialized + '\n',
+				desc = 'serialized transaction hex data',
+				ask_overwrite = False,
+				ask_tty = False)
+		if cfg.autosign:
+			if keypress_confirm(cfg, 'Mark transaction as sent on removable device?'):
+				await post_send(tx)
+		else:
+			await post_send(tx)
+	elif cfg.tx_proxy:
+		from .tx.tx_proxy import send_tx
+		if send_tx(cfg, tx):
+			if (not cfg.autosign or
+				keypress_confirm(cfg, 'Mark transaction as sent on removable device?')):
+				await post_send(tx)
+	elif cfg.test:
+		await tx.test_sendable()
+	elif await tx.send():
+		await post_send(tx)
 
 async_run(main())

+ 1 - 1
mmgen/main_txsign.py

@@ -151,7 +151,7 @@ async def main():
 		kal = get_keyaddrlist(cfg, tx1.proto)
 		kl = get_keylist(cfg)
 
-		tx2 = await txsign(cfg, tx1, seed_files, kl, kal, tx_num_disp)
+		tx2 = await txsign(cfg, tx1, seed_files, kl, kal, tx_num_str=tx_num_disp)
 		if tx2:
 			if not cfg.yes:
 				tx2.add_comment() # edits an existing comment

+ 2 - 2
mmgen/main_wallet.py

@@ -179,7 +179,7 @@ if cmd_args:
 		cfg._usage()
 	check_infile(cmd_args[0])
 
-sf = get_seed_file(cfg, nargs, invoked_as=invoked_as)
+sf = get_seed_file(cfg, nargs=nargs, invoked_as=invoked_as)
 
 if invoked_as != 'chk':
 	from .ui import do_license_msg
@@ -212,7 +212,7 @@ if invoked_as == 'subgen':
 		cfg      = cfg,
 		seed_bin = ss_in.seed.subseed(ss_idx, print_msg=True).data)
 elif invoked_as == 'seedsplit':
-	shares = ss_in.seed.split(sss.count, sss.id, master_share)
+	shares = ss_in.seed.split(sss.count, id_str=sss.id, master_idx=master_share)
 	seed_out = shares.get_share_by_idx(sss.idx, base_seed=True)
 	msg(seed_out.get_desc(ui=True))
 	ss_out = Wallet(

+ 1 - 1
mmgen/main_xmrwallet.py

@@ -143,7 +143,7 @@ elif op in ('export-outputs', 'export-outputs-sign', 'import-key-images'):
 else:
 	die(1, f'{op!r}: unrecognized operation')
 
-m = xmrwallet.op(op, cfg, infile, wallets, spec)
+m = xmrwallet.op(op, cfg, infile, wallets, spec=spec)
 
 if asyncio.run(m.main()):
 	m.post_main_success()

+ 4 - 4
mmgen/mn_entry.py

@@ -261,7 +261,7 @@ class MnemonicEntry:
 			self._usl = usl
 		return self._usl
 
-	def idx(self, w, entry_mode, lo_idx=None, hi_idx=None):
+	def idx(self, w, entry_mode, *, lo_idx=None, hi_idx=None):
 		"""
 		Return values:
 		  - all modes:
@@ -306,7 +306,7 @@ class MnemonicEntry:
 			msg('  {}) {:8} {}'.format(
 				n,
 				mode.name + ':',
-				fmt(mode.choose_info, ' '*14).lstrip().format(usl=self.uniq_ss_len),
+				fmt(mode.choose_info, indent=' '*14).lstrip().format(usl=self.uniq_ss_len),
 			))
 		prompt = f'Type a number, or hit ENTER for the default ({capfirst(self.dfl_entry_mode)}): '
 		erase = '\r' + ' ' * (len(prompt)+19) + '\r'
@@ -323,7 +323,7 @@ class MnemonicEntry:
 				time.sleep(self.cfg.err_disp_timeout)
 				msg_r(erase)
 
-	def get_mnemonic_from_user(self, mn_len, validate=True):
+	def get_mnemonic_from_user(self, mn_len, *, validate=True):
 		mll = list(self.bconv.seedlen_map_rev)
 		assert mn_len in mll, f'{mn_len}: invalid mnemonic length (must be one of {mll})'
 
@@ -418,7 +418,7 @@ class MnemonicEntryMonero(MnemonicEntry):
 	dfl_entry_mode = 'short'
 	has_chksum = True
 
-def mn_entry(cfg, wl_id, entry_mode=None):
+def mn_entry(cfg, wl_id, *, entry_mode=None):
 	if wl_id == 'words':
 		wl_id = 'mmgen'
 	me = MnemonicEntry.get_cls_by_wordlist(wl_id)(cfg)

+ 5 - 5
mmgen/msg.py

@@ -38,7 +38,7 @@ class MMGenIDRange(HiliteStr, InitErrors, MMGenObject):
 			t = proto.addr_type((ss[1], proto.dfl_mmtype)[len(ss)==2])
 			me = str.__new__(cls, '{}:{}:{}'.format(ss[0], t, ss[-1]))
 			me.sid = SeedID(sid=ss[0])
-			me.idxlist = AddrIdxList(ss[-1])
+			me.idxlist = AddrIdxList(fmt_str=ss[-1])
 			me.mmtype = t
 			assert t in proto.mmtypes, f'{t}: invalid address type for {proto.cls_name}'
 			me.al_id = str.__new__(AddrListID, me.sid+':'+me.mmtype) # checks already done
@@ -91,7 +91,7 @@ class coin_msg:
 			coin, network = network_id.split('_')
 			return init_proto(cfg=cfg, coin=coin, network=network)
 
-		def write_to_file(self, outdir=None, ask_overwrite=False):
+		def write_to_file(self, *, outdir=None, ask_overwrite=False):
 			data = {
 				'id': f'{gc.proj_name} {self.desc}',
 				'metadata': self.data,
@@ -208,7 +208,7 @@ class coin_msg:
 
 	class unsigned(completed):
 
-		async def sign(self, wallet_files, passwd_file=None):
+		async def sign(self, wallet_files, *, passwd_file=None):
 
 			from .addrlist import KeyAddrList
 
@@ -300,7 +300,7 @@ class coin_msg:
 
 			return sigs
 
-		async def verify(self, addr=None):
+		async def verify(self, *, addr=None):
 
 			sigs = self.get_sigs(addr)
 
@@ -319,7 +319,7 @@ class coin_msg:
 
 			return len(sigs)
 
-		def get_json_for_export(self, addr=None):
+		def get_json_for_export(self, *, addr=None):
 			sigs = list(self.get_sigs(addr).values())
 			pfx = self.msg_cls.sigdata_pfx
 			if pfx:

+ 12 - 9
mmgen/obj.py

@@ -102,7 +102,7 @@ class ImmutableAttr: # Descriptor
 	"""
 	ok_dtypes = (type, type(None), type(lambda:0))
 
-	def __init__(self, dtype, typeconv=True, set_none_ok=False, include_proto=False):
+	def __init__(self, dtype, *, typeconv=True, set_none_ok=False, include_proto=False):
 		self.set_none_ok = set_none_ok
 		self.typeconv = typeconv
 
@@ -154,7 +154,7 @@ class ListItemAttr(ImmutableAttr):
 	For attributes that might not be present in the data instance
 	Reassignment or deletion allowed if specified
 	"""
-	def __init__(self, dtype, typeconv=True, include_proto=False, reassign_ok=False, delete_ok=False):
+	def __init__(self, dtype, *, typeconv=True, include_proto=False, reassign_ok=False, delete_ok=False):
 		self.reassign_ok = reassign_ok
 		self.delete_ok = delete_ok
 		ImmutableAttr.__init__(self, dtype, typeconv=typeconv, include_proto=include_proto)
@@ -264,7 +264,7 @@ class Int(int, Hilite, InitErrors):
 	max_digits = None
 	color = 'red'
 
-	def __new__(cls, n, base=10):
+	def __new__(cls, n, *, base=10):
 		if isinstance(n, cls):
 			return n
 		try:
@@ -280,11 +280,11 @@ class Int(int, Hilite, InitErrors):
 			return cls.init_fail(e, n)
 
 	@classmethod
-	def fmtc(cls, s, width, color=False):
-		return super().fmtc(str(s), width=width, color=color)
+	def fmtc(cls, s, width, /, *, color=False):
+		return super().fmtc(str(s), width, color=color)
 
-	def fmt(self, width, color=False):
-		return super().fmtc(str(self), width=width, color=color)
+	def fmt(self, width, /, *, color=False):
+		return super().fmtc(str(self), width, color=color)
 
 	def hl(self, **kwargs):
 		return super().colorize(str(self), **kwargs)
@@ -306,7 +306,7 @@ class HexStr(HiliteStr, InitErrors):
 	width = None
 	hexcase = 'lower'
 	trunc_ok = False
-	def __new__(cls, s, case=None):
+	def __new__(cls, s, *, case=None):
 		if isinstance(s, cls):
 			return s
 		if case is None:
@@ -324,7 +324,7 @@ class HexStr(HiliteStr, InitErrors):
 		except Exception as e:
 			return cls.init_fail(e, s)
 
-	def truncate(self, width, color=True):
+	def truncate(self, width, /, *, color=True):
 		return self.colorize(
 			self if width >= self.width else self[:width-2] + '..',
 			color = color)
@@ -332,6 +332,9 @@ class HexStr(HiliteStr, InitErrors):
 class CoinTxID(HexStr):
 	color, width, hexcase = ('purple', 64, 'lower')
 
+def is_coin_txid(s):
+	return get_obj(CoinTxID, s=s, silent=True, return_bool=True)
+
 class WalletPassword(HexStr):
 	color, width, hexcase = ('blue', 32, 'lower')
 

+ 10 - 8
mmgen/objmethods.py

@@ -49,7 +49,7 @@ class Hilite:
 
 	# class method equivalent of fmt()
 	@classmethod
-	def fmtc(cls, s, width, color=False):
+	def fmtc(cls, s, width, /, *, color=False):
 		if len(s) > width:
 			assert cls.trunc_ok, "If 'trunc_ok' is false, 'width' must be >= width of string"
 			return cls.colorize(s[:width].ljust(width), color=color)
@@ -57,21 +57,21 @@ class Hilite:
 			return cls.colorize(s.ljust(width), color=color)
 
 	@classmethod
-	def hlc(cls, s, color=True):
+	def hlc(cls, s, *, color=True):
 		return getattr(color_mod, cls.color)(s) if color else s
 
 	@classmethod
-	def colorize(cls, s, color=True):
+	def colorize(cls, s, *, color=True):
 		return getattr(color_mod, cls.color)(s) if color else s
 
 	@classmethod
-	def colorize2(cls, s, color=True, color_override=''):
+	def colorize2(cls, s, *, color=True, color_override=''):
 		return getattr(color_mod, color_override or cls.color)(s) if color else s
 
 class HiliteStr(str, Hilite):
 
 	# supports single-width characters only
-	def fmt(self, width, color=False):
+	def fmt(self, width, /, *, color=False):
 		if len(self) > width:
 			assert self.trunc_ok, "If 'trunc_ok' is false, 'width' must be >= width of string"
 			return self.colorize(self[:width].ljust(width), color=color)
@@ -82,6 +82,8 @@ class HiliteStr(str, Hilite):
 	def fmt2(
 			self,
 			width,                  # screen width - must be at least 2 (one wide char)
+			/,
+			*,
 			color          = False,
 			encl           = '',    # if set, must be exactly 2 single-width chars
 			nullrepl       = '',
@@ -112,12 +114,12 @@ class HiliteStr(str, Hilite):
 		else:
 			return self.colorize2(s.ljust(width-s_wide_count), color=color, color_override=color_override)
 
-	def hl(self, color=True):
+	def hl(self, *, color=True):
 		return getattr(color_mod, self.color)(self) if color else self
 
 	# an alternative to hl(), with enclosure and color override
 	# can be called as an unbound method with class as first argument
-	def hl2(self, s=None, color=True, encl='', color_override=''):
+	def hl2(self, s=None, *, color=True, encl='', color_override=''):
 		if encl:
 			return self.colorize2(encl[0]+(s or self)+encl[1], color=color, color_override=color_override)
 		else:
@@ -126,7 +128,7 @@ class HiliteStr(str, Hilite):
 class InitErrors:
 
 	@classmethod
-	def init_fail(cls, e, m, e2=None, m2=None, objname=None, preformat=False):
+	def init_fail(cls, e, m, *, e2=None, m2=None, objname=None, preformat=False):
 
 		def get_errmsg():
 			ret = m if preformat else (

+ 7 - 3
mmgen/opts.py

@@ -160,7 +160,7 @@ global_opts_help_pat = re.compile(r'^\t\t\t(.)(.) (?:--([{}a-zA-Z0-9-]{2,64})(=|
 
 opt_tuple = namedtuple('cmdline_option', ['name', 'has_parm'])
 
-def parse_opts(cfg, opts_data, global_opts_data, global_filter_codes, need_proto):
+def parse_opts(cfg, opts_data, global_opts_data, global_filter_codes, *, need_proto):
 
 	def parse_v1():
 		for line in opts_data['text']['options'].strip().splitlines():
@@ -243,6 +243,7 @@ class Opts:
 	def __init__(
 			self,
 			cfg,
+			*,
 			opts_data,
 			init_opts,    # dict containing opts to pre-initialize
 			parsed_opts,
@@ -261,7 +262,7 @@ class Opts:
 			opts_data,
 			self.global_opts_data,
 			self.global_filter_codes,
-			need_proto)
+			need_proto = need_proto)
 
 		cfg._args = po.cmd_args
 		cfg._uopts = uopts = po.user_opts
@@ -292,7 +293,7 @@ class Opts:
 class UserOpts(Opts):
 
 	help_pkg = 'mmgen.help'
-	info_funcs = ('version', 'show_hash_presets')
+	info_funcs = ('version', 'show_hash_presets', 'list_daemon_ids')
 
 	global_opts_data = {
 		#  coin code : cmd code : opt : opt param : text
@@ -312,6 +313,7 @@ class UserOpts(Opts):
 			rr --daemon-data-dir=path Specify coin daemon data directory location
 			Rr --daemon-id=ID         Specify the coin daemon ID
 			rr --ignore-daemon-version Ignore coin daemon version check
+			Rr --list-daemon-ids      List all available daemon IDs
 			rr --http-timeout=t       Set HTTP timeout in seconds for JSON-RPC connections
 			-- --no-license           Suppress the GPL license prompt
 			Rr --rpc-host=HOST        Communicate with coin daemon running on host HOST
@@ -322,6 +324,7 @@ class UserOpts(Opts):
 			Rr --aiohttp-rpc-queue-len=N Use N simultaneous RPC connections with aiohttp
 			-p --regtest=0|1          Disable or enable regtest mode
 			-- --testnet=0|1          Disable or enable testnet
+			-- --test-suite           Use test suite configuration
 			br --tw-name=NAME         Specify alternate name for the BTC/LTC/BCH tracking
 			+                         wallet (default: ‘{tw_name}’)
 			-- --skip-cfg-file        Skip reading the configuration file
@@ -334,6 +337,7 @@ class UserOpts(Opts):
 			rr   For descriptions, refer to the non-prefixed versions of these options above
 			rr   Prefixed options override their non-prefixed counterparts
 			rr   OPTION                            SUPPORTED PREFIXES
+			Rr --PREFIX-daemon-id                btc ltc bch eth etc
 			rr --PREFIX-ignore-daemon-version    btc ltc bch eth etc xmr
 			br --PREFIX-tw-name                  btc ltc bch
 			Rr --PREFIX-rpc-host                 btc ltc bch eth etc

+ 9 - 12
mmgen/passwdlist.py

@@ -69,6 +69,7 @@ class PasswordList(AddrList):
 			self,
 			cfg,
 			proto,
+			*,
 			infile          = None,
 			seed            = None,
 			pw_idxs         = None,
@@ -108,7 +109,7 @@ class PasswordList(AddrList):
 		self.chksum = AddrListChksum(self)
 
 		fs = f'{self.al_id.sid}-{self.pw_id_str}-{self.pw_fmt_disp}-{self.pw_len}[{{}}]'
-		self.id_str = AddrListIDStr(self, fs)
+		self.id_str = AddrListIDStr(self, fmt_str=fs)
 
 		if not skip_chksum_msg:
 			self.do_chksum_msg(record=not infile)
@@ -125,22 +126,18 @@ class PasswordList(AddrList):
 			die('InvalidPasswdFormat',
 				f'{self.pw_fmt!r}: invalid password format.  Valid formats: {", ".join(self.pw_info)}')
 
-	def chk_pw_len(self, passwd=None):
-		if passwd is None:
-			assert self.pw_len, 'either passwd or pw_len must be set'
-			pw_len = self.pw_len
-			fs = '{l}: invalid user-requested length for {b} ({c}{m})'
-		else:
-			pw_len = len(passwd)
-			fs = '{pw}: {b} has invalid length {l} ({c}{m} characters)'
+	def chk_pw_len(self):
+		assert self.pw_len, 'pw_len must be set'
+		pw_len = self.pw_len
+		fs = '{l}: invalid user-requested length for {b} ({c}{m})'
 		d = self.pw_info[self.pw_fmt]
 		if d.valid_lens:
 			if pw_len not in d.valid_lens:
-				die(2, fs.format(l=pw_len, b=d.desc, c='not one of ', m=d.valid_lens, pw=passwd))
+				die(2, fs.format(l=pw_len, b=d.desc, c='not one of ', m=d.valid_lens))
 		elif pw_len > d.max_len:
-			die(2, fs.format(l=pw_len, b=d.desc, c='>', m=d.max_len, pw=passwd))
+			die(2, fs.format(l=pw_len, b=d.desc, c='>', m=d.max_len))
 		elif pw_len < d.min_len:
-			die(2, fs.format(l=pw_len, b=d.desc, c='<', m=d.min_len, pw=passwd))
+			die(2, fs.format(l=pw_len, b=d.desc, c='<', m=d.min_len))
 
 	def set_pw_len(self, pw_len):
 		d = self.pw_info[self.pw_fmt]

+ 3 - 3
mmgen/platform/darwin/util.py

@@ -43,7 +43,7 @@ class MacOSRamDisk:
 	desc = 'ramdisk'
 	min_size = 10 # 10MB is the minimum supported by hdiutil
 
-	def __init__(self, cfg, label, size, path=None):
+	def __init__(self, cfg, label, size, *, path=None):
 		if size < self.min_size:
 			warn_ramdisk_too_small(size, self.min_size)
 			size = self.min_size
@@ -59,7 +59,7 @@ class MacOSRamDisk:
 	def get_diskutil_size(self):
 		return get_device_size(self.label) // (2**20)
 
-	def create(self, quiet=False):
+	def create(self, *, quiet=False):
 		redir = DEVNULL if quiet else None
 		if self.exists():
 			diskutil_size = self.get_diskutil_size()
@@ -81,7 +81,7 @@ class MacOSRamDisk:
 			self.path.mkdir(parents=True, exist_ok=True)
 			run(['diskutil', 'mount', '-mountPoint', str(self.path.absolute()), self.label], stdout=redir, check=True)
 
-	def destroy(self, quiet=False):
+	def destroy(self, *, quiet=False):
 		if not self.exists():
 			self.cfg._util.qmsg(f'{self.desc.capitalize()} {self.label.hl()} at path {self.path} not found')
 			return

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

@@ -25,7 +25,7 @@ class BitcoinTwAddrData(TwAddrData):
 		"""
 	}
 
-	async def get_tw_data(self, twctl=None):
+	async def get_tw_data(self, *, twctl=None):
 		self.cfg._util.vmsg('Getting address data from tracking wallet')
 		c = self.rpc
 		if 'label_api' in c.caps:

+ 2 - 1
mmgen/proto/btc/params.py

@@ -55,6 +55,7 @@ class mainnet(CoinProtocol.Secp256k1): # chainparams.cpp
 	max_op_return_data_len = 80
 
 	coin_cfg_opts = (
+		'daemon_id',
 		'ignore_daemon_version',
 		'rpc_host',
 		'rpc_port',
@@ -65,7 +66,7 @@ class mainnet(CoinProtocol.Secp256k1): # chainparams.cpp
 		'cashaddr',
 	)
 
-	def encode_wif(self, privbytes, pubkey_type, compressed): # input is preprocessed hex
+	def encode_wif(self, privbytes, pubkey_type, *, compressed): # input is preprocessed
 		assert len(privbytes) == self.privkey_len, f'{len(privbytes)} bytes: incorrect private key length!'
 		assert pubkey_type in self.wif_ver_bytes, f'{pubkey_type!r}: invalid pubkey_type'
 		return b58chk_encode(

+ 6 - 6
mmgen/proto/btc/regtest.py

@@ -73,7 +73,7 @@ class MMGenRegtest(MMGenObject):
 		'bch': 'n2fxhNx27GhHAWQhyuZ5REcBNrJqCJsJ12',
 	}
 
-	def __init__(self, cfg, coin, bdb_wallet=False):
+	def __init__(self, cfg, coin, *, bdb_wallet=False):
 		self.cfg = cfg
 		self.coin = coin.lower()
 		self.bdb_wallet = bdb_wallet
@@ -83,7 +83,7 @@ class MMGenRegtest(MMGenObject):
 		self.proto = init_proto(cfg, self.coin, regtest=True, need_amt=True)
 		self.d = CoinDaemon(
 			cfg,
-			self.coin + '_rt',
+			network_id = self.coin + '_rt',
 			test_suite = cfg.test_suite,
 			opts       = ['bdb_wallet'] if bdb_wallet else None)
 
@@ -116,7 +116,7 @@ class MMGenRegtest(MMGenObject):
 		t.addrtype = 'compressed' if self.proto.coin == 'BCH' else 'bech32'
 		return t.hex2wif(self.bdb_hdseed)
 
-	async def generate(self, blocks=1, silent=False):
+	async def generate(self, blocks=1, *, silent=False):
 
 		blocks = int(blocks)
 
@@ -194,11 +194,11 @@ class MMGenRegtest(MMGenObject):
 			msg('Stopping regtest daemon')
 			await self.rpc_call('stop')
 
-	def init_daemon(self, reindex=False):
+	def init_daemon(self, *, reindex=False):
 		if reindex:
 			self.d.usr_coind_args.append('--reindex')
 
-	async def start_daemon(self, reindex=False, silent=True):
+	async def start_daemon(self, *, reindex=False, silent=True):
 		self.init_daemon(reindex=reindex)
 		self.d.start(silent=silent)
 		for user in ('miner', 'bob', 'alice'):
@@ -260,7 +260,7 @@ class MMGenRegtest(MMGenObject):
 
 	async def fork(self, coin): # currently disabled
 
-		proto = init_proto(self.cfg, coin, False)
+		proto = init_proto(self.cfg, coin, testnet=False)
 		if not [f for f in proto.forks if f[2] == proto.coin.lower() and f[3] is True]:
 			die(1, f'Coin {proto.coin} is not a replayable fork of coin {coin}')
 

+ 7 - 2
mmgen/proto/btc/rpc.py

@@ -54,6 +54,7 @@ class CallSigs:
 		def createwallet(
 				self,
 				wallet_name,
+				*,
 				no_keys         = True,
 				blank           = True,
 				passphrase      = '',
@@ -86,6 +87,7 @@ class CallSigs:
 		def createwallet(
 				self,
 				wallet_name,
+				*,
 				no_keys         = True,
 				blank           = True,
 				passphrase      = '',
@@ -117,6 +119,7 @@ class BitcoinRPCClient(RPCClient, metaclass=AsyncInit):
 			self,
 			cfg,
 			proto,
+			*,
 			daemon,
 			backend,
 			ignore_wallet):
@@ -268,7 +271,8 @@ class BitcoinRPCClient(RPCClient, metaclass=AsyncInit):
 
 		fn = self.get_daemon_cfg_fn()
 		try:
-			lines = get_lines_from_file(self.cfg, fn, 'daemon config file', silent=not self.cfg.verbose)
+			lines = get_lines_from_file(
+				self.cfg, fn, desc='daemon config file', silent=not self.cfg.verbose)
 		except:
 			self.cfg._util.vmsg(f'Warning: {fn!r} does not exist or is unreadable')
 			return dict((k, None) for k in req_keys)
@@ -287,7 +291,8 @@ class BitcoinRPCClient(RPCClient, metaclass=AsyncInit):
 
 	def get_daemon_auth_cookie(self):
 		fn = self.daemon.auth_cookie_fn
-		return get_lines_from_file(self.cfg, fn, 'cookie', quiet=True)[0] if os.access(fn, os.R_OK) else ''
+		return get_lines_from_file(
+			self.cfg, fn, desc='cookie', quiet=True)[0] if os.access(fn, os.R_OK) else ''
 
 	def info(self, info_id):
 

+ 1 - 1
mmgen/proto/btc/tw/addresses.py

@@ -49,7 +49,7 @@ class BitcoinTwAddresses(TwAddresses, BitcoinTwRPC):
 		qmsg = self.cfg._util.qmsg
 		qmsg_r = self.cfg._util.qmsg_r
 		qmsg_r('Getting unspent outputs...')
-		addrs = await self.get_unspent_by_mmid(self.minconf)
+		addrs = await self.get_unspent_by_mmid(minconf=self.minconf)
 		qmsg('done')
 
 		coin_amt = self.proto.coin_amt

+ 2 - 2
mmgen/proto/btc/tw/bal.py

@@ -18,10 +18,10 @@ from ....rpc import rpc_init
 
 class BitcoinTwGetBalance(TwGetBalance):
 
-	async def __init__(self, cfg, proto, minconf, quiet):
+	async def __init__(self, cfg, proto, *, minconf, quiet):
 		self.rpc = await rpc_init(cfg, proto)
 		self.walletinfo = await self.rpc.walletinfo
-		await super().__init__(cfg, proto, minconf, quiet)
+		await super().__init__(cfg, proto, minconf=minconf, quiet=quiet)
 
 	start_labels = ('TOTAL', 'Non-MMGen', 'Non-wallet')
 	conf_cols = {

+ 1 - 1
mmgen/proto/btc/tw/ctl.py

@@ -27,7 +27,7 @@ class BitcoinTwCtl(TwCtl):
 		raise NotImplementedError('not implemented')
 
 	@write_mode
-	async def import_address(self, addr, label, rescan=False): # rescan is True by default, so set to False
+	async def import_address(self, addr, *, label, rescan=False):
 		if (await self.rpc.walletinfo).get('descriptors'):
 			return await self.batch_import_address([(addr, label, rescan)])
 		else:

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

@@ -64,7 +64,7 @@ class BitcoinTwRPC(TwRPC):
 		return [label_addr_pair(label, CoinAddr(self.proto, addrs[0]))
 			for label, addrs in zip(acct_labels, acct_addrs)]
 
-	async def get_unspent_by_mmid(self, minconf=1, mmid_filter=[]):
+	async def get_unspent_by_mmid(self, *, minconf=1, mmid_filter=[]):
 		"""
 		get unspent outputs in tracking wallet, compute balances per address
 		and return a dict with elements {'twmmid': {'addr', 'lbl', 'amt'}}

+ 10 - 10
mmgen/proto/btc/tw/txhistory.py

@@ -25,7 +25,7 @@ class BitcoinTwTransaction:
 
 	no_address_str = '[DATA]'
 
-	def __init__(self, parent, proto, rpc,
+	def __init__(self, *, parent, proto, rpc,
 			idx,          # unique numeric identifier of this transaction in listing
 			unspent_info, # addrs in wallet with balances: {'mmid': {'addr', 'comment', 'amt'}}
 			mm_map,       # all addrs in wallet: ['addr', ['twmmid', 'comment']]
@@ -127,13 +127,13 @@ class BitcoinTwTransaction:
 		self.time = self.tx.get('blocktime') or self.tx['time']
 		self.time_received = self.tx.get('timereceived')
 
-	def blockheight_disp(self, color):
+	def blockheight_disp(self, *, color):
 		return (
 			# old/altcoin daemons return no 'blockheight' field, so use confirmations instead
 			Int(self.rpc.blockcount + 1 - self.confirmations).hl(color=color)
 			if self.confirmations > 0 else None)
 
-	def age_disp(self, age_fmt, width, color):
+	def age_disp(self, age_fmt, *, width, color):
 		if age_fmt == 'confs':
 			ret_str = str(self.confirmations).ljust(width)
 			return gray(ret_str) if self.confirmations < 0 and color else ret_str
@@ -147,8 +147,8 @@ class BitcoinTwTransaction:
 	def txdate_disp(self, age_fmt):
 		return self.parent.date_formatter[age_fmt](self.rpc, self.time)
 
-	def txid_disp(self, color, width=None):
-		return self.txid.hl(color=color) if width is None else self.txid.truncate(width=width, color=color)
+	def txid_disp(self, *, color, width=None):
+		return self.txid.hl(color=color) if width is None else self.txid.truncate(width, color=color)
 
 	def vouts_list_disp(self, src, color, indent, addr_view_pref):
 
@@ -165,9 +165,9 @@ class BitcoinTwTransaction:
 						i = CoinTxID(e.txid).hl(color=color),
 						n = (nocolor, red)[color](str(e.data['n']).ljust(3)),
 						a = CoinAddr(self.proto, e.coin_addr).fmt(
-							addr_view_pref, width=self.max_addrlen[src], color=color)
+							addr_view_pref, self.max_addrlen[src], color=color)
 								if e.coin_addr != self.no_address_str else
-							CoinAddr.fmtc(e.coin_addr, width=self.max_addrlen[src], color=color),
+							CoinAddr.fmtc(e.coin_addr, self.max_addrlen[src], color=color),
 						A = self.proto.coin_amt(e.data['value']).fmt(color=color)
 					).rstrip()
 				else:
@@ -200,9 +200,9 @@ class BitcoinTwTransaction:
 					if width and space_left < addr_w:
 						break
 					yield (
-						CoinAddr(self.proto, e.coin_addr).fmt(addr_view_pref, width=addr_w, color=color)
+						CoinAddr(self.proto, e.coin_addr).fmt(addr_view_pref, addr_w, color=color)
 							if e.coin_addr != self.no_address_str else
-						CoinAddr.fmtc(e.coin_addr, width=addr_w, color=color))
+						CoinAddr.fmtc(e.coin_addr, addr_w, color=color))
 					space_left -= addr_w
 				elif mmid.type == 'mmgen':
 					mmid_disp = mmid + bal_star
@@ -215,7 +215,7 @@ class BitcoinTwTransaction:
 						break
 					yield TwMMGenID.hl2(
 						TwMMGenID,
-						s = CoinAddr.fmtc(mmid.split(':', 1)[1] + bal_star, width=addr_w),
+						s = CoinAddr.fmtc(mmid.split(':', 1)[1] + bal_star, addr_w),
 						color = color,
 						color_override = co)
 					space_left -= addr_w

+ 3 - 3
mmgen/proto/btc/tx/base.py

@@ -75,7 +75,7 @@ def DeserializeTX(proto, txhex):
 	def bytes2coin_amt(bytes_le):
 		return proto.coin_amt(bytes2int(bytes_le), from_unit='satoshi')
 
-	def bshift(n, skip=False, sub_null=False):
+	def bshift(n, *, skip=False, sub_null=False):
 		nonlocal idx, raw_tx
 		ret = tx[idx:idx+n]
 		idx += n
@@ -87,7 +87,7 @@ def DeserializeTX(proto, txhex):
 
 	# https://bitcoin.org/en/developer-reference#compactsize-unsigned-integers
 	# For example, the number 515 is encoded as 0xfd0302.
-	def readVInt(skip=False):
+	def readVInt(*, skip=False):
 		nonlocal idx, raw_tx
 		s = tx[idx]
 		idx += 1
@@ -292,7 +292,7 @@ class Base(TxBase):
 		return int(ret * (self.cfg.vsize_adj or 1))
 
 	# convert absolute CoinAmt fee to sat/byte for display using estimated size
-	def fee_abs2rel(self, abs_fee, to_unit='satoshi'):
+	def fee_abs2rel(self, abs_fee, *, to_unit='satoshi'):
 		return str(int(
 			abs_fee /
 			getattr(self.proto.coin_amt, to_unit) /

+ 0 - 4
mmgen/proto/btc/tx/completed.py

@@ -75,10 +75,6 @@ class Completed(Base, TxBase.Completed):
 					'scriptPubKey:',          i.scriptPubKey,
 					'scriptPubKey->address:', ds.addr))
 
-#	def is_replaceable_from_rpc(self):
-#		dec_tx = await self.rpc.call('decoderawtransaction', self.serialized)
-#		return None < dec_tx['vin'][0]['sequence'] <= self.proto.max_int - 2
-
 	def is_replaceable(self):
 		return self.inputs[0].sequence == self.proto.max_int - 2
 

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

@@ -36,8 +36,8 @@ class TxInfo(TxInfo):
 			pink('{:0.6f}%'.format(tx.fee / tx.send_amt * 100))
 		)
 
-	def format_abs_fee(self, color, iwidth):
-		return self.tx.fee.fmt(color=color, iwidth=iwidth)
+	def format_abs_fee(self, iwidth, /, *, color=None):
+		return self.tx.fee.fmt(iwidth, color=color)
 
 	def format_verbose_footer(self):
 		tx = self.tx
@@ -48,7 +48,7 @@ class TxInfo(TxInfo):
 			out += f', Base {tsize-wsize}, Witness {wsize}'
 		return out + '\n'
 
-	def format_body(self, blockcount, nonmm_str, max_mmwid, enl, terse, sort):
+	def format_body(self, blockcount, nonmm_str, max_mmwid, enl, *, terse, sort):
 
 		if sort not in self.sort_orders:
 			die(1, '{!r}: invalid transaction view sort order. Valid options: {}'.format(
@@ -58,15 +58,15 @@ class TxInfo(TxInfo):
 		def get_mmid_fmt(e, is_input):
 			if e.mmid:
 				return e.mmid.fmt2(
-					width=max_mmwid,
-					encl='()',
-					color=True,
-					append_chars=('', ' (chg)')[bool(not is_input and e.is_chg and terse)],
-					append_color='green')
+					max_mmwid,
+					encl = '()',
+					color = True,
+					append_chars = ('', ' (chg)')[bool(not is_input and e.is_chg and terse)],
+					append_color = 'green')
 			else:
 				return MMGenID.fmtc(
 					'[vault address]' if not is_input and e.is_vault else nonmm_str,
-					width = max_mmwid,
+					max_mmwid,
 					color = True)
 
 		def format_io(desc):
@@ -91,9 +91,9 @@ class TxInfo(TxInfo):
 				for n, e in enumerate(io_sorted()):
 					yield '{:3} {} {} {} {}\n'.format(
 						n+1,
-						e.addr.fmt(vp1, width=addr_w, color=True) if e.addr else blue(data_disp(e.data).ljust(addr_w)),
+						e.addr.fmt(vp1, addr_w, color=True) if e.addr else blue(data_disp(e.data).ljust(addr_w)),
 						get_mmid_fmt(e, is_input) if e.addr else ''.ljust(max_mmwid),
-						e.amt.fmt(iwidth=iwidth, color=True),
+						e.amt.fmt(iwidth, color=True),
 						tx.dcoin)
 					if have_bch and e.addr:
 						yield '{:3} [{}]\n'.format('', e.addr.hl(vp2, color=False))
@@ -145,7 +145,7 @@ class TxInfo(TxInfo):
 			+ ''.join(format_io('inputs'))
 			+ ''.join(format_io('outputs')))
 
-	def strfmt_locktime(self, locktime=None, terse=False):
+	def strfmt_locktime(self, locktime=None, *, terse=False):
 		# Locktime itself is an unsigned 4-byte integer which can be parsed two ways:
 		#
 		# If less than 500 million, locktime is parsed as a block height. The transaction can be

+ 10 - 5
mmgen/proto/btc/tx/new.py

@@ -22,6 +22,7 @@ class New(Base, TxNew):
 	usr_fee_prompt = 'Enter transaction fee: '
 	fee_fail_fs = 'Network fee estimation for {c} confirmations failed ({t})'
 	no_chg_msg = 'Warning: Change address will be deleted as transaction produces no change'
+	msg_insufficient_funds = 'Selected outputs insufficient to fund this transaction ({} {} needed)'
 
 	def process_data_output_arg(self, arg):
 		if any(arg.startswith(pfx) for pfx in ('data:', 'hexdata:')):
@@ -73,11 +74,11 @@ class New(Base, TxNew):
 		return fee_per_kb, fe_type
 
 	# given tx size, rel fee and units, return absolute fee
-	def fee_rel2abs(self, tx_size, units, amt_in_units, unit):
-		return self.proto.coin_amt(amt_in_units * tx_size, from_unit=units[unit])
+	def fee_rel2abs(self, tx_size, amt_in_units, unit):
+		return self.proto.coin_amt(int(amt_in_units * tx_size), from_unit=unit)
 
 	# given network fee estimate in BTC/kB, return absolute fee using estimated tx size
-	def fee_est2abs(self, fee_per_kb, fe_type=None):
+	def fee_est2abs(self, fee_per_kb, *, fe_type=None):
 		tx_size = self.estimate_size()
 		ret = self.proto.coin_amt('1') * (fee_per_kb * self.cfg.fee_adjust * tx_size / 1024)
 		if self.cfg.verbose:
@@ -125,7 +126,11 @@ class New(Base, TxNew):
 	def final_inputs_ok_msg(self, funds_left):
 		return 'Transaction produces {} {} in change'.format(funds_left.hl(), self.coin)
 
-	def check_chg_addr_is_wallet_addr(self, output=None, message='Change address is not an MMGen wallet address!'):
+	def check_chg_addr_is_wallet_addr(
+			self,
+			output  = None,
+			*,
+			message = 'Change address is not an MMGen wallet address!'):
 		def do_err():
 			from ....ui import confirm_or_raise
 			confirm_or_raise(
@@ -138,7 +143,7 @@ class New(Base, TxNew):
 		elif len(self.nondata_outputs) > 1 and not self.chg_output.mmid:
 			do_err()
 
-	async def create_serialized(self, locktime=None):
+	async def create_serialized(self, *, locktime=None):
 
 		if not self.is_bump:
 			# Set all sequence numbers to the same value, in conformity with the behavior of most modern wallets:

+ 23 - 2
mmgen/proto/btc/tx/online.py

@@ -13,13 +13,13 @@ proto.btc.tx.online: Bitcoin online signed transaction class
 """
 
 from ....tx import online as TxBase
-from ....util import msg, die
+from ....util import msg, ymsg, die
 from ....color import orange
 from .signed import Signed
 
 class OnlineSigned(Signed, TxBase.OnlineSigned):
 
-	async def send(self, prompt_user=True):
+	async def send_checks(self):
 
 		self.check_correct_chain()
 
@@ -37,6 +37,27 @@ class OnlineSigned(Signed, TxBase.OnlineSigned):
 
 		await self.status.display()
 
+	async def test_sendable(self):
+
+		await self.send_checks()
+
+		res = await self.rpc.call('testmempoolaccept', (self.serialized,))
+		ret = res[0]
+
+		if ret['allowed']:
+			from ....obj import CoinTxID
+			msg('TxID: {}'.format(CoinTxID(ret['txid']).hl()))
+			msg('Transaction can be sent')
+			return True
+		else:
+			ymsg('Transaction cannot be sent')
+			msg(ret['reject-reason'])
+			return False
+
+	async def send(self, *, prompt_user=True):
+
+		await self.send_checks()
+
 		if prompt_user:
 			self.confirm_send()
 

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

@@ -61,7 +61,7 @@ class OpReturnData(bytes, InitErrors):
 			self.display_hex = False
 			return ret
 
-	def hl(self, add_label=False):
+	def hl(self, *, add_label=False):
 		'colorize and optionally label the result of str()'
 		from ....color import blue, pink
 		ret = str(self)

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

@@ -31,7 +31,7 @@ class EthereumTwAddrData(TwAddrData):
 		"""
 	}
 
-	async def get_tw_data(self, twctl=None):
+	async def get_tw_data(self, *, twctl=None):
 		from ...tw.ctl import TwCtl
 		self.cfg._util.vmsg('Getting address data from tracking wallet')
 		twctl = (twctl or await TwCtl(self.cfg, self.proto)).mmid_ordered_dict

+ 7 - 4
mmgen/proto/eth/contract.py

@@ -45,7 +45,7 @@ class TokenCommon(MMGenObject):
 			int(parse_abi(data)[-1], 16) * self.base_unit,
 			from_decimal = True)
 
-	async def do_call(self, method_sig, method_args='', toUnit=False):
+	async def do_call(self, method_sig, method_args='', *, toUnit=False):
 		data = self.create_method_id(method_sig) + method_args
 		if self.cfg.debug:
 			msg('ETH_CALL {}:  {}'.format(
@@ -99,6 +99,7 @@ class TokenCommon(MMGenObject):
 			self,
 			to_addr,
 			amt,
+			*,
 			method_sig = 'transfer(address,uint256)'):
 		from_arg = ''
 		to_arg = to_addr.rjust(64, '0')
@@ -112,6 +113,7 @@ class TokenCommon(MMGenObject):
 			start_gas,
 			gasPrice,
 			nonce,
+			*,
 			method_sig = 'transfer(address,uint256)'):
 		data = self.create_data(
 				to_addr,
@@ -125,7 +127,7 @@ class TokenCommon(MMGenObject):
 			'nonce':    nonce,
 			'data':     bytes.fromhex(data)}
 
-	async def txsign(self, tx_in, key, from_addr, chain_id=None):
+	async def txsign(self, tx_in, key, from_addr, *, chain_id=None):
 
 		from .pyethereum.transactions import Transaction
 
@@ -162,6 +164,7 @@ class TokenCommon(MMGenObject):
 			key,
 			start_gas,
 			gasPrice,
+			*,
 			method_sig = 'transfer(address,uint256)'):
 		tx_in = self.make_tx_in(
 				to_addr,
@@ -175,7 +178,7 @@ class TokenCommon(MMGenObject):
 
 class Token(TokenCommon):
 
-	def __init__(self, cfg, proto, addr, decimals, rpc=None):
+	def __init__(self, cfg, proto, addr, decimals, *, rpc=None):
 		if type(self).__name__ == 'Token':
 			from ...util2 import get_keccak
 			self.keccak_256 = get_keccak(cfg)
@@ -199,4 +202,4 @@ class ResolvedToken(TokenCommon, metaclass=AsyncInit):
 		decimals = await self.get_decimals() # requires self.addr!
 		if not decimals:
 			die('TokenNotInBlockchain', f'Token {addr!r} not in blockchain')
-		Token.__init__(self, cfg, proto, addr, decimals, rpc)
+		Token.__init__(self, cfg, proto, addr, decimals, rpc=rpc)

+ 3 - 3
mmgen/proto/eth/daemon.py

@@ -174,12 +174,12 @@ class erigon_daemon(geth_daemon):
 			test_suite   = self.test_suite,
 			datadir      = self.datadir)
 
-	def start(self, quiet=False, silent=False):
+	def start(self, *, quiet=False, silent=False):
 		super().start(quiet=quiet, silent=silent)
 		self.rpc_d.debug = self.debug
 		return self.rpc_d.start(quiet=quiet, silent=silent)
 
-	def stop(self, quiet=False, silent=False):
+	def stop(self, *, quiet=False, silent=False):
 		self.rpc_d.debug = self.debug
 		self.rpc_d.stop(quiet=quiet, silent=silent)
 		return super().stop(quiet=quiet, silent=silent)
@@ -196,7 +196,7 @@ class erigon_rpcdaemon(RPCDaemon):
 	use_pidfile = False
 	use_threads = True
 
-	def __init__(self, cfg, proto, rpc_port, private_port, test_suite, datadir):
+	def __init__(self, cfg, proto, *, rpc_port, private_port, test_suite, datadir):
 
 		self.proto = proto
 		self.test_suite = test_suite

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

@@ -14,7 +14,7 @@ proto.eth.misc: miscellaneous utilities for Ethereum base protocol
 
 from ...util2 import get_keccak
 
-def decrypt_geth_keystore(cfg, wallet_fn, passwd, check_addr=True):
+def decrypt_geth_keystore(cfg, wallet_fn, passwd, *, check_addr=True):
 	"""
 	Decrypt the encrypted private key in a Geth keystore wallet, returning the decrypted key
 	"""

+ 1 - 0
mmgen/proto/eth/params.py

@@ -55,6 +55,7 @@ class mainnet(CoinProtocol.DummyWIF, CoinProtocol.Secp256k1):
 	}
 
 	coin_cfg_opts = (
+		'daemon_id',
 		'ignore_daemon_version',
 		'rpc_host',
 		'rpc_port',

+ 5 - 7
mmgen/proto/eth/rpc.py

@@ -25,10 +25,6 @@ class daemon_warning(oneshot_warning_group):
 		color = 'yellow'
 		message = 'Geth has not been tested on mainnet. You may experience problems.'
 
-	class reth:
-		color = 'yellow'
-		message = 'Reth has not been tested on mainnet. You may experience problems.'
-
 	class erigon:
 		color = 'red'
 		message = 'Erigon support is EXPERIMENTAL. Use at your own risk!!!'
@@ -42,6 +38,7 @@ class EthereumRPCClient(RPCClient, metaclass=AsyncInit):
 			self,
 			cfg,
 			proto,
+			*,
 			daemon,
 			backend,
 			ignore_wallet):
@@ -82,13 +79,14 @@ class EthereumRPCClient(RPCClient, metaclass=AsyncInit):
 		if self.daemon.id in ('parity', 'openethereum'):
 			if (await self.call('parity_nodeKind'))['capability'] == 'full':
 				self.caps += ('full_node',)
-			self.chainID = None if ci is None else Int(ci, 16) # parity/oe return chainID only for dev chain
+			# parity/openethereum return chainID only for dev chain:
+			self.chainID = None if ci is None else Int(ci, base=16)
 			self.chain = (await self.call('parity_chain')).replace(' ', '_').replace('_testnet', '')
 		elif self.daemon.id in ('geth', 'reth', 'erigon'):
-			if self.daemon.network == 'mainnet':
+			if self.daemon.network == 'mainnet' and hasattr(daemon_warning, self.daemon.id):
 				daemon_warning(self.daemon.id)
 			self.caps += ('full_node',)
-			self.chainID = Int(ci, 16)
+			self.chainID = Int(ci, base=16)
 			self.chain = self.proto.chain_ids[self.chainID]
 
 	def make_host_path(self, wallet):

+ 1 - 1
mmgen/proto/eth/tw/addresses.py

@@ -37,7 +37,7 @@ class EthereumTwAddresses(TwAddresses, EthereumTwView, EthereumTwRPC):
 		'w':'a_view_detail',
 		'p':'a_print_detail'}
 
-	def get_column_widths(self, data, wide, interactive):
+	def get_column_widths(self, data, *, wide, interactive):
 
 		return self.compute_column_widths(
 			widths = { # fixed cols

+ 2 - 2
mmgen/proto/eth/tw/bal.py

@@ -30,9 +30,9 @@ class EthereumTwGetBalance(TwGetBalance):
 		'ge_minconf': 'Balance',
 	}
 
-	async def __init__(self, cfg, proto, *args, **kwargs):
+	async def __init__(self, cfg, proto, *, minconf, quiet):
 		self.twctl = await TwCtl(cfg, proto, mode='w')
-		await super().__init__(cfg, proto, *args, **kwargs)
+		await super().__init__(cfg, proto, minconf=minconf, quiet=quiet)
 
 	async def create_data(self):
 		in_data = self.twctl.mmid_ordered_dict

+ 7 - 6
mmgen/proto/eth/tw/ctl.py

@@ -88,13 +88,13 @@ class EthereumTwCtl(TwCtl):
 
 	@write_mode
 	async def batch_import_address(self, args_list):
-		return [await self.import_address(*a) for a in args_list]
+		return [await self.import_address(a, label=b, rescan=c) for a, b, c in args_list]
 
 	async def rescan_addresses(self, coin_addrs):
 		pass
 
 	@write_mode
-	async def import_address(self, addr, label, rescan=False):
+	async def import_address(self, addr, *, label, rescan=False):
 		r = self.data_root
 		if addr in r:
 			if not r[addr]['mmid'] and label.mmid:
@@ -174,7 +174,7 @@ class EthereumTokenTwCtl(EthereumTwCtl):
 	symbol = None
 	cur_eth_balances = {}
 
-	async def __init__(self, cfg, proto, mode='r', token_addr=None, no_rpc=False):
+	async def __init__(self, cfg, proto, *, mode='r', token_addr=None, no_rpc=False):
 
 		await super().__init__(cfg, proto, mode=mode, no_rpc=no_rpc)
 
@@ -213,15 +213,16 @@ class EthereumTokenTwCtl(EthereumTwCtl):
 		return 'token ' + self.get_param('symbol')
 
 	async def rpc_get_balance(self, addr):
-		return await Token(self.cfg, self.proto, self.token, self.decimals, self.rpc).get_balance(addr)
+		return await Token(
+			self.cfg, self.proto, self.token, self.decimals, rpc=self.rpc).get_balance(addr)
 
-	async def get_eth_balance(self, addr, force_rpc=False):
+	async def get_eth_balance(self, addr, *, force_rpc=False):
 		cache = self.cur_eth_balances
 		r = self.data['accounts']
 		ret = None if force_rpc else self.get_cached_balance(addr, cache, r)
 		if ret is None:
 			ret = await super().rpc_get_balance(addr)
-			self.cache_balance(addr, ret, cache, r)
+			self.cache_balance(addr, ret, session_cache=cache, data_root=r)
 		return ret
 
 	def get_param(self, param):

+ 1 - 1
mmgen/proto/eth/tw/json.py

@@ -109,7 +109,7 @@ class EthereumTwJSON(TwJSON):
 
 	class Export(TwJSON.Export, Base):
 
-		async def get_entries(self, include_amts=True):
+		async def get_entries(self, *, include_amts=True):
 
 			def gen_data(data):
 				for k, v in data.items():

+ 2 - 2
mmgen/proto/eth/tw/unspent.py

@@ -66,7 +66,7 @@ class EthereumTwUnspentOutputs(EthereumTwView, TwUnspentOutputs):
 
 	no_data_errmsg = 'No accounts in tracking wallet!'
 
-	def get_column_widths(self, data, wide, interactive):
+	def get_column_widths(self, data, *, wide, interactive):
 		# min screen width: 80 cols
 		# num addr [mmid] [comment] amt [amt2]
 		return self.compute_column_widths(
@@ -95,7 +95,7 @@ class EthereumTwUnspentOutputs(EthereumTwView, TwUnspentOutputs):
 			interactive = interactive,
 		)
 
-	def do_sort(self, key=None, reverse=False):
+	def do_sort(self, key=None, *, reverse=False):
 		if key == 'txid':
 			return
 		super().do_sort(key=key, reverse=reverse)

+ 9 - 10
mmgen/proto/eth/tx/base.py

@@ -34,18 +34,17 @@ class Base(TxBase.Base):
 		return self.outputs
 
 	def pretty_fmt_fee(self, fee):
-		if fee < 1:
-			ret = f'{fee:.8f}'.rstrip('0')
-			return ret + '0' if ret.endswith('.') else ret
+		if fee < 10:
+			return f'{fee:.3f}'.rstrip('0').rstrip('.')
 		return str(int(fee))
 
 	# given absolute fee in ETH, return gas price for display in selected unit
-	def fee_abs2rel(self, abs_fee, to_unit='Gwei'):
+	def fee_abs2rel(self, abs_fee, *, to_unit='Gwei'):
 		return self.pretty_fmt_fee(
-			self.fee_abs2gas(abs_fee).to_unit(to_unit))
+			self.fee_abs2gasprice(abs_fee).to_unit(to_unit))
 
 	# given absolute fee in ETH, return gas price in ETH
-	def fee_abs2gas(self, abs_fee):
+	def fee_abs2gasprice(self, abs_fee):
 		return self.proto.coin_amt(int(abs_fee.toWei() // self.gas.toWei()), from_unit='wei')
 
 	# given rel fee (gasPrice) in wei, return absolute fee using self.gas (Ethereum-only method)
@@ -63,10 +62,10 @@ class Base(TxBase.Base):
 		tx = await self.rpc.call('eth_getTransactionByHash', '0x'+txid)
 		return namedtuple('exec_status',
 				['status', 'gas_sent', 'gas_used', 'gas_price', 'contract_addr', 'tx', 'rx'])(
-			status        = Int(rx['status'], 16), # zero is failure, non-zero success
-			gas_sent      = Int(tx['gas'], 16),
-			gas_used      = Int(rx['gasUsed'], 16),
-			gas_price     = self.proto.coin_amt(int(tx['gasPrice'], 16), from_unit='wei'),
+			status        = Int(rx['status'], base=16), # zero is failure, non-zero success
+			gas_sent      = Int(tx['gas'], base=16),
+			gas_used      = Int(rx['gasUsed'], base=16),
+			gas_price     = self.proto.coin_amt(Int(tx['gasPrice'], base=16), from_unit='wei'),
 			contract_addr = self.proto.coin_addr(rx['contractAddress'][2:])
 				if rx['contractAddress'] else None,
 			tx = tx,

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

@@ -29,7 +29,7 @@ class Bump(Completed, New, TxBase.Bump):
 		return self.fee * Decimal('1.101')
 
 	def bump_fee(self, idx, fee):
-		self.txobj['gasPrice'] = self.fee_abs2gas(fee)
+		self.txobj['gasPrice'] = self.fee_abs2gasprice(fee)
 
 	async def get_nonce(self):
 		return self.txobj['nonce']

+ 3 - 3
mmgen/proto/eth/tx/info.py

@@ -28,7 +28,7 @@ class TxInfo(TxInfo):
 	""")
 	to_addr_key = 'to'
 
-	def format_body(self, blockcount, nonmm_str, max_mmwid, enl, terse, sort):
+	def format_body(self, blockcount, nonmm_str, max_mmwid, enl, *, terse, sort):
 		tx = self.tx
 		m = {}
 		for k in ('inputs', 'outputs'):
@@ -59,8 +59,8 @@ class TxInfo(TxInfo):
 			t_mmid = m['outputs'] if len(tx.outputs) else '',
 			f_mmid = m['inputs']) + '\n\n'
 
-	def format_abs_fee(self, color, iwidth):
-		return self.tx.fee.fmt(color=color, iwidth=iwidth) + (' (max)' if self.tx.txobj['data'] else '')
+	def format_abs_fee(self, iwidth, /, *, color=None):
+		return self.tx.fee.fmt(iwidth, color=color) + (' (max)' if self.tx.txobj['data'] else '')
 
 	def format_rel_fee(self):
 		return ' ({} of spend amount)'.format(

+ 7 - 8
mmgen/proto/eth/tx/new.py

@@ -55,17 +55,16 @@ class New(Base, TxBase.New):
 			'from': self.inputs[0].addr,
 			'to':   self.outputs[0].addr if self.outputs else None,
 			'amt':  self.outputs[0].amt if self.outputs else self.proto.coin_amt('0'),
-			'gasPrice': self.fee_abs2gas(self.usr_fee),
+			'gasPrice': self.fee_abs2gasprice(self.usr_fee),
 			'startGas': self.start_gas,
 			'nonce': await self.get_nonce(),
 			'chainId': self.rpc.chainID,
-			'data':  self.usr_contract_data,
-		}
+			'data':  self.usr_contract_data}
 
 	# Instead of serializing tx data as with BTC, just create a JSON dump.
 	# This complicates things but means we avoid using the rlp library to deserialize the data,
 	# thus removing an attack vector
-	async def create_serialized(self, locktime=None):
+	async def create_serialized(self, *, locktime=None):
 		assert len(self.inputs) == 1, 'Transaction has more than one input!'
 		o_num = len(self.outputs)
 		o_ok = 0 if self.usr_contract_data else 1
@@ -117,7 +116,7 @@ class New(Base, TxBase.New):
 
 	# get rel_fee (gas price) from network, return in native wei
 	async def get_rel_fee_from_network(self):
-		return Int(await self.rpc.call('eth_gasPrice'), 16), 'eth_gasPrice'
+		return Int(await self.rpc.call('eth_gasPrice'), base=16), 'eth_gasPrice'
 
 	def check_chg_addr_is_wallet_addr(self):
 		pass
@@ -127,11 +126,11 @@ class New(Base, TxBase.New):
 			assert self.usr_fee <= self.proto.max_tx_fee
 
 	# given rel fee and units, return absolute fee using self.gas
-	def fee_rel2abs(self, tx_size, units, amt_in_units, unit):
-		return self.proto.coin_amt(amt_in_units, from_unit=units[unit]) * self.gas.toWei()
+	def fee_rel2abs(self, tx_size, amt_in_units, unit):
+		return self.proto.coin_amt(int(amt_in_units * self.gas.toWei()), from_unit=unit)
 
 	# given fee estimate (gas price) in wei, return absolute fee, adjusting by self.cfg.fee_adjust
-	def fee_est2abs(self, rel_fee, fe_type=None):
+	def fee_est2abs(self, rel_fee, *, fe_type=None):
 		ret = self.fee_gasPrice2abs(rel_fee) * self.cfg.fee_adjust
 		if self.cfg.verbose:
 			msg(f'Estimated fee: {ret} ETH')

+ 4 - 1
mmgen/proto/eth/tx/online.py

@@ -20,7 +20,10 @@ from .signed import Signed, TokenSigned
 
 class OnlineSigned(Signed, TxBase.OnlineSigned):
 
-	async def send(self, prompt_user=True):
+	async def test_sendable(self):
+		raise NotImplementedError('transaction testing not implemented for Ethereum')
+
+	async def send(self, *, prompt_user=True):
 
 		self.check_correct_chain()
 

+ 2 - 2
mmgen/proto/secp256k1/keygen.py

@@ -37,7 +37,7 @@ class backend:
 				compressed = privkey.compressed)
 
 		@classmethod
-		def get_clsname(cls, cfg, silent=False):
+		def get_clsname(cls, cfg, *, silent=False):
 			try:
 				from .secp256k1 import pubkey_gen
 				if not pubkey_gen(bytes.fromhex('deadbeef'*8), 1):
@@ -67,7 +67,7 @@ class backend:
 			Uncompressed public keys start with 0x04; compressed public keys begin with 0x03 or
 			0x02 depending on whether they're greater or less than the midpoint of the curve.
 			"""
-			def privnum2pubkey(numpriv, compressed=False):
+			def privnum2pubkey(numpriv, *, compressed=False):
 				pk = self.ecdsa.SigningKey.from_secret_exponent(numpriv, curve=self.ecdsa.SECP256k1)
 				# vk_bytes = x (32 bytes) + y (32 bytes) (unsigned big-endian)
 				return pubkey_format(pk.verifying_key.to_string(), compressed)

+ 1 - 0
mmgen/proto/xmr/daemon.py

@@ -104,6 +104,7 @@ class MoneroWalletDaemon(RPCDaemon):
 			self,
 			cfg,
 			proto,
+			*,
 			wallet_dir  = None,
 			test_suite  = False,
 			user        = None,

+ 5 - 4
mmgen/proto/xmr/rpc.py

@@ -25,6 +25,7 @@ class MoneroRPCClient(RPCClient):
 			self,
 			cfg,
 			proto,
+			*,
 			host,
 			port,
 			user,
@@ -42,7 +43,7 @@ class MoneroRPCClient(RPCClient):
 			if host.endswith('.onion'):
 				self.network_proto = 'http'
 
-		super().__init__(cfg, host, port, test_connection)
+		super().__init__(cfg, host, port, test_connection=test_connection)
 
 		if self.auth_type:
 			self.auth = auth_data(user, passwd)
@@ -90,7 +91,7 @@ class MoneroRPCClient(RPCClient):
 			host_path = f'/{method}'
 		), json_rpc=False)
 
-	async def do_stop_daemon(self, silent=False):
+	async def do_stop_daemon(self, *, silent=False):
 		return self.call_raw('stop_daemon') # unreliable on macOS (daemon stops, but closes connection)
 
 	rpcmethods = ('get_info',)
@@ -100,7 +101,7 @@ class MoneroWalletRPCClient(MoneroRPCClient):
 
 	auth_type = 'digest'
 
-	def __init__(self, cfg, daemon, test_connection=True):
+	def __init__(self, cfg, daemon, *, test_connection=True):
 
 		RPCClient.__init__(
 			self            = self,
@@ -128,7 +129,7 @@ class MoneroWalletRPCClient(MoneroRPCClient):
 	def call_raw(self, *args, **kwargs):
 		raise NotImplementedError('call_raw() not implemented for class MoneroWalletRPCClient')
 
-	async def do_stop_daemon(self, silent=False):
+	async def do_stop_daemon(self, *, silent=False):
 		"""
 		NB: the 'stop_wallet' RPC call closes the open wallet before shutting down the daemon,
 		returning an error if no wallet is open

+ 4 - 2
mmgen/protocol.py

@@ -59,7 +59,7 @@ class CoinProtocol(MMGenObject):
 		decimal_prec = 28
 		_set_ok = ('tokensym',)
 
-		def __init__(self, cfg, coin, name, network, tokensym=None, need_amt=False):
+		def __init__(self, cfg, coin, *, name, network, tokensym=None, need_amt=False):
 			self.cfg        = cfg
 			self.coin       = coin.upper()
 			self.coin_id    = self.coin
@@ -180,7 +180,7 @@ class CoinProtocol(MMGenObject):
 		def viewkey(self, viewkey_str):
 			raise NotImplementedError(f'{self.name} protocol does not support view keys')
 
-		def base_proto_subclass(self, cls, modname, sub_clsname=None, is_token=False):
+		def base_proto_subclass(self, cls, modname, *, sub_clsname=None, is_token=False):
 			"""
 			magic module loading and class selection
 			"""
@@ -212,6 +212,7 @@ class CoinProtocol(MMGenObject):
 		rpc_user              = ''
 		rpc_password          = ''
 		tw_name               = ''
+		daemon_id             = ''
 
 		@classmethod
 		def get_opt_clsval(cls, cfg, opt):
@@ -281,6 +282,7 @@ class CoinProtocol(MMGenObject):
 def init_proto(
 		cfg,
 		coin       = None,
+		*,
 		testnet    = False,
 		regtest    = False,
 		network    = None,

+ 10 - 9
mmgen/rpc.py

@@ -30,7 +30,7 @@ from .objmethods import HiliteStr, InitErrors, MMGenObject
 
 auth_data = namedtuple('rpc_auth_data', ['user', 'passwd'])
 
-def dmsg_rpc(fs, data=None, is_json=False):
+def dmsg_rpc(fs, data=None, *, is_json=False):
 	msg(
 		fs if data is None else
 		fs.format(pp_fmt(json.loads(data) if is_json else data))
@@ -255,7 +255,7 @@ class RPCClient(MMGenObject):
 	network_proto = 'http'
 	proxy = None
 
-	def __init__(self, cfg, host, port, test_connection=True):
+	def __init__(self, cfg, host, port, *, test_connection=True):
 
 		self.cfg = cfg
 		self.name = type(self).__name__
@@ -317,7 +317,7 @@ class RPCClient(MMGenObject):
 			host_path = self.make_host_path(wallet)
 		))
 
-	async def batch_call(self, method, param_list, timeout=None, wallet=None):
+	async def batch_call(self, method, param_list, *, timeout=None, wallet=None):
 		"""
 		Make a single call with a list of tuples as first argument
 		For RPC calls that return a list of results
@@ -332,7 +332,7 @@ class RPCClient(MMGenObject):
 			host_path = self.make_host_path(wallet)
 		), batch=True)
 
-	async def gathered_call(self, method, args_list, timeout=None, wallet=None):
+	async def gathered_call(self, method, args_list, *, timeout=None, wallet=None):
 		"""
 		Perform multiple RPC calls, returning results in a list
 		Can be called two ways:
@@ -369,14 +369,14 @@ class RPCClient(MMGenObject):
 			timeout = timeout,
 			wallet = wallet)
 
-	def gathered_icall(self, method, args_list, timeout=None, wallet=None):
+	def gathered_icall(self, method, args_list, *, timeout=None, wallet=None):
 		return self.gathered_call(
 			method,
 			[getattr(self.call_sigs, method)(*a)[1:] for a in args_list],
 			timeout = timeout,
 			wallet = wallet)
 
-	def process_http_resp(self, run_ret, batch=False, json_rpc=True):
+	def process_http_resp(self, run_ret, *, batch=False, json_rpc=True):
 
 		def float_parser(n):
 			return n
@@ -424,7 +424,7 @@ class RPCClient(MMGenObject):
 						m = text
 			die('RPCFailure', f'{s.value} {s.name}: {m}')
 
-	async def stop_daemon(self, quiet=False, silent=False):
+	async def stop_daemon(self, *, quiet=False, silent=False):
 		if self.daemon.state == 'ready':
 			if not (quiet or silent):
 				msg(f'Stopping {self.daemon.desc} on port {self.daemon.bind_port}')
@@ -437,10 +437,10 @@ class RPCClient(MMGenObject):
 				msg(f'{self.daemon.desc} on port {self.daemon.bind_port} not running')
 			return True
 
-	def start_daemon(self, silent=False):
+	def start_daemon(self, *, silent=False):
 		return self.daemon.start(silent=silent)
 
-	async def restart_daemon(self, quiet=False, silent=False):
+	async def restart_daemon(self, *, quiet=False, silent=False):
 		await self.stop_daemon(quiet=quiet, silent=silent)
 		return self.daemon.start(silent=silent)
 
@@ -467,6 +467,7 @@ class RPCClient(MMGenObject):
 async def rpc_init(
 		cfg,
 		proto                 = None,
+		*,
 		backend               = None,
 		daemon                = None,
 		ignore_daemon_version = False,

+ 2 - 2
mmgen/seed.py

@@ -28,7 +28,7 @@ class SeedID(HiliteStr, InitErrors):
 	color = 'blue'
 	width = 8
 	trunc_ok = False
-	def __new__(cls, seed=None, sid=None):
+	def __new__(cls, *, seed=None, sid=None):
 		if isinstance(sid, cls):
 			return sid
 		try:
@@ -54,7 +54,7 @@ class SeedBase(MMGenObject):
 	data = ImmutableAttr(bytes, typeconv=False)
 	sid  = ImmutableAttr(SeedID, typeconv=False)
 
-	def __init__(self, cfg, seed_bin=None, nSubseeds=None):
+	def __init__(self, cfg, *, seed_bin=None, nSubseeds=None):
 
 		if not seed_bin:
 			from .crypto import Crypto

+ 13 - 10
mmgen/seedsplit.py

@@ -68,7 +68,7 @@ class SeedShareList(SubSeedList):
 	count  = ImmutableAttr(SeedShareCount)
 	id_str = ImmutableAttr(SeedSplitIDString)
 
-	def __init__(self, parent_seed, count, id_str=None, master_idx=None, debug_last_share=False):
+	def __init__(self, parent_seed, count, *, id_str=None, master_idx=None, debug_last_share=False):
 		self.member_type = SeedShare
 		self.parent_seed = parent_seed
 		self.id_str = id_str or 'default'
@@ -106,7 +106,8 @@ class SeedShareList(SubSeedList):
 			if last_share_debug(ls) or ls.sid in self.data['long'] or ls.sid == parent_seed.sid:
 				# collision: throw out entire split list and redo with new start nonce
 				if parent_seed.cfg.debug_subseed:
-					self._collision_debug_msg(ls.sid, count, nonce, 'nonce_start', debug_last_share)
+					self._collision_debug_msg(
+						ls.sid, count, nonce, nonce_desc='nonce_start', debug_last_share=debug_last_share)
 			else:
 				self.data['long'][ls.sid] = (count, nonce)
 				break
@@ -118,7 +119,7 @@ class SeedShareList(SubSeedList):
 			B = self.join().data
 			assert A == B, f'Data mismatch!\noriginal seed: {A!r}\nrejoined seed: {B!r}'
 
-	def get_share_by_idx(self, idx, base_seed=False):
+	def get_share_by_idx(self, idx, *, base_seed=False):
 		if idx < 1 or idx > self.count:
 			die('RangeError', f'{idx}: share index out of range')
 		elif idx == self.count:
@@ -129,7 +130,7 @@ class SeedShareList(SubSeedList):
 			ss_idx = SubSeedIdx(str(idx) + 'L')
 			return self.get_subseed_by_ss_idx(ss_idx)
 
-	def get_share_by_seed_id(self, sid, base_seed=False):
+	def get_share_by_seed_id(self, sid, *, base_seed=False):
 		if sid == self.data['long'].key(self.count-1):
 			return self.last_share
 		elif self.master_share and sid == self.data['long'].key(0):
@@ -181,7 +182,7 @@ class SeedShareBase(MMGenObject):
 	def desc(self):
 		return self.get_desc()
 
-	def get_desc(self, ui=False):
+	def get_desc(self, *, ui=False):
 		pl = self.parent_list
 		mss = f', with master share #{pl.master_share.idx}' if pl.master_share else ''
 		if ui:
@@ -250,12 +251,11 @@ class SeedShareMaster(SeedBase, SeedShareBase):
 		self.parent_list = parent_list
 		self.cfg = parent_list.parent_seed.cfg
 
-		SeedBase.__init__(self, self.cfg, self.make_base_seed_bin())
+		SeedBase.__init__(self, self.cfg, seed_bin=self.make_base_seed_bin())
 
 		self.derived_seed = SeedBase(
 			self.cfg,
-			self.make_derived_seed_bin(parent_list.id_str, parent_list.count)
-		)
+			seed_bin = self.make_derived_seed_bin(parent_list.id_str, parent_list.count))
 
 	@property
 	def fn_stem(self):
@@ -274,7 +274,7 @@ class SeedShareMaster(SeedBase, SeedShareBase):
 		scramble_key = id_str.encode() + b':' + count.to_bytes(2, 'big')
 		return Crypto(self.cfg).scramble_seed(self.data, scramble_key)[:self.byte_len]
 
-	def get_desc(self, ui=False):
+	def get_desc(self, *, ui=False):
 		psid = self.parent_list.parent_seed.sid
 		mss = f'master share #{self.idx} of '
 		return yellow('(' + mss) + psid.hl() + yellow(')') if ui else mss + psid
@@ -291,11 +291,14 @@ class SeedShareMasterJoining(SeedShareMaster):
 		self.cfg = cfg
 		self.id_str = id_str or 'default'
 		self.count = count
-		self.derived_seed = SeedBase(cfg, self.make_derived_seed_bin(self.id_str, self.count))
+		self.derived_seed = SeedBase(
+			cfg,
+			seed_bin = self.make_derived_seed_bin(self.id_str, self.count))
 
 def join_shares(
 		cfg,
 		seed_list,
+		*,
 		master_idx = None,
 		id_str     = None):
 

+ 1 - 1
mmgen/sha2.py

@@ -68,7 +68,7 @@ class Sha2:
 		# First wordBits bits of the fractional parts of the cube roots of the first nRounds primes
 		cls.K = tuple(getFractionalBits(cbrt(n)) for n in primes)
 
-	def __init__(self, message, preprocess=True):
+	def __init__(self, message, *, preprocess=True):
 		'Use preprocess=False for Sha256Compress'
 		assert isinstance(message, (bytes, bytearray, list)), 'message must be of type bytes, bytearray or list'
 		if not self.K:

+ 5 - 5
mmgen/subseed.py

@@ -86,7 +86,7 @@ class SubSeedList(MMGenObject):
 	debug_last_share_sid_len = 3
 	dfl_len = 100
 
-	def __init__(self, parent_seed, length=None):
+	def __init__(self, parent_seed, *, length=None):
 		self.member_type = SubSeed
 		self.parent_seed = parent_seed
 		self.data = {'long': IndexedDict(), 'short': IndexedDict()}
@@ -95,7 +95,7 @@ class SubSeedList(MMGenObject):
 	def __len__(self):
 		return len(self.data['long'])
 
-	def get_subseed_by_ss_idx(self, ss_idx_in, print_msg=False):
+	def get_subseed_by_ss_idx(self, ss_idx_in, *, print_msg=False):
 		ss_idx = SubSeedIdx(ss_idx_in)
 		if print_msg:
 			msg_r('{} {} of {}...'.format(
@@ -122,7 +122,7 @@ class SubSeedList(MMGenObject):
 		assert seed.sid == sid, f'{seed.sid} != {sid}: Seed ID mismatch!'
 		return seed
 
-	def get_subseed_by_seed_id(self, sid, last_idx=None, print_msg=False):
+	def get_subseed_by_seed_id(self, sid, *, last_idx=None, print_msg=False):
 
 		def get_existing_subseed_by_seed_id(sid):
 			for k in ('long', 'short') if self.have_short else ('long',):
@@ -157,7 +157,7 @@ class SubSeedList(MMGenObject):
 			do_msg(subseed)
 			return subseed
 
-	def _collision_debug_msg(self, sid, idx, nonce, nonce_desc='nonce', debug_last_share=False):
+	def _collision_debug_msg(self, sid, idx, nonce, *, nonce_desc='nonce', debug_last_share=False):
 		slen = 'short' if sid in self.data['short'] else 'long'
 		m1 = f'add_subseed(idx={idx},{slen}):'
 		if sid == self.parent_seed.sid:
@@ -172,7 +172,7 @@ class SubSeedList(MMGenObject):
 			m2 = f'collision with ID {sid} (idx={colliding_idx},{slen}),'
 		msg(f'{m1:30} {m2:46} incrementing {nonce_desc} to {nonce+1}')
 
-	def _generate(self, last_idx=None, last_sid=None):
+	def _generate(self, last_idx=None, *, last_sid=None):
 
 		if last_idx is None:
 			last_idx = self.len

+ 1 - 1
mmgen/swap/proto/thorchain/memo.py

@@ -121,7 +121,7 @@ class Memo:
 
 		return ret(proto_name, function, chain, asset, address, limit_int, int(interval), int(quantity))
 
-	def __init__(self, proto, addr, chain=None, trade_limit=None):
+	def __init__(self, proto, addr, *, chain=None, trade_limit=None):
 		self.proto = proto
 		self.chain = chain or proto.coin
 		if trade_limit is None:

+ 2 - 2
mmgen/swap/proto/thorchain/thornode.py

@@ -22,7 +22,7 @@ class ThornodeRPCClient:
 	verify = True
 	timeout = 5
 
-	def __init__(self, tx, proto=None, host=None):
+	def __init__(self, tx, *, proto=None, host=None):
 		self.cfg = tx.cfg
 		if proto:
 			self.proto = proto
@@ -38,7 +38,7 @@ class ThornodeRPCClient:
 				'https': f'socks5h://{self.cfg.proxy}'
 			})
 
-	def get(self, path, timeout=None):
+	def get(self, path, *, timeout=None):
 		return self.session.get(
 			url     = self.proto + '://' + self.host + path,
 			timeout = timeout or self.timeout,

+ 8 - 8
mmgen/term.py

@@ -50,7 +50,7 @@ class MMGenTerm:
 		pass
 
 	@classmethod
-	def init(cls, noecho=False):
+	def init(cls, *, noecho=False):
 		pass
 
 	@classmethod
@@ -93,7 +93,7 @@ class MMGenTermLinux(MMGenTerm):
 		cls.cur_term = termios.tcgetattr(cls.stdin_fd)
 
 	@classmethod
-	def init(cls, noecho=False):
+	def init(cls, *, noecho=False):
 		cls.stdin_fd = sys.stdin.fileno()
 		cls.cur_term = termios.tcgetattr(cls.stdin_fd)
 		if not hasattr(cls, 'orig_term'):
@@ -128,7 +128,7 @@ class MMGenTermLinux(MMGenTerm):
 				break
 
 	@classmethod
-	def get_char(cls, prompt='', immed_chars='', prehold_protect=True, num_bytes=5):
+	def get_char(cls, prompt='', *, immed_chars='', prehold_protect=True, num_bytes=5):
 		"""
 		Use os.read(), not file.read(), to get a variable number of bytes without blocking.
 		Request 5 bytes to cover escape sequences generated by F1, F2, .. Fn keys (5 bytes)
@@ -169,7 +169,7 @@ class MMGenTermLinuxStub(MMGenTermLinux):
 		pass
 
 	@classmethod
-	def init(cls, noecho=False):
+	def init(cls, *, noecho=False):
 		cls.stdin_fd = sys.stdin.fileno()
 
 	@classmethod
@@ -181,7 +181,7 @@ class MMGenTermLinuxStub(MMGenTermLinux):
 		pass
 
 	@classmethod
-	def get_char(cls, prompt='', immed_chars='', prehold_protect=None, num_bytes=5):
+	def get_char(cls, prompt='', *, immed_chars='', prehold_protect=None, num_bytes=5):
 		msg_r(prompt)
 		return os.read(0, num_bytes).decode()
 
@@ -230,7 +230,7 @@ class MMGenTermMSWin(MMGenTerm):
 					return
 
 	@classmethod
-	def get_char(cls, prompt='', immed_chars='', prehold_protect=True, num_bytes=None):
+	def get_char(cls, prompt='', *, immed_chars='', prehold_protect=True, num_bytes=None):
 		"""
 		always return a single character, ignore num_bytes
 		first character of 2-character sequence returned by F1-F12 keys is discarded
@@ -268,7 +268,7 @@ class MMGenTermMSWin(MMGenTerm):
 class MMGenTermMSWinStub(MMGenTermMSWin):
 
 	@classmethod
-	def get_char(cls, prompt='', immed_chars='', prehold_protect=None, num_bytes=None):
+	def get_char(cls, prompt='', *, immed_chars='', prehold_protect=None, num_bytes=None):
 		"""
 		Use stdin to allow UTF-8 and emulate the one-character behavior of MMGenTermMSWin
 		"""
@@ -289,7 +289,7 @@ def get_term():
 		'win32': (MMGenTermMSWin if sys.stdin.isatty() else MMGenTermMSWinStub),
 	}[sys.platform]
 
-def init_term(cfg, noecho=False):
+def init_term(cfg, *, noecho=False):
 
 	term = get_term()
 

+ 1 - 1
mmgen/tool/coin.py

@@ -113,7 +113,7 @@ class tool_cmd(tool_cmd_base):
 			gd.ag.to_segwit_redeem_script(data),
 			gd.ag.to_addr(data))
 
-	def _privhex2out(self, privhex: 'sstr', output_pubhex=False):
+	def _privhex2out(self, privhex: 'sstr', *, output_pubhex=False):
 		gd = self._init_generators()
 		pk = PrivKey(
 			self.proto,

+ 1 - 1
mmgen/tool/common.py

@@ -31,7 +31,7 @@ class tool_cmd_base(MMGenObject):
 	need_addrtype = False
 	need_amt = False
 
-	def __init__(self, cfg, cmdname=None, proto=None, mmtype=None):
+	def __init__(self, cfg, *, cmdname=None, proto=None, mmtype=None):
 
 		self.cfg = cfg
 

+ 2 - 2
mmgen/tool/file.py

@@ -27,7 +27,7 @@ class tool_cmd(tool_cmd_base):
 
 	need_proto = True
 
-	def __init__(self, cfg, cmdname=None, proto=None, mmtype=None):
+	def __init__(self, cfg, *, cmdname=None, proto=None, mmtype=None):
 		if cmdname == 'txview':
 			self.need_amt = True
 		super().__init__(cfg=cfg, cmdname=cmdname, proto=proto, mmtype=mmtype)
@@ -36,7 +36,7 @@ class tool_cmd(tool_cmd_base):
 		kwargs = {'skip_chksum_msg':True}
 		if not obj.__name__ == 'PasswordList':
 			kwargs.update({'key_address_validity_check':False})
-		ret = obj(self.cfg, self.proto, mmgen_addrfile, **kwargs)
+		ret = obj(self.cfg, self.proto, infile=mmgen_addrfile, **kwargs)
 		if self.cfg.verbose:
 			from ..util import msg, capfirst
 			if ret.al_id.mmtype.name == 'password':

+ 8 - 8
mmgen/tool/filecrypt.py

@@ -35,20 +35,20 @@ class tool_cmd(tool_cmd_base):
 	* Enc: AES256_CTR, 16-byte rand IV, sha256 hash + 32-byte nonce + data
 	* The encrypted file is indistinguishable from random data
 	"""
-	def encrypt(self, infile: str, outfile='', hash_preset=''):
+	def encrypt(self, infile: str, *, outfile='', hash_preset=''):
 		"encrypt a file"
-		data = get_data_from_file(self.cfg, infile, 'data for encryption', binary=True)
-		enc_d = Crypto(self.cfg).mmgen_encrypt(data, 'data', hash_preset)
+		data = get_data_from_file(self.cfg, infile, desc='data for encryption', binary=True)
+		enc_d = Crypto(self.cfg).mmgen_encrypt(data, desc='data', hash_preset=hash_preset)
 		if not outfile:
 			outfile = f'{os.path.basename(infile)}.{Crypto.mmenc_ext}'
-		write_data_to_file(self.cfg, outfile, enc_d, 'encrypted data', binary=True)
+		write_data_to_file(self.cfg, outfile, enc_d, desc='encrypted data', binary=True)
 		return True
 
-	def decrypt(self, infile: str, outfile='', hash_preset=''):
+	def decrypt(self, infile: str, *, outfile='', hash_preset=''):
 		"decrypt a file"
-		enc_d = get_data_from_file(self.cfg, infile, 'encrypted data', binary=True)
+		enc_d = get_data_from_file(self.cfg, infile, desc='encrypted data', binary=True)
 		while True:
-			dec_d = Crypto(self.cfg).mmgen_decrypt(enc_d, 'data', hash_preset)
+			dec_d = Crypto(self.cfg).mmgen_decrypt(enc_d, desc='data', hash_preset=hash_preset)
 			if dec_d:
 				break
 			from ..util import msg
@@ -59,5 +59,5 @@ class tool_cmd(tool_cmd_base):
 			outfile = remove_extension(o, Crypto.mmenc_ext)
 			if outfile == o:
 				outfile += '.dec'
-		write_data_to_file(self.cfg, outfile, dec_d, 'decrypted data', binary=True)
+		write_data_to_file(self.cfg, outfile, dec_d, desc='decrypted data', binary=True)
 		return True

+ 5 - 4
mmgen/tool/fileutil.py

@@ -32,6 +32,7 @@ class tool_cmd(tool_cmd_base):
 	def find_incog_data(self,
 			filename: str,
 			incog_id: str,
+			*,
 			keep_searching: 'continue search after finding data (ID collisions can yield false positives)' = False):
 		"Use an Incog ID to find hidden incognito wallet data"
 
@@ -65,7 +66,7 @@ class tool_cmd(tool_cmd_base):
 		os.close(f)
 		return True
 
-	def rand2file(self, outfile: str, nbytes: str, threads=4, silent=False):
+	def rand2file(self, outfile: str, nbytes: str, *, threads=4, silent=False):
 		"""
 		write ‘nbytes’ bytes of random data to specified file (dd-style byte specifiers supported)
 
@@ -148,7 +149,7 @@ class tool_cmd(tool_cmd_base):
 
 		return True
 
-	def decrypt_keystore(self, wallet_file: str, output_hex=False):
+	def decrypt_keystore(self, wallet_file: str, *, output_hex=False):
 		"decrypt the data in a keystore wallet, returning the decrypted data in binary format"
 		from ..ui import line_input
 		passwd = line_input(self.cfg, 'Enter passphrase: ', echo=self.cfg.echo_passphrase).strip().encode()
@@ -159,9 +160,9 @@ class tool_cmd(tool_cmd_base):
 		ret = decrypt_keystore(data[0]['keystore'], passwd)
 		return ret.hex() if output_hex else ret
 
-	def decrypt_geth_keystore(self, wallet_file: str, check_addr=True):
+	def decrypt_geth_keystore(self, wallet_file: str, *, check_addr=True):
 		"decrypt the private key in a Geth keystore wallet, returning the decrypted key in hex format"
 		from ..ui import line_input
 		passwd = line_input(self.cfg, 'Enter passphrase: ', echo=self.cfg.echo_passphrase).strip().encode()
 		from ..proto.eth.misc import decrypt_geth_keystore
-		return decrypt_geth_keystore(self.cfg, wallet_file, passwd, check_addr).hex()
+		return decrypt_geth_keystore(self.cfg, wallet_file, passwd, check_addr=check_addr).hex()

+ 1 - 1
mmgen/tool/help.py

@@ -183,7 +183,7 @@ def gen_tool_cmd_usage(mod, cmdname):
 		for line in docstr.split('\n')[1:]:
 			yield line.lstrip('\t')
 
-def usage(cmdname=None, exit_val=1):
+def usage(cmdname=None, *, exit_val=1):
 
 	from ..util import Msg, die
 

+ 4 - 2
mmgen/tool/mnemonic.py

@@ -98,14 +98,15 @@ class tool_cmd(tool_cmd_base):
 		if fmt == 'xmrseed':
 			hexstr = self._xmr_reduce(bytes.fromhex(hexstr)).hex()
 		f = mnemonic_fmts[fmt]
-		return ' '.join(f.conv_cls(fmt).fromhex(hexstr, f.pad))
+		return ' '.join(f.conv_cls(fmt).fromhex(hexstr, pad=f.pad))
 
 	def mn2hex(self, seed_mnemonic: 'sstr', fmt:mn_opts_disp = dfl_mnemonic_fmt):
 		"convert a mnemonic seed phrase to a hexadecimal string"
 		f = mnemonic_fmts[fmt]
-		return f.conv_cls(fmt).tohex(seed_mnemonic.split(), f.pad)
+		return f.conv_cls(fmt).tohex(seed_mnemonic.split(), pad=f.pad)
 
 	def mn2hex_interactive(self,
+			*,
 			fmt: mn_opts_disp = dfl_mnemonic_fmt,
 			mn_len: 'length of seed phrase in words' = 24,
 			print_mn: 'print the seed phrase after entry' = False):
@@ -122,6 +123,7 @@ class tool_cmd(tool_cmd_base):
 		return mnemonic_fmts[fmt].conv_cls(fmt).check_wordlist(self.cfg)
 
 	def mn_printlist(self,
+			*,
 			fmt: mn_opts_disp = dfl_mnemonic_fmt,
 			enum: 'enumerate the list' = False,
 			pager: 'send output to pager' = False):

+ 15 - 8
mmgen/tool/rpc.py

@@ -50,13 +50,14 @@ class tool_cmd(tool_cmd_base):
 			r = await rpc_init(self.cfg, self.proto, ignore_daemon_version=True, ignore_wallet=True)
 		return f'{d.coind_name} version {r.daemon_version} ({r.daemon_version_str})'
 
-	async def getbalance(self,
+	async def getbalance(self, *,
 			minconf: 'minimum number of confirmations' = 1,
 			quiet:   'produce quieter output' = False,
 			pager:   'send output to pager' = False):
 		"list confirmed/unconfirmed, spendable/unspendable balances in tracking wallet"
 		from ..tw.bal import TwGetBalance
-		return (await TwGetBalance(self.cfg, self.proto, minconf, quiet)).format(color=self.cfg.color)
+		return (await TwGetBalance(
+			self.cfg, self.proto, minconf=minconf, quiet=quiet)).format(color=self.cfg.color)
 
 	async def twops(self,
 			obj, pager, reverse, detail, sort, age_fmt, interactive,
@@ -81,7 +82,7 @@ class tool_cmd(tool_cmd_base):
 
 		return ret
 
-	async def twview(self,
+	async def twview(self, *,
 			pager:       'send output to pager' = False,
 			reverse:     'reverse order of unspent outputs' = False,
 			wide:        'display data in wide tabular format' = False,
@@ -98,7 +99,7 @@ class tool_cmd(tool_cmd_base):
 			obj, pager, reverse, wide, sort, age_fmt, interactive,
 			show_mmid = show_mmid)
 
-	async def txhist(self,
+	async def txhist(self, *,
 			pager:       'send output to pager' = False,
 			reverse:     'reverse order of transactions' = False,
 			detail:      'produce detailed, non-tabular output' = False,
@@ -114,6 +115,7 @@ class tool_cmd(tool_cmd_base):
 
 	async def listaddress(self,
 			mmgen_addr: str,
+			*,
 			wide:         'display data in wide tabular format' = False,
 			minconf:      'minimum number of confirmations' = 1,
 			showcoinaddr: 'display coin address in addition to MMGen ID' = True,
@@ -127,7 +129,7 @@ class tool_cmd(tool_cmd_base):
 			showcoinaddrs = showcoinaddr,
 			age_fmt       = age_fmt)
 
-	async def listaddresses(self,
+	async def listaddresses(self, *,
 			pager:        'send output to pager' = False,
 			reverse:      'reverse order of unspent outputs' = False,
 			wide:         'display data in wide tabular format' = False,
@@ -188,7 +190,7 @@ class tool_cmd(tool_cmd_base):
 		from ..tw.ctl import TwCtl
 		return await (await TwCtl(self.cfg, self.proto, mode='w')).rescan_address(mmgen_or_coin_addr)
 
-	async def rescan_blockchain(self,
+	async def rescan_blockchain(self, *,
 			start_block: int = None,
 			stop_block: int  = None):
 		"""
@@ -204,7 +206,12 @@ class tool_cmd(tool_cmd_base):
 		await (await TwCtl(self.cfg, self.proto, mode='w')).rescan_blockchain(start_block, stop_block)
 		return True
 
-	async def twexport(self, include_amts=True, pretty=False, prune=False, warn_used=False, force=False):
+	async def twexport(self, *,
+			include_amts = True,
+			pretty       = False,
+			prune        = False,
+			warn_used    = False,
+			force        = False):
 		"""
 		export a tracking wallet to JSON format
 
@@ -237,7 +244,7 @@ class tool_cmd(tool_cmd_base):
 			force_overwrite = force)
 		return True
 
-	async def twimport(self, filename: str, ignore_checksum=False, batch=False):
+	async def twimport(self, filename: str, *, ignore_checksum=False, batch=False):
 		"""
 		restore a tracking wallet from a JSON dump created by ‘twexport’
 

+ 16 - 12
mmgen/tool/util.py

@@ -57,6 +57,7 @@ class tool_cmd(tool_cmd_base):
 	def to_bytespec(self,
 			n: int,
 			dd_style_byte_specifier: str,
+			*,
 			fmt:       'width and precision of output' = '0.2',
 			print_sym: 'print the specifier after the numerical value' = True,
 			strip:     'strip trailing zeroes' = False,
@@ -113,6 +114,7 @@ class tool_cmd(tool_cmd_base):
 
 	def hexdump(self,
 			infile: str,
+			*,
 			cols:      'number of columns in output' = 8,
 			line_nums: "format for line numbers (valid choices: 'hex','dec')" = 'hex'):
 		"create hexdump of data from file (use '-' for stdin)"
@@ -139,6 +141,7 @@ class tool_cmd(tool_cmd_base):
 	# TODO: handle stdin
 	def hash256(self,
 			data: str,
+			*,
 			file_input: 'first arg is the name of a file containing the data' = False,
 			hex_input:  'first arg is a hexadecimal string' = False):
 		"compute sha256(sha256(data)) (double sha256)"
@@ -172,7 +175,7 @@ class tool_cmd(tool_cmd_base):
 		return make_chksum_8(
 			get_data_from_file(self.cfg, infile, dash=True, quiet=True, binary=True))
 
-	def randb58(self,
+	def randb58(self, *,
 			nbytes: 'number of bytes to output' = 32,
 			pad:    'pad output to this width' = 0):
 		"generate random data (default: 32 bytes) and convert it to base 58"
@@ -180,24 +183,24 @@ class tool_cmd(tool_cmd_base):
 		from ..crypto import Crypto
 		return baseconv('b58').frombytes(Crypto(self.cfg).get_random(nbytes), pad=pad, tostr=True)
 
-	def bytestob58(self, infile: str, pad: 'pad output to this width' = 0):
+	def bytestob58(self, infile: str, *, pad: 'pad output to this width' = 0):
 		"convert bytes to base 58 (supply data via STDIN)"
 		from ..fileutil import get_data_from_file
 		from ..baseconv import baseconv
 		data = get_data_from_file(self.cfg, infile, dash=True, quiet=True, binary=True)
 		return baseconv('b58').frombytes(data, pad=pad, tostr=True)
 
-	def b58tobytes(self, b58_str: 'sstr', pad: 'pad output to this width' = 0):
+	def b58tobytes(self, b58_str: 'sstr', *, pad: 'pad output to this width' = 0):
 		"convert a base 58 string to bytes (warning: outputs binary data)"
 		from ..baseconv import baseconv
 		return baseconv('b58').tobytes(b58_str, pad=pad)
 
-	def hextob58(self, hexstr: 'sstr', pad: 'pad output to this width' = 0):
+	def hextob58(self, hexstr: 'sstr', *, pad: 'pad output to this width' = 0):
 		"convert a hexadecimal string to base 58"
 		from ..baseconv import baseconv
 		return baseconv('b58').fromhex(hexstr, pad=pad, tostr=True)
 
-	def b58tohex(self, b58_str: 'sstr', pad: 'pad output to this width' = 0):
+	def b58tohex(self, b58_str: 'sstr', *, pad: 'pad output to this width' = 0):
 		"convert a base 58 string to hexadecimal"
 		from ..baseconv import baseconv
 		return baseconv('b58').tohex(b58_str, pad=pad)
@@ -212,28 +215,29 @@ class tool_cmd(tool_cmd_base):
 		from ..proto.btc.common import b58chk_decode
 		return b58chk_decode(b58chk_str).hex()
 
-	def hextob32(self, hexstr: 'sstr', pad: 'pad output to this width' = 0):
+	def hextob32(self, hexstr: 'sstr', *, pad: 'pad output to this width' = 0):
 		"convert a hexadecimal string to an MMGen-flavor base 32 string"
 		from ..baseconv import baseconv
-		return baseconv('b32').fromhex(hexstr, pad, tostr=True)
+		return baseconv('b32').fromhex(hexstr, pad=pad, tostr=True)
 
-	def b32tohex(self, b32_str: 'sstr', pad: 'pad output to this width' = 0):
+	def b32tohex(self, b32_str: 'sstr', *, pad: 'pad output to this width' = 0):
 		"convert an MMGen-flavor base 32 string to hexadecimal"
 		from ..baseconv import baseconv
-		return baseconv('b32').tohex(b32_str.upper(), pad)
+		return baseconv('b32').tohex(b32_str.upper(), pad=pad)
 
 	def hextob6d(self,
 			hexstr: 'sstr',
+			*,
 			pad: 'pad output to this width' = 0,
 			add_spaces: 'add a space after every 5th character' = True):
 		"convert a hexadecimal string to die roll base6 (base6d)"
 		from ..baseconv import baseconv
 		from ..util2 import block_format
-		ret = baseconv('b6d').fromhex(hexstr, pad, tostr=True)
+		ret = baseconv('b6d').fromhex(hexstr, pad=pad, tostr=True)
 		return block_format(ret, gw=5, cols=None).strip() if add_spaces else ret
 
-	def b6dtohex(self, b6d_str: 'sstr', pad: 'pad output to this width' = 0):
+	def b6dtohex(self, b6d_str: 'sstr', *, pad: 'pad output to this width' = 0):
 		"convert a die roll base6 (base6d) string to hexadecimal"
 		from ..baseconv import baseconv
 		from ..util import remove_whitespace
-		return baseconv('b6d').tohex(remove_whitespace(b6d_str), pad)
+		return baseconv('b6d').tohex(remove_whitespace(b6d_str), pad=pad)

+ 19 - 16
mmgen/tool/wallet.py

@@ -29,7 +29,7 @@ from ..wallet import Wallet
 class tool_cmd(tool_cmd_base):
 	"key, address or subseed generation from an MMGen wallet"
 
-	def __init__(self, cfg, cmdname=None, proto=None, mmtype=None):
+	def __init__(self, cfg, *, cmdname=None, proto=None, mmtype=None):
 		self.need_proto = cmdname in ('gen_key', 'gen_addr')
 		super().__init__(cfg, cmdname=cmdname, proto=proto, mmtype=mmtype)
 
@@ -40,49 +40,52 @@ class tool_cmd(tool_cmd_base):
 			wallets = [wallet] if wallet else [],
 			nargs   = 1)
 
-	def get_subseed(self, subseed_idx: str, wallet=''):
+	def get_subseed(self, subseed_idx: str, *, wallet=''):
 		"get the Seed ID of a single subseed by Subseed Index for default or specified wallet"
 		self.cfg._set_quiet(True)
-		return Wallet(self.cfg, self._get_seed_file(wallet)).seed.subseed(subseed_idx).sid
+		return Wallet(self.cfg, fn=self._get_seed_file(wallet)).seed.subseed(subseed_idx).sid
 
-	def get_subseed_by_seed_id(self, seed_id: str, wallet='', last_idx=SubSeedList.dfl_len):
+	def get_subseed_by_seed_id(self, seed_id: str, *, wallet='', last_idx=SubSeedList.dfl_len):
 		"get the Subseed Index of a single subseed by Seed ID for default or specified wallet"
 		self.cfg._set_quiet(True)
-		ret = Wallet(self.cfg, self._get_seed_file(wallet)).seed.subseed_by_seed_id(seed_id, last_idx)
+		ret = Wallet(
+			self.cfg,
+			fn = self._get_seed_file(wallet)).seed.subseed_by_seed_id(seed_id, last_idx=last_idx)
 		return ret.ss_idx if ret else None
 
-	def list_subseeds(self, subseed_idx_range: str, wallet=''):
+	def list_subseeds(self, subseed_idx_range: str, *, wallet=''):
 		"list a range of subseed Seed IDs for default or specified wallet"
 		self.cfg._set_quiet(True)
 		from ..subseed import SubSeedIdxRange
-		return Wallet(self.cfg, self._get_seed_file(wallet)).seed.subseeds.format(
+		return Wallet(self.cfg, fn=self._get_seed_file(wallet)).seed.subseeds.format(
 			*SubSeedIdxRange(subseed_idx_range))
 
 	def list_shares(self,
 			share_count: int,
+			*,
 			id_str = 'default',
 			master_share: f'(min:1, max:{MasterShareIdx.max_val}, 0=no master share)' = 0,
 			wallet = ''):
 		"list the Seed IDs of the shares resulting from a split of default or specified wallet"
 		self.cfg._set_quiet(True)
-		return Wallet(self.cfg, self._get_seed_file(wallet)).seed.split(
-			share_count, id_str, master_share).format()
+		return Wallet(self.cfg, fn=self._get_seed_file(wallet)).seed.split(
+			share_count, id_str=id_str, master_idx=master_share).format()
 
-	def gen_key(self, mmgen_addr: str, wallet=''):
+	def gen_key(self, mmgen_addr: str, *, wallet=''):
 		"generate a single WIF key for specified MMGen address from default or specified wallet"
-		return self._gen_keyaddr(mmgen_addr, 'wif', wallet)
+		return self._gen_keyaddr(mmgen_addr, 'wif', wallet=wallet)
 
-	def gen_addr(self, mmgen_addr: str, wallet=''):
+	def gen_addr(self, mmgen_addr: str, *, wallet=''):
 		"generate a single MMGen address from default or specified wallet"
-		return self._gen_keyaddr(mmgen_addr, 'addr', wallet)
+		return self._gen_keyaddr(mmgen_addr, 'addr', wallet=wallet)
 
-	def _gen_keyaddr(self, mmgen_addr, target, wallet=''):
+	def _gen_keyaddr(self, mmgen_addr, target, *, wallet=''):
 		from ..addr import MMGenID
 		from ..addrlist import AddrList, AddrIdxList
 
 		addr = MMGenID(self.proto, mmgen_addr)
 		self.cfg._set_quiet(True)
-		ss = Wallet(self.cfg, self._get_seed_file(wallet))
+		ss = Wallet(self.cfg, fn=self._get_seed_file(wallet))
 
 		if ss.seed.sid != addr.sid:
 			from ..util import die
@@ -92,7 +95,7 @@ class tool_cmd(tool_cmd_base):
 			cfg       = self.cfg,
 			proto     = self.proto,
 			seed      = ss.seed,
-			addr_idxs = AddrIdxList(str(addr.idx)),
+			addr_idxs = AddrIdxList(fmt_str=str(addr.idx)),
 			mmtype    = addr.mmtype,
 			skip_chksum = True).data[0]
 

+ 14 - 13
mmgen/tw/addresses.py

@@ -68,7 +68,7 @@ class TwAddresses(TwView):
 	def coinaddr_list(self):
 		return [d.addr for d in self.data]
 
-	async def __init__(self, cfg, proto, minconf=1, mmgen_addrs='', get_data=False):
+	async def __init__(self, cfg, proto, *, minconf=1, mmgen_addrs='', get_data=False):
 
 		await super().__init__(cfg, proto)
 
@@ -82,7 +82,7 @@ class TwAddresses(TwView):
 					f'{mmgen_addrs}: invalid address list argument ' +
 					'(must be in form <seed ID>:[<type>:]<idx list>)')
 			from ..addrlist import AddrIdxList
-			self.usr_addr_list = [MMGenID(self.proto, f'{a[0]}:{i}') for i in AddrIdxList(a[1])]
+			self.usr_addr_list = [MMGenID(self.proto, f'{a[0]}:{i}') for i in AddrIdxList(fmt_str=a[1])]
 		else:
 			self.usr_addr_list = []
 
@@ -174,22 +174,22 @@ class TwAddresses(TwView):
 	def squeezed_format_line(self, n, d, cw, fs, color, yes, no):
 		return fs.format(
 			n = str(n) + ')',
-			m = d.twmmid.fmt(width=cw.mmid, color=color),
+			m = d.twmmid.fmt(cw.mmid, color=color),
 			u = yes if d.recvd else no,
-			a = d.addr.fmt(self.addr_view_pref, width=cw.addr, color=color),
-			c = d.comment.fmt2(width=cw.comment, color=color, nullrepl='-'),
-			A = d.amt.fmt(color=color, iwidth=cw.iwidth, prec=self.disp_prec),
+			a = d.addr.fmt(self.addr_view_pref, cw.addr, color=color),
+			c = d.comment.fmt2(cw.comment, color=color, nullrepl='-'),
+			A = d.amt.fmt(cw.iwidth, color=color, prec=self.disp_prec),
 			d = self.age_disp(d, self.age_fmt)
 		)
 
 	def detail_format_line(self, n, d, cw, fs, color, yes, no):
 		return fs.format(
 			n = str(n) + ')',
-			m = d.twmmid.fmt(width=cw.mmid, color=color),
+			m = d.twmmid.fmt(cw.mmid, color=color),
 			u = yes if d.recvd else no,
-			a = d.addr.fmt(self.addr_view_pref, width=cw.addr, color=color),
-			c = d.comment.fmt2(width=cw.comment, color=color, nullrepl='-'),
-			A = d.amt.fmt(color=color, iwidth=cw.iwidth, prec=self.disp_prec),
+			a = d.addr.fmt(self.addr_view_pref, cw.addr, color=color),
+			c = d.comment.fmt2(cw.comment, color=color, nullrepl='-'),
+			A = d.amt.fmt(cw.iwidth, color=color, prec=self.disp_prec),
 			b = self.age_disp(d, 'block'),
 			D = self.age_disp(d, 'date_time'))
 
@@ -274,7 +274,7 @@ class TwAddresses(TwView):
 				return bool(e.recvd)
 		return None # addr not in tracking wallet
 
-	def get_change_address(self, al_id, bot=None, top=None, exclude=None, desc=None):
+	def get_change_address(self, al_id, *, bot=None, top=None, exclude=None, desc=None):
 		"""
 		Get lowest-indexed unused address in tracking wallet for requested AddrListID.
 		Return values on failure:
@@ -333,7 +333,7 @@ class TwAddresses(TwView):
 					break
 			return False
 
-	def get_change_address_by_addrtype(self, mmtype, exclude, desc):
+	def get_change_address_by_addrtype(self, mmtype, *, exclude, desc):
 		"""
 		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.
@@ -366,7 +366,8 @@ class TwAddresses(TwView):
 				msg(f'{res}: invalid entry')
 
 		def get_addr(mmtype):
-			return [self.get_change_address(f'{sid}:{mmtype}', r.bot, r.top, exclude=exclude, desc=desc)
+			return [self.get_change_address(
+				f'{sid}:{mmtype}', bot=r.bot, top=r.top, exclude=exclude, desc=desc)
 					for sid, r in self.sid_ranges.items()]
 
 		assert isinstance(mmtype, (type(None), MMGenAddrType))

+ 3 - 3
mmgen/tw/bal.py

@@ -26,10 +26,10 @@ from ..obj import NonNegativeInt
 
 class TwGetBalance(MMGenObject, metaclass=AsyncInit):
 
-	def __new__(cls, cfg, proto, *args, **kwargs):
+	def __new__(cls, cfg, proto, *, minconf, quiet):
 		return MMGenObject.__new__(proto.base_proto_subclass(cls, 'tw.bal'))
 
-	async def __init__(self, cfg, proto, minconf, quiet):
+	async def __init__(self, cfg, proto, *, minconf, quiet):
 
 		class BalanceInfo(dict):
 			def __init__(self):
@@ -65,7 +65,7 @@ class TwGetBalance(MMGenObject, metaclass=AsyncInit):
 					return len(str(int(max(v[colname] for v in self.data.values())))) + iwidth_adj
 
 				def make_col(label, col):
-					return self.data[label][col].fmt(iwidth=iwidths[col], color=color)
+					return self.data[label][col].fmt(iwidths[col], color=color)
 
 				if color:
 					from ..color import green, yellow

+ 8 - 6
mmgen/tw/ctl.py

@@ -63,6 +63,7 @@ class TwCtl(MMGenObject, metaclass=AsyncInit):
 			self,
 			cfg,
 			proto,
+			*,
 			mode              = 'r',
 			token_addr        = None,
 			no_rpc            = False,
@@ -167,7 +168,7 @@ class TwCtl(MMGenObject, metaclass=AsyncInit):
 	def data_root_desc(self):
 		return self.data_key
 
-	def cache_balance(self, addr, bal, session_cache, data_root, force=False):
+	def cache_balance(self, addr, bal, *, session_cache, data_root, force=False):
 		if force or addr not in session_cache:
 			session_cache[addr] = str(bal)
 			if addr in data_root:
@@ -183,11 +184,11 @@ class TwCtl(MMGenObject, metaclass=AsyncInit):
 		if addr in data_root and 'balance' in data_root[addr]:
 			return self.proto.coin_amt(data_root[addr]['balance'])
 
-	async def get_balance(self, addr, force_rpc=False):
+	async def get_balance(self, addr, *, force_rpc=False):
 		ret = None if force_rpc else self.get_cached_balance(addr, self.cur_balances, self.data_root)
 		if ret is None:
 			ret = await self.rpc_get_balance(addr)
-			self.cache_balance(addr, ret, self.cur_balances, self.data_root)
+			self.cache_balance(addr, ret, session_cache=self.cur_balances, data_root=self.data_root)
 		return ret
 
 	def force_write(self):
@@ -212,7 +213,7 @@ class TwCtl(MMGenObject, metaclass=AsyncInit):
 
 		self.orig_data = data
 
-	def write(self, quiet=True):
+	def write(self, *, quiet=True):
 		if not self.use_tw_file:
 			self.cfg._util.dmsg("'use_tw_file' is False, doing nothing")
 			return
@@ -256,6 +257,7 @@ class TwCtl(MMGenObject, metaclass=AsyncInit):
 			self,
 			addrspec,
 			comment      = '',
+			*,
 			trusted_pair = None,
 			silent       = False):
 
@@ -296,11 +298,11 @@ class TwCtl(MMGenObject, metaclass=AsyncInit):
 	async def remove_comment(self, mmaddr):
 		await self.set_comment(mmaddr, '')
 
-	async def import_address_common(self, data, batch=False, gather=False):
+	async def import_address_common(self, data, *, batch=False, gather=False):
 
 		async def do_import(address, comment, message):
 			try:
-				res = await self.import_address(address, comment)
+				res = await self.import_address(address, label=comment)
 				self.cfg._util.qmsg(message)
 				return res
 			except Exception as e:

+ 5 - 2
mmgen/tw/json.py

@@ -30,7 +30,8 @@ class TwJSON:
 		fn_pfx = 'mmgen-tracking-wallet-dump'
 
 		def __new__(cls, cfg, proto, *args, **kwargs):
-			return MMGenObject.__new__(proto.base_proto_subclass(TwJSON, 'tw.json', cls.__name__))
+			return MMGenObject.__new__(
+				proto.base_proto_subclass(TwJSON, 'tw.json', sub_clsname=cls.__name__))
 
 		def __init__(self, cfg, proto):
 			self.cfg = cfg
@@ -62,7 +63,7 @@ class TwJSON:
 
 			return fn
 
-		def json_dump(self, data, pretty=False):
+		def json_dump(self, data, *, pretty=False):
 			return json.dumps(
 				data,
 				cls        = json_encoder,
@@ -90,6 +91,7 @@ class TwJSON:
 				cfg,
 				proto,
 				filename,
+				*,
 				ignore_checksum = False,
 				batch           = False):
 
@@ -163,6 +165,7 @@ class TwJSON:
 				self,
 				cfg,
 				proto,
+				*,
 				include_amts    = True,
 				pretty          = False,
 				prune           = False,

+ 2 - 2
mmgen/tw/shared.py

@@ -45,8 +45,8 @@ class TwMMGenID(HiliteStr, InitErrors, MMGenObject):
 		me.proto = proto
 		return me
 
-	def fmt(self, **kwargs):
-		return super().fmtc(self.disp, **kwargs)
+	def fmt(self, width, /, **kwargs):
+		return super().fmtc(self.disp, width, **kwargs)
 
 # non-displaying container for TwMMGenID, TwComment
 class TwLabel(str, InitErrors, MMGenObject):

+ 4 - 4
mmgen/tw/txhistory.py

@@ -41,7 +41,7 @@ class TwTxHistory(TwView):
 	filters = ('show_unconfirmed',)
 	mod_subpath = 'tw.txhistory'
 
-	async def __init__(self, cfg, proto, sinceblock=0):
+	async def __init__(self, cfg, proto, *, sinceblock=0):
 		await super().__init__(cfg, proto)
 		self.sinceblock = NonNegativeInt(sinceblock if sinceblock >= 0 else self.rpc.blockcount + sinceblock)
 
@@ -57,7 +57,7 @@ class TwTxHistory(TwView):
 		amts_tuple = namedtuple('amts_data', ['amt'])
 		return super().set_amt_widths([amts_tuple(d.amt_disp(self.show_total_amt)) for d in data])
 
-	def get_column_widths(self, data, wide, interactive):
+	def get_column_widths(self, data, *, wide, interactive):
 
 		# var cols: inputs outputs comment [txid]
 		if not hasattr(self, 'varcol_maxwidths'):
@@ -132,9 +132,9 @@ class TwTxHistory(TwView):
 				t = d.txid_disp(width=cw.txid, color=color) if hasattr(cw, 'txid') else None,
 				d = d.age_disp(self.age_fmt, width=self.age_w, color=color),
 				i = d.vouts_disp('inputs', width=cw.inputs, color=color, addr_view_pref=self.addr_view_pref),
-				A = d.amt_disp(self.show_total_amt).fmt(iwidth=cw.iwidth, prec=self.disp_prec, color=color),
+				A = d.amt_disp(self.show_total_amt).fmt(cw.iwidth, prec=self.disp_prec, color=color),
 				o = d.vouts_disp('outputs', width=cw.outputs, color=color, addr_view_pref=self.addr_view_pref),
-				c = d.comment.fmt2(width=cw.comment, color=color, nullrepl='-'))
+				c = d.comment.fmt2(cw.comment, color=color, nullrepl='-'))
 
 	def gen_detail_display(self, data, cw, fs, color, fmt_method):
 

+ 20 - 19
mmgen/tw/unspent.py

@@ -70,7 +70,7 @@ class TwUnspentOutputs(TwView):
 			self.__dict__['proto'] = proto
 			MMGenListItem.__init__(self, **kwargs)
 
-	async def __init__(self, cfg, proto, minconf=1, addrs=[]):
+	async def __init__(self, cfg, proto, *, minconf=1, addrs=[]):
 		await super().__init__(cfg, proto)
 		self.minconf  = minconf
 		self.addrs    = addrs
@@ -177,16 +177,16 @@ class TwUnspentOutputs(TwView):
 		for n, d in enumerate(data):
 			yield fs.format(
 				n = str(n+1) + ')',
-				t = (d.txid.fmtc('|' + '.'*(cw.txid-1), width=cw.txid, color=color) if d.skip  == 'txid'
-					else d.txid.truncate(width=cw.txid, color=color)) if cw.txid else None,
-				v = ' ' + d.vout.fmt(width=cw.vout-1, color=color) if cw.vout else None,
-				a = d.addr.fmtc('|' + '.'*(cw.addr-1), width=cw.addr, color=color) if d.skip == 'addr'
-					else d.addr.fmt(self.addr_view_pref, width=cw.addr, color=color),
-				m = (d.twmmid.fmtc('.'*cw.mmid, width=cw.mmid, color=color) if d.skip == 'addr'
-					else d.twmmid.fmt(width=cw.mmid, color=color)) if cw.mmid else None,
-				c = d.comment.fmt2(width=cw.comment, color=color, nullrepl='-') if cw.comment else None,
-				A = d.amt.fmt(color=color, iwidth=cw.iwidth, prec=self.disp_prec),
-				B = d.amt2.fmt(color=color, iwidth=cw.iwidth2, prec=self.disp_prec) if cw.amt2 else None,
+				t = (d.txid.fmtc('|' + '.'*(cw.txid-1), cw.txid, color=color) if d.skip  == 'txid'
+					else d.txid.truncate(cw.txid, color=color)) if cw.txid else None,
+				v = ' ' + d.vout.fmt(cw.vout-1, color=color) if cw.vout else None,
+				a = d.addr.fmtc('|' + '.'*(cw.addr-1), cw.addr, color=color) if d.skip == 'addr'
+					else d.addr.fmt(self.addr_view_pref, cw.addr, color=color),
+				m = (d.twmmid.fmtc('.'*cw.mmid, cw.mmid, color=color) if d.skip == 'addr'
+					else d.twmmid.fmt(cw.mmid, color=color)) if cw.mmid else None,
+				c = d.comment.fmt2(cw.comment, color=color, nullrepl='-') if cw.comment else None,
+				A = d.amt.fmt(cw.iwidth, color=color, prec=self.disp_prec),
+				B = d.amt2.fmt(cw.iwidth2, color=color, prec=self.disp_prec) if cw.amt2 else None,
 				d = self.age_disp(d, self.age_fmt),
 			)
 
@@ -195,21 +195,22 @@ class TwUnspentOutputs(TwView):
 		for n, d in enumerate(data):
 			yield fs.format(
 				n = str(n+1) + ')',
-				t = d.txid.fmt(width=cw.txid, color=color) if cw.txid else None,
-				v = ' ' + d.vout.fmt(width=cw.vout-1, color=color) if cw.vout else None,
-				a = d.addr.fmt(self.addr_view_pref, width=cw.addr, color=color),
-				m = d.twmmid.fmt(width=cw.mmid, color=color),
-				A = d.amt.fmt(color=color, iwidth=cw.iwidth, prec=self.disp_prec),
-				B = d.amt2.fmt(color=color, iwidth=cw.iwidth2, prec=self.disp_prec) if cw.amt2 else None,
+				t = d.txid.fmt(cw.txid, color=color) if cw.txid else None,
+				v = ' ' + d.vout.fmt(cw.vout-1, color=color) if cw.vout else None,
+				a = d.addr.fmt(self.addr_view_pref, cw.addr, color=color),
+				m = d.twmmid.fmt(cw.mmid, color=color),
+				A = d.amt.fmt(cw.iwidth, color=color, prec=self.disp_prec),
+				B = d.amt2.fmt(cw.iwidth2, color=color, prec=self.disp_prec) if cw.amt2 else None,
 				b = self.age_disp(d, 'block'),
 				D = self.age_disp(d, 'date_time'),
-				c = d.comment.fmt2(width=cw.comment, color=color, nullrepl='-'))
+				c = d.comment.fmt2(cw.comment, color=color, nullrepl='-'))
 
 	def display_total(self):
-		msg('\nTotal unspent: {} {} ({} output{})'.format(
+		msg('\nTotal unspent: {} {} ({} {}{})'.format(
 			self.total.hl(),
 			self.proto.dcoin,
 			len(self.data),
+			self.item_desc,
 			suf(self.data)))
 
 	async def set_dates(self, us):

+ 6 - 5
mmgen/tw/view.py

@@ -250,14 +250,14 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 		'twmmid': lambda i: '{} {:010} {:024.12f}'.format(i.twmmid.sort_key, 0xffffffff - abs(i.confs), i.amt)
 	}
 
-	def sort_info(self, include_group=True):
+	def sort_info(self, *, include_group=True):
 		ret = ([], ['Reverse'])[self.reverse]
 		ret.append(self.sort_disp[self.sort_key])
 		if include_group and self.group and (self.sort_key in ('addr', 'txid', 'twmmid')):
 			ret.append('Grouped')
 		return ret
 
-	def do_sort(self, key=None, reverse=False):
+	def do_sort(self, key=None, *, reverse=False):
 		key = key or self.sort_key
 		if key not in self.sort_funcs:
 			die(1, f'{key!r}: invalid sort key.  Valid options: {" ".join(self.sort_funcs)}')
@@ -268,7 +268,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 		if self.data != save:
 			self.pos = 0
 
-	async def get_data(self, sort_key=None, reverse_sort=False):
+	async def get_data(self, *, sort_key=None, reverse_sort=False):
 
 		rpc_data = await self.get_rpc_data()
 
@@ -290,7 +290,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 		# so add NL here (' ' required because CUR_HOME erases preceding blank lines)
 		msg(' ')
 
-	def get_term_dimensions(self, min_cols, min_lines=None):
+	def get_term_dimensions(self, min_cols, *, min_lines=None):
 		from ..term import get_terminal_size, get_char_raw, _term_dimensions
 		user_resized = False
 		while True:
@@ -311,7 +311,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 			else:
 				return _term_dimensions(min_cols, ts.height)
 
-	def compute_column_widths(self, widths, maxws, minws, maxws_nice, wide, interactive):
+	def compute_column_widths(self, widths, maxws, minws, maxws_nice, *, wide, interactive):
 
 		def do_ret(freews):
 			widths.update({k:minws[k] + freews.get(k, 0) for k in minws})
@@ -390,6 +390,7 @@ class TwView(MMGenObject, metaclass=AsyncInit):
 	async def format(
 			self,
 			display_type,
+			*,
 			color           = True,
 			interactive     = False,
 			line_processing = None,

+ 5 - 4
mmgen/tx/base.py

@@ -142,7 +142,7 @@ class Base(MMGenObject):
 	def sum_inputs(self):
 		return sum(e.amt for e in self.inputs)
 
-	def sum_outputs(self, exclude=None):
+	def sum_outputs(self, *, exclude=None):
 		if exclude is None:
 			olist = self.outputs
 		else:
@@ -178,10 +178,11 @@ class Base(MMGenObject):
 		self.blockcount = self.rpc.blockcount
 
 	# returns True if comment added or changed, False otherwise
-	def add_comment(self, infile=None):
+	def add_comment(self, *, infile=None):
 		if infile:
 			from ..fileutil import get_data_from_file
-			self.comment = MMGenTxComment(get_data_from_file(self.cfg, infile, 'transaction comment'))
+			self.comment = MMGenTxComment(
+				get_data_from_file(self.cfg, infile, desc='transaction comment'))
 		else:
 			from ..ui import keypress_confirm, line_input
 			if keypress_confirm(
@@ -203,7 +204,7 @@ class Base(MMGenObject):
 			edesc = 'non-MMGen address',
 			quiet = True)
 
-	def check_non_mmgen_inputs(self, caller, non_mmaddrs=None):
+	def check_non_mmgen_inputs(self, caller, *, non_mmaddrs=None):
 		non_mmaddrs = non_mmaddrs or self.get_non_mmaddrs('inputs')
 		if non_mmaddrs:
 			indent = '  '

+ 9 - 9
mmgen/tx/file.py

@@ -45,7 +45,7 @@ def get_proto_from_coin_id(tx, coin_id, chain):
 
 	return init_proto(tx.cfg, coin, network=network, need_amt=True, tokensym=tokensym)
 
-def eval_io_data(tx, data, desc):
+def eval_io_data(tx, data, *, desc):
 	if not (desc == 'outputs' and tx.proto.base_coin == 'ETH'): # ETH txs can have no outputs
 		assert len(data), f'no {desc}!'
 	for d in data:
@@ -80,10 +80,10 @@ class MMGenTxFile(MMGenObject):
 		self.fmt_data = None
 		self.filename = None
 
-	def parse(self, infile, metadata_only=False, quiet_open=False):
+	def parse(self, infile, *, metadata_only=False, quiet_open=False):
 		tx = self.tx
 		from ..fileutil import get_data_from_file
-		data = get_data_from_file(tx.cfg, infile, f'{tx.desc} data', quiet=quiet_open)
+		data = get_data_from_file(tx.cfg, infile, desc=f'{tx.desc} data', quiet=quiet_open)
 		if len(data) > tx.cfg.max_tx_file_size:
 			die('MaxFileSizeExceeded',
 				f'Transaction file size exceeds limit ({tx.cfg.max_tx_file_size} bytes)')
@@ -112,7 +112,7 @@ class MMGenTxFile(MMGenObject):
 				setattr(tx, k, v(data[k]) if v else data[k])
 
 		for k in ('inputs', 'outputs'):
-			setattr(tx, k, eval_io_data(tx, data[k], k))
+			setattr(tx, k, eval_io_data(tx, data[k], desc=k))
 
 		tx.check_txfile_hex_data()
 
@@ -124,7 +124,7 @@ class MMGenTxFile(MMGenObject):
 		tx = self.tx
 		tx.file_format = 'legacy'
 
-		def deserialize(raw_data, desc):
+		def deserialize(raw_data, *, desc):
 			from ast import literal_eval
 			try:
 				return literal_eval(raw_data)
@@ -199,12 +199,12 @@ class MMGenTxFile(MMGenObject):
 			tx.parse_txfile_serialized_data()
 			for k in ('inputs', 'outputs'):
 				desc = f'{k} data'
-				res = deserialize(io_data[k], k)
+				res = deserialize(io_data[k], desc=k)
 				for d in res:
 					if 'label' in d:
 						d['comment'] = d['label']
 						del d['label']
-				setattr(tx, k, eval_io_data(tx, res, k))
+				setattr(tx, k, eval_io_data(tx, res, desc=k))
 			desc = 'send amount in metadata'
 			assert tx.proto.coin_amt(send_amt) == tx.send_amt, f'{send_amt} != {tx.send_amt}'
 		except Exception as e:
@@ -286,7 +286,7 @@ class MMGenTxFile(MMGenObject):
 
 		return fmt_data
 
-	def write(self,
+	def write(self, *,
 		add_desc              = '',
 		outdir                = None,
 		ask_write             = True,
@@ -316,7 +316,7 @@ class MMGenTxFile(MMGenObject):
 			ignore_opt_outdir     = outdir)
 
 	@classmethod
-	def get_proto(cls, cfg, filename, quiet_open=False):
+	def get_proto(cls, cfg, filename, *, quiet_open=False):
 		from . import BaseTX
 		tmp_tx = BaseTX(cfg=cfg)
 		cls(tmp_tx).parse(filename, metadata_only=True, quiet_open=quiet_open)

+ 8 - 8
mmgen/tx/info.py

@@ -25,7 +25,7 @@ class TxInfo:
 		self.cfg = cfg
 		self.tx = tx
 
-	def format(self, terse=False, sort='addr'):
+	def format(self, *, terse=False, sort='addr'):
 
 		tx = self.tx
 
@@ -101,11 +101,11 @@ class TxInfo:
 			iwidth = len(str(int(tx.sum_inputs())))
 
 			yield self.txinfo_ftr_fs.format(
-				i = tx.sum_inputs().fmt(color=True, iwidth=iwidth),
-				o = tx.sum_outputs().fmt(color=True, iwidth=iwidth),
-				C = tx.change.fmt(color=True, iwidth=iwidth),
-				s = tx.send_amt.fmt(color=True, iwidth=iwidth),
-				a = self.format_abs_fee(color=True, iwidth=iwidth),
+				i = tx.sum_inputs().fmt(iwidth, color=True),
+				o = tx.sum_outputs().fmt(iwidth, color=True),
+				C = tx.change.fmt(iwidth, color=True),
+				s = tx.send_amt.fmt(iwidth, color=True),
+				a = self.format_abs_fee(iwidth, color=True),
 				r = self.format_rel_fee(),
 				d = tx.dcoin,
 				c = tx.coin)
@@ -115,7 +115,7 @@ class TxInfo:
 
 		return ''.join(gen_view())
 
-	def view_with_prompt(self, prompt, pause=True):
+	def view_with_prompt(self, prompt, *, pause=True):
 		prompt += ' (y)es, (N)o, pager (v)iew, (t)erse view: '
 		from ..term import get_char
 		while True:
@@ -131,7 +131,7 @@ class TxInfo:
 				break
 			msg('Invalid reply')
 
-	def view(self, pager=False, pause=True, terse=False):
+	def view(self, *, pager=False, pause=True, terse=False):
 		o = self.format(terse=terse)
 		if pager:
 			from ..ui import do_pager

+ 25 - 19
mmgen/tx/new.py

@@ -70,6 +70,13 @@ def mmaddr2coinaddr(cfg, mmaddr, ad_w, ad_f, proto):
 
 	return CoinAddr(proto, coin_addr)
 
+def parse_fee_spec(proto, fee_arg):
+	import re
+	units = {u[0]:u for u in proto.coin_amt.units}
+	pat = re.compile(r'((?:[1-9][0-9]*)|(?:[0-9]+\.[0-9]+))({})'.format('|'.join(units)))
+	if m := pat.match(fee_arg):
+		return namedtuple('parsed_fee_spec', ['amt', 'unit'])(m[1], units[m[2]])
+
 class New(Base):
 
 	fee_is_approximate = False
@@ -78,7 +85,6 @@ class New(Base):
 		ERROR: No change address specified.  If you wish to create a transaction with
 		only one output, specify a single output address with no {} amount
 	"""
-	msg_insufficient_funds = 'Selected outputs insufficient to fund this transaction ({} {} needed)'
 	chg_autoselected = False
 	_funds_available = namedtuple('funds_available', ['is_positive', 'amt'])
 
@@ -117,16 +123,12 @@ class New(Base):
 		if fee := get_obj(self.proto.coin_amt, num=fee_arg, silent=True):
 			return fee
 
-		import re
-		units = {u[0]:u for u in self.proto.coin_amt.units}
-		pat = re.compile(r'([1-9][0-9]*)({})'.format('|'.join(units)))
-		if pat.match(fee_arg):
-			amt, unit = pat.match(fee_arg).groups()
-			return self.fee_rel2abs(tx_size, units, int(amt), unit)
+		if res := parse_fee_spec(self.proto, fee_arg):
+			return self.fee_rel2abs(tx_size, float(res.amt), res.unit)
 
 		return False
 
-	def get_usr_fee_interactive(self, fee=None, desc='Starting'):
+	def get_usr_fee_interactive(self, fee=None, *, desc='Starting'):
 		abs_fee = None
 		from ..ui import line_input
 		while True:
@@ -164,7 +166,7 @@ class New(Base):
 			return False
 		return True
 
-	def add_output(self, coinaddr, amt, is_chg=False, is_vault=False, data=None):
+	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))
 
@@ -197,7 +199,7 @@ class New(Base):
 
 		return _pa(arg, mmid, coin_addr, amt, None, is_vault)
 
-	async def get_autochg_addr(self, proto, arg, exclude, desc, all_addrtypes=False):
+	async def get_autochg_addr(self, proto, arg, *, exclude, desc, all_addrtypes=False):
 		from ..tw.addresses import TwAddresses
 		al = await TwAddresses(self.cfg, proto, get_data=True)
 
@@ -287,7 +289,7 @@ class New(Base):
 		for addrfile in addrfiles:
 			check_infile(addrfile)
 			try:
-				ad_f.add(AddrList(self.cfg, proto, addrfile))
+				ad_f.add(AddrList(self.cfg, proto, infile=addrfile))
 			except Exception as e:
 				msg(f'{type(e).__name__}: {e}')
 		return ad_f
@@ -385,7 +387,10 @@ class New(Base):
 			self.get_unspent_nums_from_user
 		)(self.twuo.data)
 
-		msg(f'Selected output{suf(sel_nums)}: {{}}'.format(' '.join(str(n) for n in sel_nums)))
+		msg('Selected {}{}: {}'.format(
+			self.twuo.item_desc,
+			suf(sel_nums),
+			' '.join(str(n) for n in sel_nums)))
 		sel_unspent = MMGenList(self.twuo.data[i-1] for i in sel_nums)
 
 		if not await self.precheck_sufficient_funds(
@@ -400,12 +405,12 @@ class New(Base):
 	async def get_fee(self, fee, outputs_sum, start_fee_desc):
 
 		if fee:
-			self.usr_fee = self.get_usr_fee_interactive(fee, start_fee_desc)
+			self.usr_fee = self.get_usr_fee_interactive(fee, desc=start_fee_desc)
 		else:
 			fee_per_kb, fe_type = await self.get_rel_fee_from_network()
 			self.usr_fee = self.get_usr_fee_interactive(
-				None if fee_per_kb is None else self.fee_est2abs(fee_per_kb, fe_type),
-				self.network_estimated_fee_label)
+				None if fee_per_kb is None else self.fee_est2abs(fee_per_kb, fe_type=fe_type),
+				desc = self.network_estimated_fee_label)
 
 		funds = await self.get_funds_available(self.usr_fee, outputs_sum)
 
@@ -419,14 +424,14 @@ class New(Base):
 		else:
 			self.warn_insufficient_funds(funds.amt, self.coin)
 
-	async def create(self, cmd_args, locktime=None, do_info=False, caller='txcreate'):
+	async def create(self, cmd_args, *, locktime=None, do_info=False, caller='txcreate'):
 
 		assert isinstance(locktime, (int, type(None))), 'locktime must be of type int'
 
 		from ..tw.unspent import TwUnspentOutputs
 
 		if self.cfg.comment_file:
-			self.add_comment(self.cfg.comment_file)
+			self.add_comment(infile=self.cfg.comment_file)
 
 		if not do_info:
 			cmd_args, addrfile_args = self.get_addrfiles_from_cmdline(cmd_args)
@@ -478,10 +483,11 @@ class New(Base):
 				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(
+			desc = 'User-selected' if self.cfg.fee else 'Recommended' if fee_hint else None
+			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):
+					desc)) is not None:
 				break
 
 		self.check_non_mmgen_inputs(caller)

+ 2 - 3
mmgen/tx/online.py

@@ -23,7 +23,7 @@ class OnlineSigned(Signed):
 
 	def check_swap_expiry(self):
 		import time
-		from ..util import msg, make_timestr, die
+		from ..util import msg, make_timestr
 		from ..util2 import format_elapsed_hr
 		from ..color import pink, yellow
 		expiry = self.swap_quote_expiry
@@ -34,8 +34,7 @@ class OnlineSigned(Signed):
 			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')
+		return t_rem >= 0
 
 	def confirm_send(self):
 		from ..util import msg

+ 6 - 6
mmgen/tx/sign.py

@@ -37,7 +37,7 @@ def get_seed_for_seed_id(sid, infiles, saved_seeds):
 	subseeds_checked = False
 	while True:
 		if infiles:
-			seed = Wallet(cfg, infiles.pop(0), ignore_in_fmt=True, passwd_file=global_passwd_file).seed
+			seed = Wallet(cfg, fn=infiles.pop(0), ignore_in_fmt=True, passwd_file=global_passwd_file).seed
 		elif subseeds_checked is False:
 			seed = saved_seeds[list(saved_seeds)[0]].subseed_by_seed_id(sid, print_msg=True)
 			subseeds_checked = True
@@ -77,7 +77,7 @@ def generate_kals_for_mmgen_addrs(need_keys, infiles, saved_seeds, proto):
 						skip_chksum = True)
 	return MMGenList(gen_kals())
 
-def add_keys(src, io_list, infiles=None, saved_seeds=None, keyaddr_list=None):
+def add_keys(src, io_list, infiles=None, saved_seeds=None, *, keyaddr_list=None):
 
 	need_keys = [e for e in io_list if e.mmid and not e.have_wif]
 
@@ -128,7 +128,7 @@ def get_tx_files(cfg, args):
 		die(1, 'You must specify a raw transaction file!')
 	return ret
 
-def get_seed_files(cfg, args, ignore_dfl_wallet=False, empty_ok=False):
+def get_seed_files(cfg, args, *, ignore_dfl_wallet=False, empty_ok=False):
 	# favor unencrypted seed sources first, as they don't require passwords
 	ret = _pop_matching_fns(args, get_wallet_extensions('unenc'))
 	from ..filename import find_file_in_dir
@@ -142,16 +142,16 @@ def get_seed_files(cfg, args, ignore_dfl_wallet=False, empty_ok=False):
 
 def get_keyaddrlist(cfg, proto):
 	if cfg.mmgen_keys_from_file:
-		return KeyAddrList(cfg, proto, cfg.mmgen_keys_from_file)
+		return KeyAddrList(cfg, proto, infile=cfg.mmgen_keys_from_file)
 	return None
 
 def get_keylist(cfg):
 	if cfg.keys_from_file:
 		from ..fileutil import get_lines_from_file
-		return get_lines_from_file(cfg, cfg.keys_from_file, 'key-address data', trim_comments=True)
+		return get_lines_from_file(cfg, cfg.keys_from_file, desc='key-address data', trim_comments=True)
 	return None
 
-async def txsign(cfg_parm, tx, seed_files, kl, kal, tx_num_str='', passwd_file=None):
+async def txsign(cfg_parm, tx, seed_files, kl, kal, *, tx_num_str='', passwd_file=None):
 
 	keys = MMGenList() # list of AddrListEntry objects
 	non_mmaddrs = tx.get_non_mmaddrs('inputs')

+ 223 - 0
mmgen/tx/tx_proxy.py

@@ -0,0 +1,223 @@
+#!/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
+
+"""
+tx.tx_proxy: tx proxy classes
+"""
+
+from ..color import green, pink, orange
+from ..util import msg, msg_r, die
+
+class TxProxyClient:
+
+	proto = 'https'
+	verify = True
+	timeout = 60
+	http_hdrs = {
+		'User-Agent': 'curl/8.7.1',
+		'Proxy-Connection': 'Keep-Alive'}
+
+	def __init__(self, cfg):
+		self.cfg = cfg
+		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 call(self, name, path, err_fs, timeout, *, data=None):
+		url = self.proto + '://' + self.host + path
+		kwargs = {
+			'url': url,
+			'timeout': timeout or self.timeout,
+			'verify': self.verify}
+		if data:
+			kwargs['data'] = data
+		res = getattr(self.session, name)(**kwargs)
+		if res.status_code != 200:
+			die(2, '\n' + err_fs.format(s=res.status_code, u=url, d=data))
+		return res.content.decode()
+
+	def get(self, *, path, timeout=None):
+		err_fs = 'HTTP Get failed with status code {s}\n  URL: {u}'
+		return self.call('get', path, err_fs, timeout)
+
+	def post(self, *, path, data, timeout=None):
+		err_fs = 'HTTP Post failed with status code {s}\n  URL: {u}\n  DATA: {d}'
+		return self.call('post', path, err_fs, timeout, data=data)
+
+	def get_form(self, timeout=None):
+		return self.get(path=self.form_path, timeout=timeout)
+
+	def post_form(self, *, data, timeout=None):
+		return self.post(path=self.form_path, data=data, timeout=timeout)
+
+	def get_form_element(self, text):
+		from lxml import html
+		root = html.document_fromstring(text)
+		res = [e for e in root.forms if e.attrib.get('action', '').endswith(self.form_path)]
+		assert res, 'no matching forms!'
+		assert len(res) == 1, 'more than one matching form!'
+		return res[0]
+
+	def cache_fn(self, desc):
+		return f'{self.name}-{desc}.html'
+
+	def save_response(self, data, desc):
+		from ..fileutil import write_data_to_file
+		write_data_to_file(
+			self.cfg,
+			self.cache_fn(desc),
+			data,
+			desc = f'{desc} page from {orange(self.host)}')
+
+class BlockchairTxProxyClient(TxProxyClient):
+
+	name = 'blockchair'
+	host = 'blockchair.com'
+	form_path = '/broadcast'
+	assets = {
+		'avax': 'avalanche',
+		'btc':  'bitcoin',
+		'bch':  'bitcoin-cash',
+		'bnb':  'bnb',
+		'dash': 'dash',
+		'doge': 'dogecoin',
+		'eth':  'ethereum',
+		'etc':  'ethereum-classic',
+		'ltc':  'litecoin',
+		'zec':  'zcash',
+	}
+	active_assets = () # tried with ETH, doesn’t work
+
+	def create_post_data(self, *, form_text, coin, tx_hex):
+
+		coin = coin.lower()
+		assert coin in self.assets, f'coin {coin} not supported by {self.name}'
+		asset = self.assets[coin]
+
+		form = self.get_form_element(form_text)
+		data = {}
+
+		e = form.find('.//input')
+		assert e.attrib['name'] == '_token', 'input name incorrect!'
+		data['_token'] = e.attrib['value']
+
+		e = form.find('.//textarea')
+		assert e.attrib['name'] == 'data', 'textarea name incorrect!'
+		data['data'] = '0x' + tx_hex
+
+		e = form.find('.//button')
+		assert e is not None, 'missing button!'
+
+		e = form.find('.//select')
+		assert e.attrib['name'] == 'blockchain', 'select element name incorrect!'
+
+		assets = [f.get('value') for f in e.iter() if f.get('value')]
+		assert asset in assets, f'coin {coin} ({asset}) not currently supported by {self.name}'
+
+		data['blockchain'] = asset
+
+		return data
+
+	def get_txid(self, *, result_text):
+		msg(f'Response parsing TBD.  Check the cached response at {self.cache_fn("result")}')
+
+class EtherscanTxProxyClient(TxProxyClient):
+	name = 'etherscan'
+	host = 'etherscan.io'
+	form_path = '/pushTx'
+	assets = {'eth': 'ethereum'}
+	active_assets = ('eth',)
+
+	def create_post_data(self, *, form_text, coin, tx_hex):
+
+		form = self.get_form_element(form_text)
+		data = {}
+
+		for e in form.findall('.//input'):
+			data[e.attrib['name']] = e.attrib['value']
+
+		if len(data) != 4:
+			msg('')
+			self.save_response(form_text, 'form')
+			die(3, f'{len(data)}: unexpected number of keys in data (expected 4)')
+
+		e = form.find('.//textarea')
+		data[e.attrib['name']] = '0x' + tx_hex
+
+		return data
+
+	def get_txid(self, *, result_text):
+		import json
+		from ..obj import CoinTxID, is_coin_txid
+		form = self.get_form_element(result_text)
+		json_text = form.find('div/div/div')[1].tail
+		txid = json.loads(json_text)['result'].removeprefix('0x')
+		if is_coin_txid(txid):
+			return CoinTxID(txid)
+		else:
+			return False
+
+def send_tx(cfg, tx):
+
+	c = get_client(cfg)
+	msg(f'Using {pink(cfg.tx_proxy.upper())} tx proxy')
+
+	if not cfg.test:
+		tx.confirm_send()
+
+	msg_r(f'Retrieving form from {orange(c.host)}...')
+	form_text = c.get_form(timeout=180)
+	msg('done')
+
+	msg_r('Parsing form...')
+	post_data = c.create_post_data(
+		form_text = form_text,
+		coin      = cfg.coin,
+		tx_hex    = tx.serialized)
+	msg('done')
+
+	if cfg.test:
+		msg(f'Form retrieved from {orange(c.host)} and parsed')
+		msg(green('Transaction can be sent'))
+		return False
+
+	msg_r('Sending data...')
+	result_text = c.post_form(data=post_data, timeout=180)
+	msg('done')
+
+	msg_r('Parsing response...')
+	txid = c.get_txid(result_text=result_text)
+	msg('done')
+
+	msg('Transaction ' + (f'sent: {txid.hl()}' if txid else 'send failed'))
+	c.save_response(result_text, 'result')
+
+	return bool(txid)
+
+tx_proxies = {
+	'blockchair': BlockchairTxProxyClient,
+	'etherscan':  EtherscanTxProxyClient
+}
+
+def get_client(cfg, *, check_only=False):
+	proxy = tx_proxies[cfg.tx_proxy]
+	if cfg.coin.lower() in proxy.active_assets:
+		return True if check_only else proxy(cfg)
+	else:
+		die(1, f'Coin {cfg.coin} not supported by TX proxy {pink(proxy.name.upper())}')
+
+def check_client(cfg):
+	return get_client(cfg, check_only=True)

+ 11 - 10
mmgen/ui.py

@@ -16,7 +16,7 @@ import sys, os
 
 from .util import msg, msg_r, Msg, die
 
-def confirm_or_raise(cfg, message, action, expect='YES', exit_msg='Exiting at user request'):
+def confirm_or_raise(cfg, message, action, *, expect='YES', exit_msg='Exiting at user request'):
 	if message:
 		msg(message)
 	if line_input(
@@ -32,13 +32,13 @@ def get_words_from_user(cfg, prompt):
 		msg('Sanitized input: [{}]'.format(' '.join(words)))
 	return words
 
-def get_data_from_user(cfg, desc='data'): # user input MUST be UTF-8
+def get_data_from_user(cfg, *, desc='data'): # user input MUST be UTF-8
 	data = line_input(cfg, f'Enter {desc}: ', echo=cfg.echo_passphrase)
 	if cfg.debug:
 		msg(f'User input: [{data}]')
 	return data
 
-def line_input(cfg, prompt, echo=True, insert_txt='', hold_protect=True):
+def line_input(cfg, prompt, *, echo=True, insert_txt='', hold_protect=True):
 	"""
 	multi-line prompts OK
 	one-line prompts must begin at beginning of line
@@ -81,12 +81,13 @@ def line_input(cfg, prompt, echo=True, insert_txt='', hold_protect=True):
 	return reply.strip()
 
 def keypress_confirm(
-	cfg,
-	prompt,
-	default_yes     = False,
-	verbose         = False,
-	no_nl           = False,
-	complete_prompt = False):
+		cfg,
+		prompt,
+		*,
+		default_yes     = False,
+		verbose         = False,
+		no_nl           = False,
+		complete_prompt = False):
 
 	if not complete_prompt:
 		prompt = '{} {}: '.format(prompt, '(Y/n)' if default_yes else '(y/N)')
@@ -133,7 +134,7 @@ def do_pager(text):
 		Msg(text+end_msg)
 	set_vt100()
 
-def do_license_msg(cfg, immed=False):
+def do_license_msg(cfg, *, immed=False):
 
 	if cfg.quiet or cfg.no_license or cfg.yes or not cfg.stdin_tty:
 		return

+ 17 - 16
mmgen/util.py

@@ -69,6 +69,7 @@ class Util:
 			desc1,
 			chk2,
 			desc2,
+			*,
 			hdr         = '',
 			die_on_fail = False,
 			verbose     = False):
@@ -88,7 +89,7 @@ class Util:
 
 		return True
 
-	def compare_or_die(self, val1, desc1, val2, desc2, e='Error'):
+	def compare_or_die(self, val1, desc1, val2, desc2, *, e='Error'):
 		if val1 != val2:
 			die(3, f"{e}: {desc2} ({val2}) doesn't match {desc1} ({val1})")
 		if self.cfg.debug:
@@ -156,7 +157,7 @@ def mdie(*args):
 	mmsg(*args)
 	sys.exit(0)
 
-def die(ev, s='', stdout=False):
+def die(ev, s='', *, stdout=False):
 	if isinstance(ev, int):
 		from .exception import MMGenSystemExit, MMGenError
 		if ev <= 2:
@@ -179,15 +180,15 @@ def pp_fmt(d):
 def pp_msg(d):
 	msg(pp_fmt(d))
 
-def indent(s, indent='    ', append='\n'):
+def indent(s, *, indent='    ', append='\n'):
 	"indent multiple lines of text with specified string"
 	return indent + ('\n'+indent).join(s.strip().splitlines()) + append
 
-def fmt(s, indent='', strip_char=None, append='\n'):
+def fmt(s, *, indent='', strip_char=None, append='\n'):
 	"de-indent multiple lines of text, or indent with specified string"
 	return indent + ('\n'+indent).join([l.lstrip(strip_char) for l in s.strip().splitlines()]) + append
 
-def fmt_list(iterable, fmt='dfl', indent='', conv=None):
+def fmt_list(iterable, *, fmt='dfl', indent='', conv=None):
 	"pretty-format a list"
 	_conv, sep, lq, rq = {
 		'dfl':       (str,  ", ", "'",  "'"),
@@ -206,7 +207,7 @@ def fmt_list(iterable, fmt='dfl', indent='', conv=None):
 	conv = conv or _conv
 	return indent + (sep+indent).join(lq+conv(e)+rq for e in iterable)
 
-def fmt_dict(mapping, fmt='dfl', kconv=None, vconv=None):
+def fmt_dict(mapping, *, fmt='dfl', kconv=None, vconv=None):
 	"pretty-format a dict"
 	kc, vc, sep, fs = {
 		'dfl':           (str, str,  ", ",  "'{}' ({})"),
@@ -242,7 +243,7 @@ def list_gen(*data):
 					yield d[idx]
 	return list(gen())
 
-def remove_dups(iterable, edesc='element', desc='list', quiet=False, hide=False):
+def remove_dups(iterable, *, edesc='element', desc='list', quiet=False, hide=False):
 	"""
 	Remove duplicate occurrences of iterable elements, preserving first occurrence
 	If iterable is a generator, return a list, else type(iterable)
@@ -259,7 +260,7 @@ def remove_dups(iterable, edesc='element', desc='list', quiet=False, hide=False)
 def contains_any(target_list, source_list):
 	return any(map(target_list.count, source_list))
 
-def suf(arg, suf_type='s', verb='none'):
+def suf(arg, suf_type='s', *, verb='none'):
 	suf_types = {
 		'none': {
 			's':   ('s',   ''),
@@ -292,7 +293,7 @@ def remove_extension(fn, ext):
 	a, b = os.path.splitext(fn)
 	return a if b[1:] == ext else fn
 
-def make_chksum_N(s, nchars, sep=False, rounds=2, upper=True):
+def make_chksum_N(s, nchars, *, sep=False, rounds=2, upper=True):
 	if isinstance(s, str):
 		s = s.encode()
 	from hashlib import sha256
@@ -306,7 +307,7 @@ def make_chksum_N(s, nchars, sep=False, rounds=2, upper=True):
 		assert 4 <= nchars <= 64, 'illegal ‘nchars’ value'
 	return ret.upper() if upper else ret
 
-def make_chksum_8(s, sep=False):
+def make_chksum_8(s, *, sep=False):
 	from .obj import HexStr
 	from hashlib import sha256
 	s = HexStr(sha256(sha256(s).digest()).hexdigest()[:8].upper(), case='upper')
@@ -360,7 +361,7 @@ def secs_to_ms(secs):
 def is_int(s): # actually is_nonnegative_int()
 	return set(str(s) or 'x') <= set(digits)
 
-def check_int_between(val, imin, imax, desc):
+def check_int_between(val, imin, imax, *, desc):
 	if not imin <= int(val) <= imax:
 		die(1, f'{val}: invalid value for {desc} (must be between {imin} and {imax})')
 	return int(val)
@@ -379,7 +380,7 @@ def is_utf8(s):
 	else:
 		return True
 
-def remove_whitespace(s, ws='\t\r\n '):
+def remove_whitespace(s, *, ws='\t\r\n '):
 	return s.translate(dict((ord(e), None) for e in ws))
 
 def strip_comment(line):
@@ -396,7 +397,7 @@ class oneshot_warning:
 
 	color = 'nocolor'
 
-	def __init__(self, div=None, fmt_args=[], reverse=False):
+	def __init__(self, *, div=None, fmt_args=[], reverse=False):
 		self.do(type(self), div, fmt_args, reverse)
 
 	def do(self, wcls, div, fmt_args, reverse):
@@ -422,10 +423,10 @@ class oneshot_warning:
 
 class oneshot_warning_group(oneshot_warning):
 
-	def __init__(self, wcls, div=None, fmt_args=[], reverse=False):
+	def __init__(self, wcls, *, div=None, fmt_args=[], reverse=False):
 		self.do(getattr(self, wcls), div, fmt_args, reverse)
 
-def get_subclasses(cls, names=False):
+def get_subclasses(cls, *, names=False):
 	def gen(cls):
 		for i in cls.__subclasses__():
 			yield i
@@ -456,7 +457,7 @@ def exit_if_mswin(feature):
 	if sys.platform == 'win32':
 		die(2, capfirst(feature) + ' not supported on the MSWin / MSYS2 platform')
 
-def have_sudo(silent=False):
+def have_sudo(*, silent=False):
 	from subprocess import run, DEVNULL
 	redir = DEVNULL if silent else None
 	try:

+ 24 - 8
mmgen/util2.py

@@ -33,7 +33,7 @@ def die_pause(ev=0, s=''):
 def cffi_override_fixup():
 	from cffi import FFI
 	class FFI_override:
-		def cdef(self, csource, override=False, packed=False, pack=None):
+		def cdef(self, csource, *, override=False, packed=False, pack=None):
 			self._cdef(csource, override=True, packed=packed, pack=pack)
 	FFI.cdef = FFI_override.cdef
 
@@ -92,7 +92,7 @@ bytespec_map = (
 	('E',  1152921504606846976),
 )
 
-def int2bytespec(n, spec, fmt, print_sym=True, strip=False, add_space=False):
+def int2bytespec(n, spec, fmt, *, print_sym=True, strip=False, add_space=False):
 
 	def spec2int(spec):
 		for k, v in bytespec_map:
@@ -128,14 +128,21 @@ def parse_bytespec(nbytes):
 
 	die(1, f'{nbytes!r}: invalid byte specifier')
 
-def format_elapsed_days_hr(t, now=None, cached={}):
+def format_elapsed_days_hr(t, *, now=None, cached={}):
 	e = int((now or time.time()) - t)
 	if not e in cached:
 		days = abs(e) // 86400
 		cached[e] = f'{days} day{suf(days)} ' + ('ago' if e > 0 else 'in the future')
 	return cached[e]
 
-def format_elapsed_hr(t, now=None, cached={}, rel_now=True, show_secs=False, future_msg='in the future'):
+def format_elapsed_hr(
+		t,
+		*,
+		now        = None,
+		cached     = {},
+		rel_now    = True,
+		show_secs  = False,
+		future_msg = 'in the future'):
 	e = int((now or time.time()) - t)
 	key = f'{e}:{rel_now}:{show_secs}'
 	if not key in cached:
@@ -160,7 +167,7 @@ def format_elapsed_hr(t, now=None, cached={}, rel_now=True, show_secs=False, fut
 		cached[key] = ' '.join(f'{n} {desc}{suf(n)}' for desc, n in data if n) + add_suffix()
 	return cached[key]
 
-def pretty_format(s, width=80, pfx=''):
+def pretty_format(s, *, width=80, pfx=''):
 	out = []
 	while s:
 		if len(s) <= width:
@@ -171,7 +178,7 @@ def pretty_format(s, width=80, pfx=''):
 		s = s[i+1:]
 	return pfx + ('\n'+pfx).join(out)
 
-def block_format(data, gw=2, cols=8, line_nums=None, data_is_hex=False):
+def block_format(data, *, gw=2, cols=8, line_nums=None, data_is_hex=False):
 	assert line_nums in (None, 'hex', 'dec'), "'line_nums' must be one of None, 'hex' or 'dec'"
 	ln_fs = '{:06x}: ' if line_nums == 'hex' else '{:06}: '
 	bytes_per_chunk = gw
@@ -185,8 +192,8 @@ def block_format(data, gw=2, cols=8, line_nums=None, data_is_hex=False):
 			for i in range(nchunks)
 	).rstrip() + '\n'
 
-def pretty_hexdump(data, gw=2, cols=8, line_nums=None):
-	return block_format(data.hex(), gw, cols, line_nums, data_is_hex=True)
+def pretty_hexdump(data, *, gw=2, cols=8, line_nums=None):
+	return block_format(data.hex(), gw=gw, cols=cols, line_nums=line_nums, data_is_hex=True)
 
 def decode_pretty_hexdump(data):
 	pat = re.compile(fr'^[{hexdigits}]+:\s+')
@@ -213,6 +220,15 @@ def cliargs_convert(args):
 
 	return tuple(gen())
 
+def port_in_use(port):
+	import socket
+	try:
+		socket.create_connection(('localhost', port)).close()
+	except:
+		return False
+	else:
+		return True
+
 class ExpInt(int):
 	'encode or parse an integer in exponential notation with specified precision'
 

+ 3 - 0
mmgen/wallet/__init__.py

@@ -47,6 +47,7 @@ _wd('words',        'MMGenMnemonic',     'mmwords', 'mnemonic',   False, ('mmwor
 }
 
 def get_wallet_data(
+		*,
 		wtype       = None,
 		fmt_code    = None,
 		ext         = None,
@@ -73,6 +74,7 @@ def get_wallet_data(
 
 def get_wallet_cls(
 		wtype       = None,
+		*,
 		fmt_code    = None,
 		ext         = None,
 		die_on_fail = False):
@@ -111,6 +113,7 @@ def _get_me(modname):
 
 def Wallet(
 	cfg,
+	*,
 	fn            = None,
 	ss            = None,
 	seed_bin      = None,

+ 5 - 5
mmgen/wallet/base.py

@@ -40,7 +40,7 @@ class wallet(MMGenObject, metaclass=WalletMeta):
 	class WalletData(MMGenObject):
 		pass
 
-	def __init__(self,
+	def __init__(self, *,
 		in_data       = None,
 		passwd_file   = None):
 
@@ -79,7 +79,7 @@ class wallet(MMGenObject, metaclass=WalletMeta):
 			self.fmt_data = get_data_from_file(
 				self.cfg,
 				self.infile.name,
-				self.desc,
+				desc   = self.desc,
 				binary = self.file_mode=='binary')
 		elif self.in_data:
 			self.fmt_data = self.in_data
@@ -88,7 +88,7 @@ class wallet(MMGenObject, metaclass=WalletMeta):
 
 	def _get_data_from_user(self, desc):
 		from ..ui import get_data_from_user
-		return get_data_from_user(self.cfg, desc)
+		return get_data_from_user(self.cfg, desc=desc)
 
 	def _deformat_once(self):
 		self._get_data()
@@ -110,7 +110,7 @@ class wallet(MMGenObject, metaclass=WalletMeta):
 		self._format()
 		return self.fmt_data
 
-	def write_to_file(self, outdir='', desc=''):
+	def write_to_file(self, *, outdir='', desc=''):
 		self._format()
 		kwargs = {
 			'desc':     desc or self.desc,
@@ -130,7 +130,7 @@ class wallet(MMGenObject, metaclass=WalletMeta):
 			self.fmt_data,
 			**kwargs)
 
-	def check_usr_seed_len(self, bitlen=None):
+	def check_usr_seed_len(self, *, bitlen=None):
 		chk = bitlen or self.seed.bitlen
 		if self.cfg.seed_len and self.cfg.seed_len != chk:
 			die(1, f'ERROR: requested seed length ({self.cfg.seed_len}) doesn’t match seed length of source ({chk})')

+ 1 - 1
mmgen/wallet/brain.py

@@ -50,7 +50,7 @@ class wallet(wallet):
 			d.hash_preset,
 			buflen = bw_seed_len // 8)
 		self.cfg._util.qmsg('Done')
-		self.seed = Seed(self.cfg, seed)
+		self.seed = Seed(self.cfg, seed_bin=seed)
 		msg(f'Seed ID: {self.seed.sid}')
 		self.cfg._util.qmsg('Check this value against your records')
 		return True

+ 2 - 2
mmgen/wallet/dieroll.py

@@ -60,7 +60,7 @@ class wallet(wallet):
 					desc       = 'gathered from your die rolls')
 				self.desc += ' plus user-supplied entropy'
 
-		self.seed = Seed(self.cfg, seed_bytes)
+		self.seed = Seed(self.cfg, seed_bin=seed_bytes)
 
 		self.check_usr_seed_len()
 		return True
@@ -69,7 +69,7 @@ class wallet(wallet):
 
 		if not self.cfg.stdin_tty:
 			from ..ui import get_data_from_user
-			return get_data_from_user(self.cfg, desc)
+			return get_data_from_user(self.cfg, desc=desc)
 
 		bc = baseconv('b6d')
 

+ 3 - 3
mmgen/wallet/enc.py

@@ -31,7 +31,7 @@ class wallet(wallet):
 				die(2, 'Passphrase from password file, so exiting')
 			msg('Trying again...')
 
-	def _get_hash_preset_from_user(self, old_preset, add_desc=''):
+	def _get_hash_preset_from_user(self, old_preset, *, add_desc=''):
 		prompt = 'Enter {}hash preset for {}{}{},\nor hit ENTER to {} value ({!r}): '.format(
 			('old ' if self.op=='pwchg_old' else 'new ' if self.op=='pwchg_new' else ''),
 			('', 'new ')[self.op=='new'],
@@ -41,7 +41,7 @@ class wallet(wallet):
 			old_preset)
 		return self.crypto.get_hash_preset_from_user(old_preset=old_preset, prompt=prompt)
 
-	def _get_hash_preset(self, add_desc=''):
+	def _get_hash_preset(self, *, add_desc=''):
 		if hasattr(self, 'ss_in') and hasattr(self.ss_in.ssdata, 'hash_preset'):
 			old_hp = self.ss_in.ssdata.hash_preset
 			if self.cfg.keep_hash_preset:
@@ -71,7 +71,7 @@ class wallet(wallet):
 			passwd_file = self.passwd_file,
 			pw_desc = ('new ' if self.op=='pwchg_new' else '') + 'passphrase')
 
-	def _get_passphrase(self, add_desc=''):
+	def _get_passphrase(self, *, add_desc=''):
 		return self.crypto.get_passphrase(
 			data_desc = self.desc + (f' {add_desc}' if add_desc else ''),
 			passwd_file = self.passwd_file,

+ 3 - 3
mmgen/wallet/incog_base.py

@@ -142,9 +142,9 @@ class wallet(wallet):
 			return False
 
 	def _verify_seed_oldfmt(self, seed):
-		m = f'Seed ID: {make_chksum_8(seed)}.  Is the Seed ID correct?'
+		prompt = f'Seed ID: {make_chksum_8(seed)}.  Is the Seed ID correct?'
 		from ..ui import keypress_confirm
-		if keypress_confirm(self.cfg, m, True):
+		if keypress_confirm(self.cfg, prompt, default_yes=True):
 			return seed
 		else:
 			return False
@@ -189,7 +189,7 @@ class wallet(wallet):
 				key_id   = ''))
 
 		if seed:
-			self.seed = Seed(self.cfg, seed)
+			self.seed = Seed(self.cfg, seed_bin=seed)
 			msg(f'Seed ID: {self.seed.sid}')
 			return True
 		else:

+ 5 - 5
mmgen/wallet/mmgen.py

@@ -33,7 +33,7 @@ class wallet(wallet):
 		super().__init__(*args, **kwargs)
 
 	# logic identical to _get_hash_preset_from_user()
-	def _get_label_from_user(self, old_lbl=''):
+	def _get_label_from_user(self, *, old_lbl=''):
 		prompt = 'Enter a wallet label, or hit ENTER {}: '.format(
 			'to reuse the label {}'.format(old_lbl.hl2(encl='‘’')) if old_lbl else
 			'for no label')
@@ -60,7 +60,7 @@ class wallet(wallet):
 				lbl = self.label
 				self.cfg._util.qmsg('Using user-configured label {}'.format(lbl.hl2(encl='‘’')))
 			else: # Prompt, using old value as default
-				lbl = self._get_label_from_user(old_lbl)
+				lbl = self._get_label_from_user(old_lbl=old_lbl)
 			if (not self.cfg.keep_label) and self.op == 'pwchg_new':
 				self.cfg._util.qmsg('Label {}'.format('unchanged' if lbl == old_lbl else f'changed to {lbl!r}'))
 		elif self.label:
@@ -122,7 +122,7 @@ class wallet(wallet):
 		d1, d2, d3, d4, d5 = lines[2].split()
 		d.seed_id = d1.upper()
 		d.key_id  = d2.upper()
-		self.check_usr_seed_len(int(d3))
+		self.check_usr_seed_len(bitlen=int(d3))
 		d.pw_status, d.timestamp = d4, d5
 
 		hpdata = lines[3].split()
@@ -171,9 +171,9 @@ class wallet(wallet):
 		d.passwd = self._get_passphrase(
 			add_desc = os.path.basename(self.infile.name) if self.cfg.quiet else '')
 		key = self.crypto.make_key(d.passwd, d.salt, d.hash_preset)
-		ret = self.crypto.decrypt_seed(d.enc_seed, key, d.seed_id, d.key_id)
+		ret = self.crypto.decrypt_seed(d.enc_seed, key, seed_id=d.seed_id, key_id=d.key_id)
 		if ret:
-			self.seed = Seed(self.cfg, ret)
+			self.seed = Seed(self.cfg, seed_bin=ret)
 			return True
 		else:
 			return False

+ 1 - 1
mmgen/wallet/mmhex.py

@@ -54,7 +54,7 @@ class wallet(wallet):
 		if not self.cfg._util.compare_chksums(chk, 'file', make_chksum_6(hstr), 'computed', verbose=True):
 			return False
 
-		self.seed = Seed(self.cfg, bytes.fromhex(hstr))
+		self.seed = Seed(self.cfg, seed_bin=bytes.fromhex(hstr))
 		self.ssdata.chksum = chk
 
 		self.check_usr_seed_len()

+ 6 - 6
mmgen/wallet/mnemonic.py

@@ -32,7 +32,7 @@ class wallet(wallet):
 
 		if not self.cfg.stdin_tty:
 			from ..ui import get_data_from_user
-			return get_data_from_user(self.cfg, desc)
+			return get_data_from_user(self.cfg, desc=desc)
 
 		self._print_seed_type()
 
@@ -53,8 +53,8 @@ class wallet(wallet):
 		seed = self.seed.data
 
 		bc = self.conv_cls(self.wl_id)
-		mn  = bc.frombytes(seed, 'seed')
-		rev = bc.tobytes(mn, 'seed')
+		mn  = bc.frombytes(seed, pad='seed')
+		rev = bc.tobytes(mn, pad='seed')
 
 		# Internal error, so just die on fail
 		self.cfg._util.compare_or_die(rev, 'recomputed seed', seed, 'original seed', e='Internal error')
@@ -78,8 +78,8 @@ class wallet(wallet):
 				msg(f'Invalid mnemonic: word #{n} is not in the {self.wl_id.upper()} wordlist')
 				return False
 
-		seed = bc.tobytes(mn, 'seed')
-		rev  = bc.frombytes(seed, 'seed')
+		seed = bc.tobytes(mn, pad='seed')
+		rev  = bc.frombytes(seed, pad='seed')
 
 		if len(seed) * 8 not in Seed.lens:
 			msg('Invalid mnemonic (produces too large a number)')
@@ -93,7 +93,7 @@ class wallet(wallet):
 			desc2 = 'original mnemonic',
 			e     = 'Internal error')
 
-		self.seed = Seed(self.cfg, seed)
+		self.seed = Seed(self.cfg, seed_bin=seed)
 		self.ssdata.mnemonic = mn
 
 		self.check_usr_seed_len()

+ 1 - 1
mmgen/wallet/plainhex.py

@@ -36,7 +36,7 @@ class wallet(wallet):
 			msg(f'Invalid data length ({len(d)}) in {desc}')
 			return False
 
-		self.seed = Seed(self.cfg, bytes.fromhex(d))
+		self.seed = Seed(self.cfg, seed_bin=bytes.fromhex(d))
 
 		self.check_usr_seed_len()
 

+ 1 - 1
mmgen/wallet/seed.py

@@ -59,7 +59,7 @@ class wallet(wallet):
 			msg(f'Invalid base-58 encoded seed: {b}')
 			return False
 
-		self.seed = Seed(self.cfg, ret)
+		self.seed = Seed(self.cfg, seed_bin=ret)
 		self.ssdata.chksum = a
 		self.ssdata.b58seed = b
 

+ 2 - 2
mmgen/xmrseed.py

@@ -46,7 +46,7 @@ class xmrseed(baseconv):
 		wstr = ''.join(word[:3] for word in words)
 		return words[crc32(wstr.encode()) % len(words)]
 
-	def tobytes(self, words_arg, pad=None):
+	def tobytes(self, words_arg, *, pad=None):
 
 		assert isinstance(words_arg, (list, tuple)), 'words must be list or tuple'
 		assert pad is None, f"{pad}: invalid 'pad' argument (must be None)"
@@ -77,7 +77,7 @@ class xmrseed(baseconv):
 
 		return b''.join(gen())
 
-	def frombytes(self, bytestr, pad=None, tostr=False):
+	def frombytes(self, bytestr, *, pad=None, tostr=False):
 		assert pad is None, f"{pad}: invalid 'pad' argument (must be None)"
 
 		desc = self.desc.short

+ 1 - 1
mmgen/xmrwallet/__init__.py

@@ -113,5 +113,5 @@ def op_cls(op_name):
 	cls.name = op_name
 	return cls
 
-def op(op, cfg, infile, wallets, spec=None):
+def op(op, cfg, infile, wallets, *, spec=None):
 	return op_cls(op.replace('-', '_'))(cfg, uargs(infile, wallets, spec))

+ 4 - 4
mmgen/xmrwallet/file/__init__.py

@@ -21,7 +21,7 @@ class MoneroMMGenFile:
 
 	silent_load = False
 
-	def make_chksum(self, keys=None):
+	def make_chksum(self, *, keys=None):
 		res = json.dumps(
 			dict((k, v) for k, v in self.data._asdict().items() if (not keys or k in keys)),
 			cls = json_encoder
@@ -30,11 +30,11 @@ class MoneroMMGenFile:
 
 	@property
 	def base_chksum(self):
-		return self.make_chksum(self.base_chksum_fields)
+		return self.make_chksum(keys=self.base_chksum_fields)
 
 	@property
 	def full_chksum(self):
-		return self.make_chksum(self.full_chksum_fields) if self.full_chksum_fields else None
+		return self.make_chksum(keys=self.full_chksum_fields) if self.full_chksum_fields else None
 
 	def check_checksums(self, d_wrap):
 		for k in ('base_chksum', 'full_chksum'):
@@ -60,5 +60,5 @@ class MoneroMMGenFile:
 
 	def extract_data_from_file(self, cfg, fn):
 		return json.loads(
-			get_data_from_file(cfg, str(fn), self.desc, silent=self.silent_load)
+			get_data_from_file(cfg, str(fn), desc=self.desc, silent=self.silent_load)
 		)[self.data_label]

+ 5 - 5
mmgen/xmrwallet/file/outputs.py

@@ -45,7 +45,7 @@ class MoneroWalletOutputsFile:
 			self.name = type(self).__name__
 			self.cfg = cfg
 
-		def write(self, add_suf='', quiet=False):
+		def write(self, *, add_suf='', quiet=False):
 			from ...fileutil import write_data_to_file
 			write_data_to_file(
 				cfg               = self.cfg,
@@ -71,7 +71,7 @@ class MoneroWalletOutputsFile:
 			)
 			return fn.parent / fn.name[:-(len(self.ext)+self.ext_offset+1)]
 
-		def get_info(self, indent=''):
+		def get_info(self, *, indent=''):
 			if self.data.signed_key_images is not None:
 				data = self.data.signed_key_images or []
 				return f'{indent}{self.wallet_fn.name}: {len(data)} signed key image{suf(data)}'
@@ -81,7 +81,7 @@ class MoneroWalletOutputsFile:
 	class New(Base):
 		ext = 'raw'
 
-		def __init__(self, parent, wallet_fn, data, wallet_idx=None, sign=False):
+		def __init__(self, parent, wallet_fn, data, *, wallet_idx=None, sign=False):
 			super().__init__(parent.cfg)
 			self.wallet_fn = wallet_fn
 			init_data = dict.fromkeys(self.data_tuple._fields)
@@ -96,7 +96,7 @@ class MoneroWalletOutputsFile:
 
 	class Completed(New):
 
-		def __init__(self, parent, fn=None, wallet_fn=None):
+		def __init__(self, parent, *, fn=None, wallet_fn=None):
 			def check_equal(desc, a, b):
 				assert a == b, f'{desc} mismatch: {a} (from file) != {b} (from filename)'
 			fn = fn or self.get_outfile(parent.cfg, wallet_fn)
@@ -115,7 +115,7 @@ class MoneroWalletOutputsFile:
 			self.check_checksums(d_wrap)
 
 		@classmethod
-		def find_fn_from_wallet_fn(cls, cfg, wallet_fn, ret_on_no_match=False):
+		def find_fn_from_wallet_fn(cls, cfg, wallet_fn, *, ret_on_no_match=False):
 			path = get_autosign_obj(cfg).xmr_outputs_dir or Path()
 			pat = cls.fn_fs.format(
 				a = wallet_fn.name,

+ 6 - 6
mmgen/xmrwallet/file/tx.py

@@ -84,7 +84,7 @@ class MoneroMMGenTX:
 		def src_wallet_idx(self):
 			return int(self.data.source.split(':')[0])
 
-		def get_info_oneline(self, indent='', addr_w=None):
+		def get_info_oneline(self, *, indent='', addr_w=None):
 			d = self.data
 			return self.oneline_fs.format(
 					a = yellow(d.network),
@@ -94,12 +94,12 @@ class MoneroMMGenTX:
 					e = purple(d.op.ljust(9)),
 					f = red('{}:{}'.format(d.source.wallet, d.source.account).ljust(6)),
 					g = red('{}:{}'.format(d.dest.wallet, d.dest.account).ljust(6)) if d.dest else cyan('ext   '),
-					h = d.amount.fmt(color=True, iwidth=4, prec=12),
-					j = d.dest_address.fmt(0, width=addr_w, color=True) if addr_w else d.dest_address.hl(0),
+					h = d.amount.fmt(4, color=True, prec=12),
+					j = d.dest_address.fmt(0, addr_w, color=True) if addr_w else d.dest_address.hl(0),
 					x = '->'
 				)
 
-		def get_info(self, indent='', addr_w=None):
+		def get_info(self, *, indent='', addr_w=None):
 			d = self.data
 			pmt_id = d.dest_address.parsed.payment_id
 			fs = '\n'.join(list_gen(
@@ -138,7 +138,7 @@ class MoneroMMGenTX:
 					F = (Int(d.priority).hl() + f' [{tx_priorities[d.priority]}]') if d.priority else None,
 					n = d.fee.hl(),
 					o = d.dest_address.hl(0) if self.cfg.full_address
-						else d.dest_address.fmt(0, width=addr_width, color=True),
+						else d.dest_address.fmt(0, addr_width, color=True),
 					P = pink(pmt_id.hex()) if pmt_id else None,
 					s = make_timestr(d.submit_time) if d.submit_time else None,
 					S = pink(f" [cold signed{', submitted' if d.complete else ''}]") if d.signed_txset else '',
@@ -153,7 +153,7 @@ class MoneroMMGenTX:
 		def file_id(self):
 			return (self.base_chksum + ('-' + self.full_chksum if self.full_chksum else '')).upper()
 
-		def write(self, delete_metadata=False, ask_write=True, ask_overwrite=True):
+		def write(self, *, delete_metadata=False, ask_write=True, ask_overwrite=True):
 			dict_data = self.data._asdict()
 			if delete_metadata:
 				dict_data['metadata'] = None

+ 2 - 2
mmgen/xmrwallet/include.py

@@ -19,7 +19,7 @@ from ..color import red, green, pink
 from ..addr import CoinAddr, AddrIdx
 from ..util import die
 
-def gen_acct_addr_info(self, wallet_data, account, indent=''):
+def gen_acct_addr_info(self, wallet_data, account, *, indent=''):
 	fs = indent + '{I:<3} {A} {U} {B} {L}'
 	addrs_data = wallet_data.addrs_data[account]['addresses']
 
@@ -47,7 +47,7 @@ def gen_acct_addr_info(self, wallet_data, account, indent=''):
 		from .ops import fmt_amt
 		yield fs.format(
 			I = addr['address_index'],
-			A = ca.hl(0) if self.cfg.full_address else ca.fmt(0, color=True, width=addr_width),
+			A = ca.hl(0) if self.cfg.full_address else ca.fmt(0, addr_width, color=True),
 			U = (red('True ') if addr['used'] else green('False')),
 			B = fmt_amt(bal),
 			L = pink(addr['label']))

+ 2 - 2
mmgen/xmrwallet/ops/__init__.py

@@ -51,7 +51,7 @@ class OpBase:
 		self.uargs = uarg_tuple
 
 		def fmt_amt(amt):
-			return self.proto.coin_amt(amt, from_unit='atomic').fmt(iwidth=5, prec=12, color=True)
+			return self.proto.coin_amt(amt, from_unit='atomic').fmt(5, prec=12, color=True)
 		def hl_amt(amt):
 			return self.proto.coin_amt(amt, from_unit='atomic').hl()
 
@@ -102,7 +102,7 @@ class OpBase:
 			self.cfg.tx_relay_daemon,
 			re.ASCII)
 
-	def display_tx_relay_info(self, indent=''):
+	def display_tx_relay_info(self, *, indent=''):
 		m = self.parse_tx_relay_opt()
 		msg(fmt(f"""
 			TX relay info:

+ 1 - 1
mmgen/xmrwallet/ops/create.py

@@ -68,7 +68,7 @@ class OpCreateOffline(OpCreate):
 		vkal = ViewKeyAddrList(
 			cfg       = self.cfg,
 			proto     = self.proto,
-			addrfile  = None,
+			infile    = None,
 			addr_idxs = self.uargs.wallets,
 			seed      = self.seed_src.seed,
 			skip_chksum_msg = True)

+ 1 - 1
mmgen/xmrwallet/ops/import.py

@@ -23,7 +23,7 @@ class OpImportOutputs(OpWallet):
 	action = 'importing wallet outputs into'
 	start_daemon = False
 
-	async def main(self, fn, wallet_idx, restart_daemon=True):
+	async def main(self, fn, wallet_idx, *, restart_daemon=True):
 		if restart_daemon:
 			await self.restart_wallet_daemon()
 		h = MoneroWalletRPC(self, self.addr_data[0])

+ 1 - 1
mmgen/xmrwallet/ops/label.py

@@ -59,7 +59,7 @@ class OpLabel(OpMixinSpec, OpWallet):
 		from . import addr_width
 		msg('\n  {a} {b}\n  {c} {d}\n  {e} {f}'.format(
 				a = 'Address:       ',
-				b = ca.hl(0) if self.cfg.full_address else ca.fmt(0, color=True, width=addr_width),
+				b = ca.hl(0) if self.cfg.full_address else ca.fmt(0, addr_width, color=True),
 				c = 'Existing label:',
 				d = pink(addr['label']) if addr['label'] else gray('[none]'),
 				e = 'New label:     ',

+ 1 - 1
mmgen/xmrwallet/ops/relay.py

@@ -41,7 +41,7 @@ class OpRelay(OpBase):
 			md = None
 		else:
 			from ...daemon import CoinDaemon
-			md = CoinDaemon(self.cfg, 'xmr', test_suite=self.cfg.test_suite)
+			md = CoinDaemon(self.cfg, network_id='xmr', test_suite=self.cfg.test_suite)
 			host, port = ('localhost', md.rpc_port)
 			proxy = None
 

+ 1 - 1
mmgen/xmrwallet/ops/sign.py

@@ -21,7 +21,7 @@ class OpSign(OpWallet):
 	action = 'signing transaction with'
 	start_daemon = False
 
-	async def main(self, fn, restart_daemon=True):
+	async def main(self, fn, *, restart_daemon=True):
 		if restart_daemon:
 			await self.restart_wallet_daemon()
 		tx = MoneroMMGenTX.Unsigned(self.cfg, fn)

+ 1 - 1
mmgen/xmrwallet/ops/txview.py

@@ -28,7 +28,7 @@ class OpTxview(OpBase):
 	footer = ''
 	do_umount = False
 
-	async def main(self, cols=None):
+	async def main(self, *, cols=None):
 
 		self.mount_removable_device()
 

+ 6 - 6
mmgen/xmrwallet/ops/wallet.py

@@ -102,9 +102,9 @@ class OpWallet(OpBase):
 			for first_try in (True, False):
 				try:
 					self.kal = (ViewKeyAddrList if (self.cfg.watch_only and first_try) else KeyAddrList)(
-						cfg      = cfg,
-						proto    = self.proto,
-						addrfile = str(self.autosign_viewkey_addr_file) if self.cfg.autosign else self.uargs.infile,
+						cfg    = cfg,
+						proto  = self.proto,
+						infile = str(self.autosign_viewkey_addr_file) if self.cfg.autosign else self.uargs.infile,
 						key_address_validity_check = True,
 						skip_chksum_msg = True)
 					break
@@ -140,7 +140,7 @@ class OpWallet(OpBase):
 		return MoneroRPCClient(
 			cfg    = self.cfg,
 			proto  = self.proto,
-			daemon = CoinDaemon(self.cfg, 'xmr'),
+			daemon = CoinDaemon(self.cfg, network_id='xmr'),
 			host   = host,
 			port   = int(port),
 			user   = None,
@@ -163,7 +163,7 @@ class OpWallet(OpBase):
 
 	def create_addr_data(self):
 		if self.uargs.wallets:
-			idxs = AddrIdxList(self.uargs.wallets)
+			idxs = AddrIdxList(fmt_str=self.uargs.wallets)
 			self.addr_data = [d for d in self.kal.data if d.idx in idxs]
 			if len(self.addr_data) != len(idxs):
 				die(1, f'List {self.uargs.wallets!r} contains addresses not present in supplied key-address file')
@@ -183,7 +183,7 @@ class OpWallet(OpBase):
 				self.c.daemon.force_kill = True
 				self.c.daemon.stop()
 
-	def get_wallet_fn(self, data, watch_only=None):
+	def get_wallet_fn(self, data, *, watch_only=None):
 		if watch_only is None:
 			watch_only = self.cfg.watch_only
 		return Path(

+ 5 - 5
mmgen/xmrwallet/rpc.py

@@ -35,7 +35,7 @@ class MoneroWalletRPC:
 			MoneroMMGenTX.NewUnsigned if self.cfg.watch_only else
 			MoneroMMGenTX.NewSigned)
 
-	def open_wallet(self, desc=None, refresh=True):
+	def open_wallet(self, desc=None, *, refresh=True):
 		add_desc = desc + ' ' if desc else self.parent.add_wallet_desc
 		gmsg_r(f'\n  Opening {add_desc}wallet...')
 		self.c.call( # returns {}
@@ -62,7 +62,7 @@ class MoneroWalletRPC:
 		await self.c.stop_daemon(quiet=True) # closes wallet
 		gmsg_r('done')
 
-	def gen_accts_info(self, accts_data, addrs_data, indent='    ', skip_empty_ok=False):
+	def gen_accts_info(self, accts_data, addrs_data, *, indent='    ', skip_empty_ok=False):
 		from .ops import addr_width
 		fs = indent + '  {I:<3} {A} {N} {B} {L}'
 		yield indent + f'Accounts of wallet {self.fn.name}:'
@@ -79,12 +79,12 @@ class MoneroWalletRPC:
 			from .ops import fmt_amt
 			yield fs.format(
 				I = str(e['account_index']),
-				A = ca.hl(0) if self.cfg.full_address else ca.fmt(0, color=True, width=addr_width),
+				A = ca.hl(0) if self.cfg.full_address else ca.fmt(0, addr_width, color=True),
 				N = red(str(len(addrs_data[i]['addresses'])).ljust(6)),
 				B = fmt_amt(e['unlocked_balance']),
 				L = pink(e['label']))
 
-	def get_wallet_data(self, print=True, skip_empty_ok=False):
+	def get_wallet_data(self, *, print=True, skip_empty_ok=False):
 		accts_data = self.c.call('get_accounts')
 		addrs_data = [
 			self.c.call('get_address', account_index=i)
@@ -123,7 +123,7 @@ class MoneroWalletRPC:
 		msg(cyan(ret['address']))
 		return ret['address']
 
-	def get_last_addr(self, account, wallet_data, display=True):
+	def get_last_addr(self, account, wallet_data, *, display=True):
 		if display:
 			msg('\n    Getting last address:')
 		acct_addrs = wallet_data.addrs_data[account]['addresses']

+ 1 - 0
nix/packages.nix

@@ -46,5 +46,6 @@
         semantic-version = semantic-version;
         pexpect          = pexpect;         # test suite
         pycoin           = pycoin;          # test suite
+        lxml             = lxml;
     };
 }

+ 9 - 0
pyproject.toml

@@ -6,6 +6,14 @@ requires = [
 ]
 build-backend = "setuptools.build_meta"
 
+[tool.ruff]
+line-length = 106
+indent-width = 4
+
+[tool.ruff.format]
+quote-style = "single"
+indent-style = "tab"
+
 [tool.ruff.lint]
 ignore = [
 	"E401", # multiple imports per line
@@ -125,4 +133,5 @@ ignored-classes = [ # ignored for no-member, otherwise checked
 	"Opts",
 	"Help",
 	"FFI_override",
+	"RPC",
 ]

+ 1 - 0
setup.cfg

@@ -58,6 +58,7 @@ install_requires =
 	aiohttp
 	requests
 	pexpect
+	lxml
 	scrypt; platform_system != "Windows" # must be installed by hand on MSYS2
 	semantic-version; platform_system != "Windows" # scripts/create-token.py
 

+ 4 - 0
test/cmdtest_d/common.py

@@ -108,6 +108,7 @@ def get_file_with_ext(
 		no_dot      = False,
 		return_list = False,
 		delete_all  = False,
+		subdir      = None,
 		substr      = False):
 
 	dot = '' if no_dot else '.'
@@ -118,6 +119,9 @@ def get_file_with_ext(
 			or fn.endswith(dot + ext)
 			or (substr and ext in fn))
 
+	if subdir:
+		tdir = os.path.join(tdir, subdir)
+
 	# Don’t use os.scandir here - it returns broken paths under Windows/MSYS2
 	flist = [os.path.join(tdir, name) for name in os.listdir(tdir) if have_match(name)]
 

+ 14 - 4
test/cmdtest_d/ct_automount.py

@@ -59,7 +59,9 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtest):
 		('alice_txcreate4',                  'creating a transaction'),
 		('alice_txbump1',                    'bumping the unsigned transaction (error)'),
 		('alice_txbump2',                    'bumping the unsent transaction (error)'),
-		('alice_txsend2',                    'sending the transaction'),
+		('alice_txsend2_dump_hex',           'dumping the transaction to hex'),
+		('alice_txsend2_cli',                'sending the transaction via cli'),
+		('alice_txsend2_mark_sent',          'marking the transaction sent'),
 		('alice_txbump3',                    'bumping the transaction'),
 		('alice_txsend3',                    'sending the bumped transaction'),
 		('alice_txbump4',                    'bumping the transaction (new outputs, fee too low)'),
@@ -143,10 +145,18 @@ class CmdTestAutosignAutomount(CmdTestAutosignThreaded, CmdTestRegtest):
 		return self.run_setup(mn_type='default', use_dfl_wallet=True, passwd=rt_pw)
 
 	def alice_txsend1(self):
-		return self._user_txsend('alice', 'This one’s worth a comment', no_wait=True)
+		return self._user_txsend('alice', comment='This one’s worth a comment', no_wait=True)
 
-	def alice_txsend2(self):
-		return self._user_txsend('alice', need_rbf=True)
+	def alice_txsend2_dump_hex(self):
+		return self._user_txsend('alice', need_rbf=True, dump_hex=True)
+
+	def alice_txsend2_cli(self):
+		if not self.proto.cap('rbf'):
+			return 'skip'
+		return self._user_dump_hex_send_cli('alice')
+
+	def alice_txsend2_mark_sent(self):
+		return self._user_txsend('alice', need_rbf=True, mark_sent=True)
 
 	def alice_txsend3(self):
 		return self._user_txsend('alice', need_rbf=True)

+ 30 - 7
test/cmdtest_d/ct_autosign.py

@@ -81,6 +81,7 @@ class CmdTestAutosignBase(CmdTestBase):
 			atexit.register(self._macOS_eject_disk, self.asi.dev_label)
 
 		self.opts = ['--coins='+','.join(self.coins)]
+		self.txhex_file = f'{self.tmpdir}/tx_dump.hex'
 
 		if not self.live:
 			self.spawn_env['MMGEN_TEST_SUITE_ROOT_PFX'] = self.tmpdir
@@ -228,7 +229,7 @@ class CmdTestAutosignBase(CmdTestBase):
 				t.expect('OK? (Y/n): ', '\n')
 			from mmgen.mn_entry import mn_entry
 			entry_mode = 'full'
-			mne = mn_entry(cfg, mn_type, entry_mode)
+			mne = mn_entry(cfg, mn_type, entry_mode=entry_mode)
 			if usr_entry_modes:
 				t.expect('user-configured')
 			else:
@@ -492,7 +493,15 @@ class CmdTestAutosignThreaded(CmdTestAutosignBase):
 
 		return do_return()
 
-	def _user_txsend(self, user, comment=None, no_wait=False, need_rbf=False):
+	def _user_txsend(
+			self,
+			user,
+			*,
+			comment   = None,
+			no_wait   = False,
+			need_rbf  = False,
+			dump_hex  = False,
+			mark_sent = False):
 
 		if need_rbf and not self.proto.cap('rbf'):
 			return 'skip'
@@ -500,12 +509,26 @@ class CmdTestAutosignThreaded(CmdTestAutosignBase):
 		if not no_wait:
 			self._wait_signed('transaction')
 
+		extra_opt = (
+			[f'--dump-hex={self.txhex_file}'] if dump_hex
+			else ['--mark-sent'] if mark_sent
+			else [])
+
 		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 = self.spawn('mmgen-txsend', [f'--{user}', '--quiet', '--autosign'] + extra_opt)
+
+		if mark_sent:
+			t.written_to_file('Sent automount transaction')
+		else:
+			t.view_tx('t')
+			t.do_comment(comment)
+			if dump_hex:
+				t.written_to_file('Serialized transaction hex data')
+				t.expect('(y/N): ', 'n') # mark as sent?
+			else:
+				self._do_confirm_send(t, quiet=True)
+				t.written_to_file('Sent automount transaction')
+
 		t.read()
 		self.remove_device_online()
 		return t

+ 3 - 2
test/cmdtest_d/ct_base.py

@@ -76,8 +76,9 @@ class CmdTestBase:
 	def get_file_with_ext(self, ext, **kwargs):
 		return get_file_with_ext(self.tmpdir, ext, **kwargs)
 
-	def read_from_tmpfile(self, fn, binary=False):
-		return read_from_file(os.path.join(self.tmpdir, fn), binary=binary)
+	def read_from_tmpfile(self, fn, binary=False, subdir=None):
+		tdir = os.path.join(self.tmpdir, subdir) if subdir else self.tmpdir
+		return read_from_file(os.path.join(tdir, fn), binary=binary)
 
 	def write_to_tmpfile(self, fn, data, binary=False):
 		return write_to_file(os.path.join(self.tmpdir, fn), data, binary=binary)

+ 21 - 3
test/cmdtest_d/ct_ethdev.py

@@ -58,6 +58,7 @@ from .common import (
 )
 from .ct_base import CmdTestBase
 from .ct_shared import CmdTestShared
+from .etherscan import run_etherscan_server
 
 del_addrs = ('4', '1')
 dfl_sid = '98831F3A'
@@ -178,6 +179,12 @@ token_bals_getbalance = lambda k: {
 
 coin = cfg.coin
 
+def etherscan_server_start():
+	import threading
+	t = threading.Thread(target=run_etherscan_server, name='Etherscan server thread')
+	t.daemon = True
+	t.start()
+
 class CmdTestEthdev(CmdTestBase, CmdTestShared):
 	'Ethereum transacting, token deployment and tracking wallet operations'
 	networks = ('eth', 'etc')
@@ -237,6 +244,8 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 		('txview1_sig',          'viewing the signed transaction'),
 		('tx_status0_bad',       'getting the transaction status'),
 		('txsign1_ni',           'signing the transaction (non-interactive)'),
+		('txsend_etherscan_test','sending the transaction via Etherscan (simulation, with --test)'),
+		('txsend_etherscan',     'sending the transaction via Etherscan (simulation)'),
 		('txsend1',              'sending the transaction'),
 		('bal1',                 f'the {coin} balance'),
 
@@ -426,7 +435,7 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 		self.proto = init_proto( cfg, cfg.coin, network='regtest', need_amt=True)
 
 		from mmgen.daemon import CoinDaemon
-		self.daemon = CoinDaemon( cfg, self.proto.coin+'_rt', test_suite=True)
+		self.daemon = CoinDaemon( cfg, network_id=self.proto.coin+'_rt', test_suite=True)
 
 		if self.daemon.id == 'reth':
 			global dfl_devkey, dfl_devaddr
@@ -450,6 +459,9 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 		self.message = 'attack at dawn'
 		self.spawn_env['MMGEN_BOGUS_SEND'] = ''
 
+		if type(self) is CmdTestEthdev:
+			etherscan_server_start() # TODO: stop server when test group finishes executing
+
 	@property
 	async def rpc(self):
 		from mmgen.rpc import rpc_init
@@ -715,7 +727,7 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 			+ [txfile, dfl_words_file])
 		return self.txsign_ui_common(t, ni=ni, has_label=True)
 
-	def txsend(self, ext='{}.regtest.sigtx', add_args=[]):
+	def txsend(self, ext='{}.regtest.sigtx', add_args=[], test=False):
 		ext = ext.format('-α' if cfg.debug_utf8 else '')
 		txfile = self.get_file_with_ext(ext, no_dot=True)
 		t = self.spawn('mmgen-txsend', self.eth_args + add_args + [txfile])
@@ -723,6 +735,7 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 			t,
 			quiet      = not cfg.debug,
 			bogus_send = False,
+			test       = test,
 			has_label  = True)
 		return t
 
@@ -765,6 +778,10 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 		return self.tx_status(ext='{}.regtest.sigtx', expect_str='neither in mempool nor blockchain', exit_val=1)
 	def txsign1_ni(self):
 		return self.txsign(ni=True, dev_send=True)
+	def txsend_etherscan_test(self):
+		return self.txsend(add_args=['--tx-proxy=ether', '--test'], test='tx_proxy')
+	def txsend_etherscan(self):
+		return self.txsend(add_args=['--tx-proxy=ethersc'])
 	def txsend1(self):
 		return self.txsend()
 	def txview1_sig(self): # do after send so that TxID is displayed
@@ -1127,7 +1144,8 @@ class CmdTestEthdev(CmdTestBase, CmdTestShared):
 					usr_addrs[i]))
 
 		def gen_addr(addr):
-			return tool_cmd(cfg, cmdname='gen_addr', proto=self.proto).gen_addr(addr, dfl_words_file)
+			return tool_cmd(
+				cfg, cmdname='gen_addr', proto=self.proto).gen_addr(addr, wallet=dfl_words_file)
 
 		silence()
 		usr_addrs = list(map(gen_addr, usr_mmaddrs))

+ 3 - 3
test/cmdtest_d/ct_input.py

@@ -422,7 +422,7 @@ class CmdTestInput(CmdTestBase):
 		mn = mn or sample_mn[fmt]['mn'].split()
 		t = self.spawn('mmgen-tool', ['mn2hex_interactive', 'fmt='+fmt, 'mn_len=12', 'print_mn=1'])
 		from mmgen.mn_entry import mn_entry
-		mne = mn_entry(cfg, fmt, entry_mode)
+		mne = mn_entry(cfg, fmt, entry_mode=entry_mode)
 		t.expect(
 			'Type a number.*: ',
 			('\n' if enter_for_dfl else str(mne.entry_modes.index(entry_mode)+1)),
@@ -465,7 +465,7 @@ class CmdTestInput(CmdTestBase):
 			t.expect('Type a number.*: ', '6', regex=True)
 			t.expect('invalid')
 			from mmgen.mn_entry import mn_entry
-			mne = mn_entry(cfg, fmt, entry_mode)
+			mne = mn_entry(cfg, fmt, entry_mode=entry_mode)
 			t.expect('Type a number.*: ', str(mne.entry_modes.index(entry_mode)+1), regex=True)
 			t.expect(r'Using entry mode (\S+)', regex=True)
 			mode = strip_ansi_escapes(t.p.match.group(1)).lower()
@@ -489,7 +489,7 @@ class CmdTestInput(CmdTestBase):
 	def mnemonic_entry_mmgen_minimal(self):
 		from mmgen.mn_entry import mn_entry
 		# erase_chars: '\b\x7f'
-		m = mn_entry(cfg, 'mmgen', 'minimal')
+		m = mn_entry(cfg, 'mmgen', entry_mode='minimal')
 		np = 2
 		mn = (
 			'z',

+ 11 - 5
test/cmdtest_d/ct_main.py

@@ -68,7 +68,7 @@ def make_brainwallet_file(fn):
 	d = ''.join(rand_pairs).rstrip() + '\n'
 	if cfg.verbose:
 		msg_r(f'Brainwallet password:\n{cyan(d)}')
-	write_data_to_file(cfg, fn, d, 'brainwallet password', quiet=True, ignore_opt_outdir=True)
+	write_data_to_file(cfg, fn, d, desc='brainwallet password', quiet=True, ignore_opt_outdir=True)
 
 def verify_checksum_or_exit(checksum, chk):
 	chk = strip_ansi_escapes(chk)
@@ -397,7 +397,7 @@ class CmdTestMain(CmdTestBase, CmdTestShared):
 		addrfile = self.get_file_with_ext('addrs')
 		from mmgen.addrlist import AddrList
 		silence()
-		chk = AddrList(cfg, self.proto, addrfile).chksum
+		chk = AddrList(cfg, self.proto, infile=addrfile).chksum
 		end_silence()
 		if cfg.verbose and display:
 			msg(f'Checksum: {cyan(chk)}')
@@ -533,7 +533,13 @@ class CmdTestMain(CmdTestBase, CmdTestShared):
 		return self.walletchk(wf, wcls=wcls, dfl_wallet=dfl_wallet)
 
 	def _write_fake_data_to_file(self, d):
-		write_data_to_file(cfg, self.unspent_data_file, d, 'Unspent outputs', quiet=True, ignore_opt_outdir=True)
+		write_data_to_file(
+				cfg,
+				self.unspent_data_file,
+				d,
+				desc              = 'Unspent outputs',
+				quiet             = True,
+				ignore_opt_outdir = True)
 		if cfg.verbose or cfg.exact_output:
 			sys.stderr.write(f'Fake transaction wallet data written to file {self.unspent_data_file!r}\n')
 
@@ -627,7 +633,7 @@ class CmdTestMain(CmdTestBase, CmdTestShared):
 		tx_data, ad = {}, AddrData(self.proto)
 		for s in sources:
 			addrfile = get_file_with_ext(self.cfgs[s]['tmpdir'], 'addrs')
-			al = AddrList(cfg, self.proto, addrfile)
+			al = AddrList(cfg, self.proto, infile=addrfile)
 			ad.add(al)
 			aix = AddrIdxList(fmt_str=self.cfgs[s]['addr_idx_list'])
 			if len(aix) != addrs_per_wallet:
@@ -843,7 +849,7 @@ class CmdTestMain(CmdTestBase, CmdTestShared):
 		wcls = get_wallet_cls(fmt_code=out_fmt)
 		msg('==> {}: {}'.format(
 			wcls.desc,
-			cyan(get_data_from_file(cfg, f, wcls.desc))
+			cyan(get_data_from_file(cfg, f, desc=wcls.desc))
 		))
 		end_silence()
 		return t

+ 97 - 21
test/cmdtest_d/ct_regtest.py

@@ -27,7 +27,7 @@ from mmgen.proto.btc.regtest import MMGenRegtest
 from mmgen.proto.bch.cashaddr import b32a
 from mmgen.proto.btc.common import b58a
 from mmgen.color import yellow
-from mmgen.util import msg_r, die, gmsg, capfirst, suf, fmt_list
+from mmgen.util import msg_r, die, gmsg, capfirst, suf, fmt_list, is_hex_str
 from mmgen.protocol import init_proto
 from mmgen.addrlist import AddrList
 from mmgen.wallet import Wallet, get_wallet_cls
@@ -189,14 +189,15 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 		('subgroup.view',           ['label']),
 		('subgroup._auto_chg_deps', ['twexport', 'label']),
 		('subgroup.auto_chg',       ['_auto_chg_deps']),
+		('subgroup.dump_hex',       ['fund_users']),
 		('stop',                    'stopping regtest daemon'),
 	)
 	cmd_subgroups = {
 	'misc': (
 		'miscellaneous commands',
-		('daemon_version',         'mmgen-tool daemon_version'),
-		('halving_calculator_bob', 'halving calculator (Bob)'),
-		('cli_txcreate',           '‘mmgen-cli createrawtransaction’'),
+		('daemon_version',           'mmgen-tool daemon_version'),
+		('halving_calculator_bob',   'halving calculator (Bob)'),
+		('cli_createrawtransaction', '‘mmgen-cli createrawtransaction’'),
 	),
 	'init_bob': (
 		'creating Bob’s MMGen wallet and tracking wallet',
@@ -224,7 +225,7 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 		('fund_bob',                     'funding Bob’s wallet'),
 		('fund_alice',                   'funding Alice’s wallet'),
 		('generate',                     'mining a block'),
-		('bob_bal1_cli',                 'Bob’s balance (via ‘mmgen-cli’)'),
+		('bob_bal1',                     'Bob’s balance'),
 		('generate_extra_deterministic', 'generate extra blocks for deterministic run'),
 	),
 	'msg': (
@@ -458,6 +459,17 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 									'(no unused addresses)'),
 		('carol_delete_wallet',      'unloading and deleting Carol’s tracking wallet'),
 	),
+	'dump_hex': (
+		'sending from dumped hex',
+		('bob_dump_hex_create',      'dump_hex transaction - creating'),
+		('bob_dump_hex_sign',        'dump_hex transaction - signing'),
+		('bob_dump_hex_dump_stdout', 'dump_hex transaction - dumping tx hex to stdout'),
+		('bob_dump_hex_dump',        'dump_hex transaction - dumping tx hex to file'),
+		('bob_dump_hex_test',        'dump_hex transaction - test whether TX can be sent'),
+		('bob_dump_hex_send_cli',    'dump_hex transaction - sending via cli'),
+		('generate',                 'mining a block'),
+		('bob_bal7',                 'Bob’s balance'),
+	),
 	}
 
 	def __init__(self, trunner, cfgs, spawn):
@@ -494,11 +506,12 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 		self.burn_addr = make_burn_addr(self.proto)
 		self.user_sids = {}
 		self.protos = (self.proto,)
+		self.dump_hex_subdir = os.path.join(self.tmpdir, 'nochg_tx')
 
 	def _add_comments_to_addr_file(self, proto, addrfile, outfile, use_comments=False):
 		silence()
 		gmsg(f'Adding comments to address file {addrfile!r}')
-		a = AddrList(cfg, proto, addrfile)
+		a = AddrList(cfg, proto, infile=addrfile)
 		for n, idx in enumerate(a.idxs(), 1):
 			if use_comments:
 				a.set_comment(idx, get_comment())
@@ -548,7 +561,7 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 		t.expect('time until halving')
 		return t
 
-	def cli_txcreate(self):
+	def cli_createrawtransaction(self):
 		txid = 'beadcafe' * 8
 		return self.spawn(
 			'mmgen-cli',
@@ -777,6 +790,15 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 	def bob_twview1(self):
 		return self.user_twview('bob', chk=('1', rtAmts[0]))
 
+	def _user_bal_cli(self, user, *, chk=None, chks=[]):
+		t = self.spawn('mmgen-cli', [f'--{user}', 'getbalance', '*', '1', 'true'])
+		res = t.read().splitlines()[0].rstrip('0').rstrip('.')
+		if chk:
+			assert res == chk, f'{res}: invalid balance! (expected {chk})'
+		else:
+			assert res in chks, f'{res}: invalid balance! (expected one of {chks})'
+		return t
+
 	def user_bal(self, user, bal, opts=[], args=['showempty=1'], skip_check=False, proto=None):
 		proto = proto or self.proto
 		t = self.spawn('mmgen-tool', opts + [f'--{user}', f'--coin={proto.coin}', 'listaddresses'] + args)
@@ -785,20 +807,16 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 		return t
 
 	def alice_bal1(self):
-		return self.user_bal('alice', rtFundAmt)
+		return self._user_bal_cli('alice', chk=rtFundAmt)
 
 	def alice_bal2(self):
-		return self.user_bal('alice', rtBals[8])
+		return self._user_bal_cli('alice', chk=rtBals[8])
 
-	def bob_bal1_cli(self):
-		t = self.spawn(
-			'mmgen-cli',
-			['--regtest=1', '--wallet=bob', f'--coin={self.proto.coin}', 'getbalance', '*', '0', 'true'])
-		t.expect(rtFundAmt + '.00')
-		return t
+	def bob_bal1(self):
+		return self._user_bal_cli('bob', chk=rtFundAmt)
 
 	def bob_bal2(self):
-		return self.user_bal('bob', rtBals[0], self._cashaddr_opt(1))
+		return self._user_bal_cli('bob', chk=rtBals[0])
 
 	def bob_bal2a(self):
 		return self.user_bal('bob', rtBals[0], args=['showempty=1', 'age_fmt=confs'])
@@ -819,16 +837,16 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 		return self.user_bal('bob', rtBals[0], args=['showempty=0', 'sort=twmmid', 'reverse=1'])
 
 	def bob_bal3(self):
-		return self.user_bal('bob', rtBals[1])
+		return self._user_bal_cli('bob', chk=rtBals[1])
 
 	def bob_bal4(self):
-		return self.user_bal('bob', rtBals[2])
+		return self._user_bal_cli('bob', chk=rtBals[2])
 
 	def bob_bal5(self):
-		return self.user_bal('bob', rtBals[3])
+		return self._user_bal_cli('bob', chk=rtBals[3])
 
 	def bob_bal6(self):
-		return self.user_bal('bob', rtBals[7])
+		return self._user_bal_cli('bob', chk=rtBals[7])
 
 	def bob_subwallet_addrgen1(self):
 		return self.addrgen('bob', subseed_idx='29L', mmtypes=['C'])  # 29L: 2FA7BBA8
@@ -1110,7 +1128,7 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 			sid, self.get_altcoin_pfx(proto.coin), id_str, addr_range, x='-α' if cfg.debug_utf8 else '')
 		addrfile = get_file_with_ext(self._user_dir(user), ext, no_dot=True)
 		silence()
-		addr = AddrList(cfg, proto, addrfile).data[idx].addr
+		addr = AddrList(cfg, proto, infile=addrfile).data[idx].addr
 		end_silence()
 		return addr
 
@@ -2180,6 +2198,64 @@ class CmdTestRegtest(CmdTestBase, CmdTestShared):
 			'L',
 			'contains no unused addresses of address type')
 
+	def bob_dump_hex_create(self):
+		if not os.path.exists(self.dump_hex_subdir):
+			os.mkdir(self.dump_hex_subdir)
+		autochg_arg = self._user_sid('bob') + ':C'
+		return self.txcreate_ui_common(
+			self.spawn('mmgen-txcreate',
+				[
+					'-d',
+					self.dump_hex_subdir,
+					'-B',
+					'--bob',
+					'--fee=0.00009713',
+					autochg_arg
+				]),
+			auto_chg_addr = autochg_arg)
+
+	def bob_dump_hex_sign(self):
+		txfile = get_file_with_ext(self.dump_hex_subdir, 'rawtx')
+		return self.txsign_ui_common(
+			self.spawn('mmgen-txsign', ['-d', self.dump_hex_subdir, '--bob', txfile]),
+			do_passwd = True,
+			passwd    = rt_pw)
+
+	def _bob_dump_hex_dump(self, file):
+		txfile = get_file_with_ext(self.dump_hex_subdir, 'sigtx')
+		t = self.spawn('mmgen-txsend', ['-d', self.dump_hex_subdir, f'--dump-hex={file}', '--bob', txfile])
+		t.expect('view: ', '\n')
+		t.expect('(y/N): ', '\n') # add comment?
+		t.written_to_file('Sent transaction')
+		return t
+
+	def bob_dump_hex_dump(self):
+		return self._bob_dump_hex_dump('tx_dump.hex')
+
+	def bob_dump_hex_dump_stdout(self):
+		return self._bob_dump_hex_dump('-')
+
+	def _user_dump_hex_send_cli(self, user, *, subdir=None):
+		txhex = self.read_from_tmpfile('tx_dump.hex', subdir=subdir).strip()
+		t = self.spawn('mmgen-cli', [f'--{user}', 'sendrawtransaction', txhex])
+		txid = t.read().splitlines()[0]
+		assert is_hex_str(txid) and len(txid) == 64
+		return t
+
+	def bob_dump_hex_test(self):
+		txfile = get_file_with_ext(self.dump_hex_subdir, 'sigtx')
+		t = self.spawn('mmgen-txsend', ['--bob', '--test', txfile])
+		self.txsend_ui_common(t, bogus_send=False, test=True)
+		return t
+
+	def bob_dump_hex_send_cli(self):
+		return self._user_dump_hex_send_cli('bob', subdir='nochg_tx')
+
+	def bob_bal7(self):
+		if not self.coin == 'btc':
+			return 'skip'
+		return self._user_bal_cli('bob', chks=['499.99990287', '46.51845565'])
+
 	def stop(self):
 		self.spawn('', msg_only=True)
 		if cfg.no_daemon_stop:

+ 17 - 7
test/cmdtest_d/ct_shared.py

@@ -134,15 +134,18 @@ class CmdTestShared:
 			ni          = False,
 			save        = True,
 			do_passwd   = False,
+			passwd      = None,
 			has_label   = False):
 
 		txdo = (caller or self.test_name)[:4] == 'txdo'
 
-		if do_passwd:
-			t.passphrase('MMGen wallet', self.wpasswd)
+		if do_passwd and txdo:
+			t.passphrase('MMGen wallet', passwd or self.wpasswd)
 
-		if not ni and not txdo:
+		if not (ni or txdo):
 			t.view_tx(view)
+			if do_passwd:
+				t.passphrase('MMGen wallet', passwd or self.wpasswd)
 			t.do_comment(add_comment, has_label=has_label)
 			t.expect('(Y/n): ', ('n', 'y')[save])
 
@@ -159,6 +162,7 @@ class CmdTestShared:
 			file_desc    = 'Sent transaction',
 			confirm_send = True,
 			bogus_send   = True,
+			test         = False,
 			quiet        = False,
 			has_label    = False):
 
@@ -169,16 +173,22 @@ class CmdTestShared:
 			t.view_tx(view)
 			t.do_comment(add_comment, has_label=has_label)
 
-		self._do_confirm_send(t, quiet=quiet, confirm_send=confirm_send)
+		if not test:
+			self._do_confirm_send(t, quiet=quiet, confirm_send=confirm_send)
 
 		if bogus_send:
 			txid = ''
 			t.expect('BOGUS transaction NOT sent')
+		elif test == 'tx_proxy':
+			t.expect('can be sent')
+			return True
 		else:
-			txid = strip_ansi_escapes(t.expect_getend('Transaction sent: '))
+			m = 'TxID: ' if test else 'Transaction sent: '
+			txid = strip_ansi_escapes(t.expect_getend(m))
 			assert len(txid) == 64, f'{txid!r}: Incorrect txid length!'
 
-		t.written_to_file(file_desc)
+		if not test:
+			t.written_to_file(file_desc)
 
 		return txid
 
@@ -295,7 +305,7 @@ class CmdTestShared:
 			fn = t.written_to_file('Password list' if passgen else 'Addresses')
 			cls = PasswordList if passgen else AddrList
 			silence()
-			al = cls(cfg, self.proto, fn, skip_chksum_msg=True) # read back the file we’ve written
+			al = cls(cfg, self.proto, infile=fn, skip_chksum_msg=True) # read back the file we’ve written
 			end_silence()
 			cmp_or_die(al.chksum, chksum, desc=f'{ftype}list data checksum from file')
 		return t

+ 1 - 1
test/cmdtest_d/ct_swap.py

@@ -669,7 +669,7 @@ class CmdTestSwap(CmdTestRegtest, CmdTestAutosignThreaded):
 		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 = self.spawn('mmgen-txsend', ['-d', self.tmpdir, '--bob', fn], exit_val=1)
 		t.expect('expired')
 		return t
 

+ 1 - 1
test/cmdtest_d/ct_xmr_autosign.py

@@ -133,7 +133,7 @@ class CmdTestXMRAutosign(CmdTestXMRWallet, CmdTestAutosignThreaded):
 			cfg       = self.cfg,
 			proto     = self.proto,
 			addr_idxs = '1-2',
-			seed      = Wallet(cfg, data.mmwords).seed,
+			seed      = Wallet(cfg, fn=data.mmwords).seed,
 			skip_chksum_msg = True,
 			key_address_validity_check = False)
 		kal.file.write(ask_overwrite=False)

+ 3 - 11
test/cmdtest_d/ct_xmrwallet.py

@@ -25,6 +25,7 @@ from subprocess import run, PIPE
 from collections import namedtuple
 
 from mmgen.util import msg, fmt, async_run, capfirst, is_int, die, list_gen
+from mmgen.util2 import port_in_use
 from mmgen.obj import MMGenRange
 from mmgen.amt import XMRAmt
 from mmgen.addrlist import ViewKeyAddrList, KeyAddrList, AddrIdxList
@@ -155,15 +156,6 @@ class CmdTestXMRWallet(CmdTestBase):
 	@classmethod
 	def init_proxy(cls, external_call=False):
 
-		def port_in_use(port):
-			import socket
-			try:
-				socket.create_connection(('localhost', port)).close()
-			except:
-				return False
-			else:
-				return True
-
 		def start_proxy():
 			if external_call or not cfg.no_daemon_autostart:
 				run(a+b2)
@@ -512,7 +504,7 @@ class CmdTestXMRWallet(CmdTestBase):
 			+ ([] if data.autosign else [data.kafile])
 			+ ([wallets] if wallets else [])
 		)
-		wlist = AddrIdxList(wallets) if wallets else MMGenRange(data.kal_range).items
+		wlist = AddrIdxList(fmt_str=wallets) if wallets else MMGenRange(data.kal_range).items
 		for n, wnum in enumerate(wlist, 1):
 			t.expect('ing wallet {}/{} ({})'.format(
 				n,
@@ -684,7 +676,7 @@ class CmdTestXMRWallet(CmdTestBase):
 		kal = (ViewKeyAddrList if data.autosign else KeyAddrList)(
 			cfg      = cfg,
 			proto    = self.proto,
-			addrfile = data.kafile,
+			infile   = data.kafile,
 			skip_chksum_msg = True,
 			key_address_validity_check = False)
 		end_silence()

+ 32 - 0
test/cmdtest_d/etherscan.py

@@ -0,0 +1,32 @@
+#!/usr/bin/env python3
+
+from http.server import HTTPServer, CGIHTTPRequestHandler
+
+from mmgen.util import msg
+from mmgen.util2 import port_in_use
+
+class handler(CGIHTTPRequestHandler):
+	header = b'HTTP/1.1 200 OK\nContent-type: text/html\n\n'
+
+	def do_response(self, target):
+		with open(f'test/ref/ethereum/etherscan-{target}.html') as fh:
+			text = fh.read()
+		self.wfile.write(self.header + text.encode())
+
+	def do_GET(self):
+		return self.do_response('form')
+
+	def do_POST(self):
+		return self.do_response('result')
+
+def run_etherscan_server(server_class=HTTPServer, handler_class=handler):
+
+	if port_in_use(28800):
+		msg('Port 28800 in use. Assuming etherscan server is running')
+		return True
+
+	msg('Etherscan server listening on port 28800')
+	server_address = ('localhost', 28800)
+	httpd = server_class(server_address, handler_class)
+	httpd.serve_forever()
+	msg('Etherscan server exiting')

+ 2 - 2
test/daemontest_d/ut_exec.py

@@ -14,7 +14,7 @@ from mmgen.daemon import CoinDaemon
 from ..include.common import cfg, qmsg, qmsg_r, vmsg, msg
 
 def test_flags(coin):
-	d = CoinDaemon(cfg, coin)
+	d = CoinDaemon(cfg, network_id=coin)
 	vmsg(f'Available opts:  {fmt_list(d.avail_opts, fmt="bare")}')
 	vmsg(f'Available flags: {fmt_list(d.avail_flags, fmt="bare")}')
 	vals = namedtuple('vals', ['online', 'no_daemonize', 'keep_cfg_file'])
@@ -26,7 +26,7 @@ def test_flags(coin):
 				(['online'],                 ['keep_cfg_file'], vals(True, False, True)),
 				(['online', 'no_daemonize'], ['keep_cfg_file'], vals(True, True, True)),
 			):
-			d = CoinDaemon(cfg, coin, opts=opts, flags=flags)
+			d = CoinDaemon(cfg, network_id=coin, opts=opts, flags=flags)
 			assert d.flag.keep_cfg_file == val.keep_cfg_file
 			assert d.opt.online == val.online
 			assert d.opt.no_daemonize == val.no_daemonize

+ 3 - 3
test/daemontest_d/ut_msg.py

@@ -84,13 +84,13 @@ async def run_test(network_id, chksum, msghash_type='raw'):
 	print_total(await m.verify())
 
 	pumsg('\nTesting single address verification:\n')
-	print_total(await m.verify(single_addr))
+	print_total(await m.verify(addr=single_addr))
 
 	pumsg('\nTesting JSON dump for export:\n')
 	msg(m.get_json_for_export())
 
 	pumsg('\nTesting single address JSON dump for export:\n')
-	msg(m.get_json_for_export(single_addr))
+	msg(m.get_json_for_export(addr=single_addr))
 
 	from mmgen.fileutil import write_data_to_file
 	exported_sigs = os.path.join(tmpdir, 'signatures.json')
@@ -107,7 +107,7 @@ async def run_test(network_id, chksum, msghash_type='raw'):
 	print_total(await m.verify())
 
 	pumsg('\nTesting single address verification (exported data):\n')
-	print_total(await m.verify(single_addr_coin))
+	print_total(await m.verify(addr=single_addr_coin))
 
 	pumsg('\nTesting display (exported data):\n')
 	msg(m.format())

+ 7 - 4
test/daemontest_d/ut_rpc.py

@@ -91,7 +91,7 @@ class init_test:
 
 	@staticmethod
 	async def btc(cfg, daemon, backend, cfg_override):
-		rpc = await rpc_init(cfg, daemon.proto, backend, daemon)
+		rpc = await rpc_init(cfg, daemon.proto, backend=backend, daemon=daemon)
 		do_msg(rpc, backend)
 
 		wi = await rpc.walletinfo
@@ -106,7 +106,7 @@ class init_test:
 
 	@staticmethod
 	async def bch(cfg, daemon, backend, cfg_override):
-		rpc = await rpc_init(cfg, daemon.proto, backend, daemon)
+		rpc = await rpc_init(cfg, daemon.proto, backend=backend, daemon=daemon)
 		do_msg(rpc, backend)
 		return rpc
 
@@ -114,7 +114,7 @@ class init_test:
 
 	@staticmethod
 	async def eth(cfg, daemon, backend, cfg_override):
-		rpc = await rpc_init(cfg, daemon.proto, backend, daemon)
+		rpc = await rpc_init(cfg, daemon.proto, backend=backend, daemon=daemon)
 		do_msg(rpc, backend)
 		await rpc.call('eth_blockNumber', timeout=300)
 		if rpc.proto.network == 'testnet':
@@ -167,7 +167,7 @@ async def run_test(network_ids, test_cf_auth=False, daemon_ids=None, cfg_overrid
 
 class unit_tests:
 
-	altcoin_deps = ('ltc', 'bch', 'geth', 'erigon', 'parity', 'xmrwallet')
+	altcoin_deps = ('ltc', 'bch', 'geth', 'reth', 'erigon', 'parity', 'xmrwallet')
 	arm_skip = ('parity',) # no prebuilt binaries for ARM
 
 	async def btc(self, name, ut):
@@ -204,6 +204,9 @@ class unit_tests:
 				'eth_testnet_chain_names': ['goerli', 'holesky', 'foo', 'bar', 'baz'],
 		})
 
+	async def reth(self, name, ut):
+		return await run_test(['eth', 'eth_rt'], daemon_ids=['reth']) # TODO: eth_tn
+
 	async def erigon(self, name, ut):
 		return await run_test(['eth', 'eth_tn', 'eth_rt'], daemon_ids=['erigon'])
 

+ 1 - 1
test/daemontest_d/ut_tx.py

@@ -110,7 +110,7 @@ class unit_tests:
 
 	async def newtx(self, name, ut):
 		qmsg('  Testing NewTX initializer')
-		d = CoinDaemon(cfg, 'btc', test_suite=True)
+		d = CoinDaemon(cfg, network_id='btc', test_suite=True)
 		d.start()
 
 		proto = init_proto(cfg, 'btc', need_amt=True)

+ 3 - 3
test/gentest.py

@@ -322,7 +322,7 @@ def do_ab_test(proto, scfg, addr_type, gen1, kg2, ag, tool, cache_data):
 			for _ in range(scfg.rounds):
 				yield getrand(32)
 
-	kg1 = KeyGenerator(cfg, proto, addr_type.pubkey_type, gen1)
+	kg1 = KeyGenerator(cfg, proto, addr_type.pubkey_type, backend=gen1)
 	if type(kg1) == type(kg2):
 		die(4, 'Key generators are the same!')
 
@@ -378,7 +378,7 @@ def ab_test(proto, scfg):
 
 	if scfg.gen2:
 		assert scfg.gen1 != 'all', "'all' must be used only with external tool"
-		kg2 = KeyGenerator(cfg, proto, addr_type.pubkey_type, scfg.gen2)
+		kg2 = KeyGenerator(cfg, proto, addr_type.pubkey_type, backend=scfg.gen2)
 		tool = None
 	else:
 		toolname = find_or_check_tool(proto, addr_type, scfg.tool)
@@ -541,7 +541,7 @@ def main():
 		for p in protos:
 			ab_test(p, scfg)
 	else:
-		kg = KeyGenerator(cfg, proto, addr_type.pubkey_type, scfg.gen1)
+		kg = KeyGenerator(cfg, proto, addr_type.pubkey_type, backend=scfg.gen1)
 		ag = AddrGenerator(cfg, proto, addr_type)
 		if scfg.test == 'speed':
 			speed_test(proto, kg, ag, scfg.rounds)

+ 1 - 1
test/include/common.py

@@ -307,7 +307,7 @@ def test_daemons_ops(*network_ids, op, remove_datadir=False):
 		silent = not (cfg.verbose or cfg.exact_output)
 		ret = False
 		for network_id in network_ids:
-			d = CoinDaemon(cfg, network_id, test_suite=True)
+			d = CoinDaemon(cfg, network_id=network_id, test_suite=True)
 			if remove_datadir:
 				d.wait = True
 				d.stop(silent=True)

+ 2 - 2
test/modtest_d/ut_addrlist.py

@@ -27,7 +27,7 @@ def do_test(
 	proto = init_proto(cfg, coin or 'btc')
 	seed = Seed(cfg, seed_bin=bytes.fromhex('feedbead'*8))
 	mmtype = MMGenAddrType(proto, addrtype or 'C')
-	idxs = AddrIdxList(idx_spec or '1-3')
+	idxs = AddrIdxList(fmt_str=idx_spec or '1-3')
 
 	if cfg.verbose:
 		debug_addrlist_save = cfg.debug_addrlist
@@ -84,7 +84,7 @@ class unit_tests:
 				('2,4',           '2,4'),
 				('',              ''),
 			):
-			l = AddrIdxList(i)
+			l = AddrIdxList(fmt_str=i)
 			if cfg.verbose:
 				msg(f'list: {list(l)}\nin:   {i}\nout:  {o}\n')
 			assert l.id_str == o, f'{l.id_str} != {o}'

+ 81 - 0
test/modtest_d/ut_amt.py

@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+
+"""
+test.modtest_d.ut_amt: CoinAmt unit tests for the MMGen suite
+"""
+
+from decimal import Decimal
+
+from mmgen.protocol import init_proto
+from mmgen.tx.new import parse_fee_spec
+from mmgen.cfg import Config
+
+from ..include.common import cfg, vmsg
+
+def get_protos(data):
+	return {coin: init_proto(cfg, coin, need_amt=True) for coin in set(d[0] for d in data)}
+
+def test_to_unit(data):
+	protos = get_protos(data)
+	for proto, amt, unit, chk in data:
+		amt = protos[proto].coin_amt(amt)
+		res = amt.to_unit(unit)
+		vmsg(f'  {proto.upper()} {amt.fmt(8)} => {res:<14} {unit}')
+		if '.' in chk:
+			assert res == Decimal(chk), f'{res} != {Decimal(chk)}'
+		else:
+			assert res == int(chk), f'{res} != {int(chk)}'
+	return True
+
+def test_fee_spec(data):
+	protos = get_protos(data)
+	for proto, spec, amt, unit in data:
+		vmsg(f'  {proto.upper():6} {spec:<5} => {amt:<4} {unit}')
+		res = parse_fee_spec(protos[proto], spec)
+		assert res.amt == amt, f'  {res.amt} != {amt}'
+		assert res.unit == unit, f'  {res.unit} != {unit}'
+	return True
+
+class unit_tests:
+
+	altcoin_deps = ('fee_spec_alt', 'to_unit_alt')
+
+	def to_unit(self, name, ut, desc='CoinAmt.to_unit() (BTC)'):
+		return test_to_unit((
+			('btc', '0.00000001', 'satoshi', '1'),
+			('btc', '1.23456789', 'satoshi', '123456789')))
+
+	def to_unit_alt(self, name, ut, desc='CoinAmt.to_unit() (LTC, BCH, ETH, XMR)'):
+		return test_to_unit((
+			('ltc', '0.00000001',           'satoshi', '1'),
+			('ltc', '1.23456789',           'satoshi', '123456789'),
+			('bch', '0.00000001',           'satoshi', '1'),
+			('bch', '1.23456789',           'satoshi', '123456789'),
+			('eth', '1.234567890123456789', 'wei',     '1234567890123456789'),
+			('eth', '1.234567890123456789', 'Kwei',    '1234567890123456.789'),
+			('eth', '1.234567890123456789', 'Mwei',    '1234567890123.456789'),
+			('eth', '1.234567890123456789', 'finney',  '1234.567890123456789'),
+			('eth', '0.000000012345678901', 'Mwei',    '12345.678901'),
+			('eth', '0.000000000000000001', 'Kwei',    '0.001'),
+			('eth', '0.000000000000000001', 'Gwei',    '0.000000001'),
+			('eth', '0.00000001',           'Gwei',    '10'),
+			('eth', '1',                    'Gwei',    '1000000000'),
+			('eth', '1',                    'finney',  '1000'),
+			('xmr', '1',                    'atomic',  '1000000000000'),
+			('xmr', '0.000000000001',       'atomic',  '1'),
+			('xmr', '1.234567890123',       'atomic',  '1234567890123')))
+
+	def fee_spec(self, name, ut, desc='fee spec parsing (BTC)'):
+		return test_fee_spec((
+			('btc', '32s',  '32', 'satoshi'),
+			('btc', '1s',   '1',  'satoshi')))
+
+	def fee_spec_alt(self, name, ut, desc='fee spec parsing (LTC, BCH, ETH, XMR)'):
+		return test_fee_spec((
+			('ltc', '3.07s', '3.07', 'satoshi'),
+			('bch', '3.07s', '3.07', 'satoshi'),
+			('eth', '3.07G', '3.07', 'Gwei'),
+			('eth', '37M',   '37',   'Mwei'),
+			('eth', '3701w', '3701', 'wei'),
+			('eth', '3.07M', '3.07', 'Mwei'),
+			('xmr', '3.07a', '3.07', 'atomic')))

+ 1 - 1
test/modtest_d/ut_bip39.py

@@ -180,7 +180,7 @@ class unit_tests:
 		assert seed_hex == '3c30b98d3d9a713cf5a7a42f5dd27b3bf7f4d792d2b9225f6f519a0da978e13c6f36989ef2123b12a96d6ad5a443a95d61022ffaa9fbce8f946da7b67f75d339'
 
 		passwd = 'passw0rd'
-		seed_hex = bip39().generate_seed(mnemonic.split(), passwd).hex()
+		seed_hex = bip39().generate_seed(mnemonic.split(), passwd=passwd).hex()
 		vmsg(f'  Password: {orange(passwd)}\n    {seed_hex}')
 		assert seed_hex == '7eb773bf60f1a5071f96736b6ddbe5c544a7b7740182a80493e29577e58b7cde011d4e38d26f65dab6c9fdebe5594e523447a1427ffd60746e6d04b4daa42eb1'
 

+ 1 - 1
test/modtest_d/ut_bip_hd.py

@@ -197,7 +197,7 @@ class unit_tests:
 
 		coin_type1 = purpose.derive_private()
 
-		coin_type2 = m.to_coin_type('btc', addr_type='bech32')
+		coin_type2 = m.to_coin_type(coin='btc', addr_type='bech32')
 		assert coin_type1.address == coin_type2.address
 		vmsg(f'  {coin_type1.address=}')
 

+ 1 - 1
test/modtest_d/ut_gen.py

@@ -62,7 +62,7 @@ def do_test(proto, wif, addr_chk, addr_type, internal_keccak):
 
 	for n, backend in enumerate(get_backends(at.pubkey_type)):
 
-		kg = KeyGenerator( cfg, proto, at.pubkey_type, n+1)
+		kg = KeyGenerator(cfg, proto, at.pubkey_type, silent=n+1)
 		qmsg(blue(f'  Testing backend {backend!r} for addr type {addr_type!r}{add_msg}'))
 
 		data = kg.gen_data(privkey)

+ 1 - 1
test/modtest_d/ut_misc.py

@@ -50,7 +50,7 @@ class unit_tests:
 		vmsg(brown('  vectors:'))
 		vmsg(fs.format('REL_NOW', 'SHOW_SECS', 'ELAPSED', 'OUTPUT'))
 		for (t, now, rel_now, show_secs, out_chk) in vectors:
-			out = format_elapsed_hr(t, now, rel_now=rel_now, show_secs=show_secs)
+			out = format_elapsed_hr(t, now=now, rel_now=rel_now, show_secs=show_secs)
 			assert out == out_chk, f'{out} != {out_chk}'
 			vmsg(fs.format(repr(rel_now), repr(show_secs), now-t, out))
 

+ 6 - 6
test/modtest_d/ut_seedsplit.py

@@ -66,14 +66,14 @@ class unit_test:
 
 				for a, b, c, d, e, f, h, i, p in test_data[id_str if id_str is not None else 'default']:
 					seed_bin = bytes.fromhex('deadbeef' * a)
-					seed = Seed(cfg, seed_bin)
+					seed = Seed(cfg, seed_bin=seed_bin)
 					assert seed.sid == b, seed.sid
 
 					for share_count, j, k, l, m in (
 							(2, c, c, d, i),
 							(5, e, f, h, p)):
 
-						shares = seed.split(share_count, id_str, master_idx)
+						shares = seed.split(share_count, id_str=id_str, master_idx=master_idx)
 						A = len(shares)
 						assert A == share_count, A
 
@@ -103,7 +103,7 @@ class unit_test:
 
 						if master_idx:
 							slist = [shares.get_share_by_idx(i+1, base_seed=True) for i in range(len(shares))]
-							A = Seed.join_shares(cfg, slist, master_idx, id_str).sid
+							A = Seed.join_shares(cfg, slist, master_idx=master_idx, id_str=id_str).sid
 							assert A == b, A
 
 				msg('OK')
@@ -112,7 +112,7 @@ class unit_test:
 			msg_r('Testing defaults and limits...')
 
 			seed_bin = bytes.fromhex('deadbeef' * 8)
-			seed = Seed(cfg, seed_bin)
+			seed = Seed(cfg, seed_bin=seed_bin)
 
 			shares = seed.split(SeedShareIdx.max_val)
 			s = shares.format()
@@ -136,7 +136,7 @@ class unit_test:
 			vmsg('')
 
 			seed_bin = bytes.fromhex(seed_hex)
-			seed = Seed(cfg, seed_bin)
+			seed = Seed(cfg, seed_bin=seed_bin)
 
 			SeedShareIdx.max_val = ss_count
 			shares = seed.split(ss_count, master_idx=master_idx)
@@ -159,7 +159,7 @@ class unit_test:
 			msg_r('Testing last share collisions with shortened Seed IDs')
 			vmsg('')
 			seed_bin = bytes.fromhex('2eadbeef'*8)
-			seed = Seed(cfg, seed_bin)
+			seed = Seed(cfg, seed_bin=seed_bin)
 			ssm_save = SeedShareIdx.max_val
 			ssm = SeedShareIdx.max_val = 2048
 			shares = SeedShareList(seed, count=ssm, id_str='foo', master_idx=1, debug_last_share=True)

+ 8 - 8
test/modtest_d/ut_subseed.py

@@ -25,7 +25,7 @@ class unit_test:
 				):
 
 				seed_bin = bytes.fromhex('deadbeef' * a)
-				seed = Seed(cfg, seed_bin)
+				seed = Seed(cfg, seed_bin=seed_bin)
 				assert seed.sid == b, seed.sid
 
 				subseed = seed.subseed('2s')
@@ -40,7 +40,7 @@ class unit_test:
 				assert subseed.idx == 10, subseed.idx
 				assert subseed.ss_idx == h, subseed.ss_idx
 
-				seed2 = Seed(cfg, seed_bin)
+				seed2 = Seed(cfg, seed_bin=seed_bin)
 				ss2_list = seed2.subseeds
 
 				seed2.subseeds._generate(1)
@@ -98,31 +98,31 @@ class unit_test:
 
 			seed_bin = bytes.fromhex('deadbeef' * 8)
 
-			seed = Seed(cfg, seed_bin, nSubseeds=11)
+			seed = Seed(cfg, seed_bin=seed_bin, nSubseeds=11)
 			seed.subseeds._generate()
 			ss = seed.subseeds
 			assert len(ss.data['long']) == len(ss.data['short']), len(ss.data['short'])
 			assert len(ss) == 11, len(ss)
 
-			seed = Seed(cfg, seed_bin)
+			seed = Seed(cfg, seed_bin=seed_bin)
 			seed.subseeds._generate()
 			ss = seed.subseeds
 			assert len(ss.data['long']) == len(ss.data['short']), len(ss.data['short'])
 			assert len(ss) == nSubseeds, len(ss)
 
-			seed = Seed(cfg, seed_bin)
+			seed = Seed(cfg, seed_bin=seed_bin)
 			seed.subseed_by_seed_id('EEEEEEEE')
 			ss = seed.subseeds
 			assert len(ss.data['long']) == len(ss.data['short']), len(ss.data['short'])
 			assert len(ss) == nSubseeds, len(ss)
 
-			seed = Seed(cfg, seed_bin)
+			seed = Seed(cfg, seed_bin=seed_bin)
 			subseed = seed.subseed_by_seed_id('803B165C')
 			assert len(ss.data['long']) == len(ss.data['short']), len(ss.data['short'])
 			assert subseed.sid == '803B165C', subseed.sid
 			assert subseed.idx == 3, subseed.idx
 
-			seed = Seed(cfg, seed_bin)
+			seed = Seed(cfg, seed_bin=seed_bin)
 			subseed = seed.subseed_by_seed_id('803B165C', last_idx=1)
 			assert len(ss.data['long']) == len(ss.data['short']), len(ss.data['short'])
 			assert subseed is None, subseed
@@ -169,7 +169,7 @@ class unit_test:
 			msg_r(f'Testing Seed ID collisions ({ss_count} subseed pairs)...')
 
 			seed_bin = bytes.fromhex('12abcdef' * 8) # 95B3D78D
-			seed = Seed(cfg, seed_bin)
+			seed = Seed(cfg, seed_bin=seed_bin)
 
 			seed.subseeds._generate(ss_count)
 			ss = seed.subseeds

+ 2 - 2
test/objattrtest_d/oat_btc_mainnet.py

@@ -93,8 +93,8 @@ tests = {
 		'data': (0b00001, bytes),
 		'sid':  (0b00001, SeedID),
 		},
-		[cfg, seed_bin],
-		{},
+		(cfg,),
+		{'seed_bin': seed_bin},
 	),
 	'SubSeed': atd({
 		'idx':    (0b00001, int),

+ 16 - 9
test/objtest_d/ot_btc_mainnet.py

@@ -80,11 +80,17 @@ tests = {
 	},
 	'AddrIdxList': {
 		'arg1': 'fmt_str',
-		'bad': ('x', '5,9,1-2-3', '8,-11', '66,3-2', '0-3'),
+		'bad': (
+			{'fmt_str': 'x'},
+			{'fmt_str': '5,9,1-2-3'},
+			{'fmt_str': '8,-11'},
+			{'fmt_str': '66,3-2'},
+			{'fmt_str': '0-3'},
+		),
 		'good': (
-			('3,2,2', (2, 3)),
-			('101,1,3,5,2-7,99', (1, 2, 3, 4, 5, 6, 7, 99, 101)),
-			({'idx_list': AddrIdxList('1-5')}, (1, 2, 3, 4, 5))
+			{'fmt_str': '3,2,2', 'ret': (2, 3)},
+			{'fmt_str': '101,1,3,5,2-7,99', 'ret': (1, 2, 3, 4, 5, 6, 7, 99, 101)},
+			{'idx_list': AddrIdxList(fmt_str='1-5'), 'ret': (1, 2, 3, 4, 5)},
 		)
 	},
 	'SubSeedIdxRange': {
@@ -143,13 +149,14 @@ tests = {
 			{'sid': 1},
 			{'sid': 'F00BAA123'},
 			{'sid': 'f00baa12'},
-			'я', r32, 'abc'
-			),
+			{'seed': r32},
+			{'sid': 'abc'},
+		),
 		'good': (
 			{'sid': 'F00BAA12'},
-			{'seed': Seed(cfg, r16),     'ret': SeedID(seed=Seed(cfg, r16))},
-			{'sid':  Seed(cfg, r16).sid, 'ret': SeedID(seed=Seed(cfg, r16))}
-			)
+			{'seed': Seed(cfg, seed_bin=r16),     'ret': SeedID(seed=Seed(cfg, seed_bin=r16))},
+			{'sid':  Seed(cfg, seed_bin=r16).sid, 'ret': SeedID(seed=Seed(cfg, seed_bin=r16))}
+		)
 	},
 	'SubSeedIdx': {
 		'arg1': 's',

+ 1 - 1
test/overlay/fakemods/mmgen/crypto.py

@@ -12,4 +12,4 @@ if overlay_fake_os.getenv('MMGEN_TEST_SUITE_DETERMINISTIC'):
 		len(overlay_fake_get_random_orig(self, length)))
 
 	Crypto.add_user_random = lambda self, rand_bytes, desc: overlay_fake_urandom(
-		len(overlay_fake_add_user_random_orig(self, rand_bytes, desc)))
+		len(overlay_fake_add_user_random_orig(self, rand_bytes, desc=desc)))

+ 10 - 0
test/overlay/fakemods/mmgen/tx/tx_proxy.py

@@ -0,0 +1,10 @@
+from .tx_proxy_orig import *
+
+class overlay_fake_EtherscanTxProxyClient:
+	proto  = 'http'
+	host   = 'localhost:28800'
+	verify = False
+
+EtherscanTxProxyClient.proto = overlay_fake_EtherscanTxProxyClient.proto
+EtherscanTxProxyClient.host = overlay_fake_EtherscanTxProxyClient.host
+EtherscanTxProxyClient.verify = overlay_fake_EtherscanTxProxyClient.verify

+ 34 - 0
test/ref/ethereum/etherscan-form.html

@@ -0,0 +1,34 @@
+<!doctype html>
+<html id="html" lang="en">
+<head><title>
+	Broadcast Raw Transaction | Etherscan
+</title>
+<meta charset="utf-8" />
+</head>
+<body>
+
+<form action="/search" method="GET">
+	<input type="hidden" value="" id="hdnSearchLabel" />
+</form>
+
+<form method="post" action="./pushTx" id="ctl00" class="js-validate">
+	<input type="hidden" name="__VIEWSTATE" id="__VIEWSTATE" value="foo" />
+	<input type="hidden" name="__VIEWSTATEGENERATOR" id="__VIEWSTATEGENERATOR" value="bar" />
+	<input type="hidden" name="__EVENTVALIDATION" id="__EVENTVALIDATION" value="baz" />
+<div>
+	<div>
+		<label for="signedTransactionHex">Enter signed transaction hex</label>
+		<div class="js-form-message">
+		<textarea name="ctl00$ContentPlaceHolder1$txtRawTx" rows="8" cols="20" maxlength="50000" id="ContentPlaceHolder1_txtRawTx" required="" placeholder="e.g. 0x.." data-bg-msg="Please enter signed transaction hex">
+</textarea>
+		</div>
+		<p>Tip: You can also broadcast programatically via our <a href="https://docs.etherscan.io/api-endpoints/geth-parity-proxy" target="_blank">[eth_sendRawTransaction]</a>. Accepts the paramater "hex" for prefilling the input box above (i.e <a href="/pushTx?hex=0x000000">Click here</a>)</p>
+	</div>
+
+	<div class="card-footer bg-light">
+	  <input type="submit" name="ctl00$ContentPlaceHolder1$btnSubmit" value="Send Transaction" id="ContentPlaceHolder1_btnSubmit" class="btn btn-primary" />
+	</div>
+ </div>
+</form>
+</body>
+</html>

+ 33 - 0
test/ref/ethereum/etherscan-result.html

@@ -0,0 +1,33 @@
+<!doctype html>
+<html id="html" lang="en">
+<head><title>
+	Broadcast Raw Transaction | Etherscan
+</title><meta charset="utf-8"/>
+</head>
+<body>
+<form action="/search" method="GET">
+	<input id="hdnIsTestNet" value="False" type="hidden" />
+</form>
+
+<form method="post" action="./pushTx" id="ctl00" class="js-validate">
+<input type="hidden" name="__VIEWSTATE" id="__VIEWSTATE" value="foo" />
+<input type="hidden" name="__VIEWSTATEGENERATOR" id="__VIEWSTATEGENERATOR" value="bar" />
+<input type="hidden" name="__EVENTVALIDATION" id="__EVENTVALIDATION" value="baz" />
+	<div>
+	<div>
+		<div role='alert'><button type='button'></button><strong>Success! </strong>{"jsonrpc":"2.0","id":1,"result":"0xbeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafe"}
+<span class='d-block'><i></i> Transaction Hash: <a href='/tx/0xbeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafe'><span class='text-primary'><a href='/tx/0xbeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafe'>0xbeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafebeadcafe</a></span></b></a></span></div>
+		<label for="signedTransactionHex">Enter signed transaction hex</label>
+		<div>
+		<textarea name="ctl00$ContentPlaceHolder1$txtRawTx" rows="8" cols="20" maxlength="50000" id="ContentPlaceHolder1_txtRawTx" required="" placeholder="e.g. 0x.." data-bg-msg="Please enter signed transaction hex">
+0xdeadbeef</textarea>
+		</div>
+		<p>Tip: You can also broadcast programatically via our <a href="https://docs.etherscan.io/api-endpoints/geth-parity-proxy" target="_blank">[eth_sendRawTransaction]</a>. Accepts the paramater "hex" for prefilling the input box above (i.e <a href="/pushTx?hex=0x000000">Click here</a>)</p>
+	</div>
+	<div>
+	  <input type="submit" name="ctl00$ContentPlaceHolder1$btnSubmit" value="Send Transaction" id="ContentPlaceHolder1_btnSubmit" />
+		</div>
+	 </div>
+  </form>
+</body>
+</html>

+ 16 - 2
test/test-release.d/cfg.sh

@@ -20,7 +20,7 @@ groups_desc="
 
 init_groups() {
 	dfl_tests='dep alt obj color daemon mod hash ref tool tool2 gen help autosign btc btc_tn btc_rt altref altgen bch bch_rt ltc ltc_rt eth etc xmr'
-	extra_tests='dep dev lint autosign_live ltc_tn bch_tn'
+	extra_tests='dep dev lint pylint autosign_live ltc_tn bch_tn'
 	noalt_tests='dep alt obj color daemon mod hash ref tool tool2 gen help autosign btc btc_tn btc_rt'
 	quick_tests='dep alt obj color daemon mod hash ref tool tool2 gen help autosign btc btc_rt altref altgen eth etc xmr'
 	qskip_tests='lint btc_tn bch bch_rt ltc ltc_rt'
@@ -72,7 +72,8 @@ init_tests() {
 	"
 
 	[ "$VERBOSE" ] || STDOUT_DEVNULL='> /dev/null'
-	d_lint="code errors with static code analyzer"
+	d_lint="code errors with Ruff static code analyzer"
+	e_lint="Error checking failed!"
 	t_lint="
 		b ruff check setup.py $STDOUT_DEVNULL
 		b ruff check mmgen $STDOUT_DEVNULL
@@ -80,6 +81,19 @@ init_tests() {
 		b ruff check examples $STDOUT_DEVNULL
 	"
 
+	PYLINT_OPTS='--errors-only --jobs=0'
+	d_pylint="code errors with Pylint static code analyzer"
+	e_pylint="Error checking failed!"
+	t_pylint="
+		b $pylint $PYLINT_OPTS mmgen
+		b $pylint $PYLINT_OPTS test
+		b $pylint $PYLINT_OPTS --disable=relative-beyond-top-level test/cmdtest_d
+		a $pylint $PYLINT_OPTS --ignore-paths '.*/eth/.*' mmgen
+		a $pylint $PYLINT_OPTS --ignore-paths '.*/ut_dep.py,.*/ut_testdep.py' test
+		a $pylint $PYLINT_OPTS --ignore-paths '.*/ct_ethdev.py' --disable=relative-beyond-top-level test/cmdtest_d
+		- $pylint $PYLINT_OPTS examples
+	"
+
 	d_daemon="low-level subsystems involving coin daemons"
 	t_daemon="- $daemontest_py --exclude exec"
 

+ 6 - 3
test/test-release.sh

@@ -18,8 +18,7 @@
 
 run_test() {
 	set +x
-	tests="t_$1"
-	skips="t_$1_skip"
+	local tests="t_$1" skips="t_$1_skip" continue_on_error="e_$1" have_error=
 
 	while read skip test; do
 		[ "$test" ] || continue
@@ -35,11 +34,13 @@ run_test() {
 				echo -e "${GREEN}Running:$RESET $test_disp"
 				eval "$test" || {
 					echo -e $RED"test-release.sh: test '$CUR_TEST' failed at command '$test'"$RESET
-					exit 1
+					have_error=1
+					[ "${!continue_on_error}" ] || exit 1
 				}
 			fi
 		fi
 	done <<<${!tests}
+	if [ "$have_error" ]; then { echo -e "$RED${!continue_on_error}$RESET"; exit 1; }; fi
 }
 
 prompt_skip() {
@@ -276,6 +277,7 @@ gentest_py='test/gentest.py --quiet'
 scrambletest_py='test/scrambletest.py'
 altcoin_mod_opts='--quiet'
 mmgen_tool='cmds/mmgen-tool'
+pylint='PYTHONPATH=. pylint' # PYTHONPATH required by older Pythons (e.g. v3.9)
 python='python3'
 rounds=10
 typescript_file='test-release.out'
@@ -370,6 +372,7 @@ do
 		tooltest_py+=" --verbose"
 		mmgen_tool+=" --verbose"
 		objattrtest_py+=" --verbose"
+		pylint+=" --verbose"
 		scrambletest_py+=" --verbose" ;;
 	X)  IN_REEXEC=1 ;;
 	*)  exit ;;