Browse Source

B2X support, locktime-based coin splitting utility
- full support for B2X fork
- `mmgen-split` coin splitting utility for replayable forks
- `mmgen-regtest fork` command for testing forking scenarios
- `regtest_split` command added to `test/test.py` test suite
- timelock support for `txcreate` and `txdo`
- nlocktime and nsequence checks after signing and before sending

philemon 7 years ago
parent
commit
420d0e9699

+ 1 - 0
cmds/mmgen-autosign

@@ -312,6 +312,7 @@ def set_led(cmd):
 	led_thread.start()
 
 def get_insert_status():
+	if os.getenv('MMGEN_TEST_SUITE'): return True
 	try: os.stat(os.path.join('/dev/disk/by-label/',part_label))
 	except: return False
 	else: return True

+ 24 - 0
cmds/mmgen-split

@@ -0,0 +1,24 @@
+#!/usr/bin/env python
+
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
+# Copyright (C)2013-2017 Philemon <mmgen-py@yandex.com>
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""
+mmgen-split: Split funds after a fork using a timelocked transaction
+"""
+
+from mmgen.main import launch
+launch("split")

+ 22 - 13
mmgen/main_regtest.py

@@ -40,32 +40,41 @@ opts_data = lambda: {
 
                          AVAILABLE COMMANDS
 
-  setup          - set up system for regtest operation with MMGen
-  stop           - stop the regtest coin daemon
-  bob            - switch to Bob's wallet, starting daemon if necessary
-  alice          - switch to Alice's wallet, starting daemon if necessary
-  user           - show current user
-  generate       - mine a block
-  send ADDR AMT  - send amount AMT to address ADDR
-  test_daemon    - test whether daemon is running
-  get_balances   - get balances of Bob and Alice
-  show_mempool   - show transaction IDs in mempool
+  setup           - set up system for regtest operation with MMGen
+  fork COIN       - create a fork of coin COIN
+  stop            - stop the regtest coin daemon
+  bob             - switch to Bob's wallet, starting daemon if necessary
+  alice           - switch to Alice's wallet, starting daemon if necessary
+  user            - show current user
+  generate N      - mine n blocks (defaults to 1)
+  send ADDR AMT   - send amount AMT of miner funds to address ADDR
+  test_daemon     - test whether daemon is running
+  get_balances    - get balances of Bob and Alice
+  show_mempool    - show transaction IDs in mempool
+  cli [arguments] - execute an RPC call with arguments
 	"""
 }
 
 cmd_args = opts.init(opts_data)
 
 cmds = ('setup','stop','generate','test_daemon','create_data_dir','bob','alice','miner','user','send',
-		'wait_for_daemon','wait_for_exit','get_current_user','get_balances','show_mempool')
+		'wait_for_daemon','wait_for_exit','get_current_user','get_balances','show_mempool','cli','fork')
 
 try:
 	if cmd_args[0] == 'send':
 		assert len(cmd_args) == 3
+	elif cmd_args[0] == 'fork':
+		assert len(cmd_args) == 2
+	elif cmd_args[0] == 'generate':
+		assert len(cmd_args) in (1,2)
+		if len(cmd_args) == 2:
+			cmd_args[1] = int(cmd_args[1])
+	elif cmd_args[0] == 'cli':
+		pass
 	else:
 		assert cmd_args[0] in cmds and len(cmd_args) == 1
 except:
 	opts.usage()
 else:
-	args = cmd_args[1:]
 	from mmgen.regtest import *
-	globals()[cmd_args[0]](*args)
+	globals()[cmd_args[0]](*cmd_args[1:])

+ 139 - 0
mmgen/main_split.py

@@ -0,0 +1,139 @@
+#!/usr/bin/env python
+#
+# mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
+# Copyright (C)2013-2017 Philemon <mmgen-py@yandex.com>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# TODO: check that balances of output addrs are zero?
+
+"""
+mmgen-split: Split funds after a replayable chain fork using a timelocked transaction
+"""
+
+import time
+
+from mmgen.common import *
+
+opts_data = lambda: {
+	'desc': """Split funds in a {pnm} wallet after a chain fork using a
+                timelocked transaction""".format(pnm=g.proj_name),
+	'usage':'[opts] [output addr1] [output addr2]',
+	'options': """
+-h, --help           Print this help message
+--, --longhelp       Print help message for long options (common options)
+-f, --tx-fees=     f The transaction fees for each chain (comma-separated)
+-c, --other-coin=  c The coin symbol of the other chain (default: {oc})
+-B, --no-blank       Don't blank screen before displaying unspent outputs
+-d, --outdir=      d Specify an alternate directory 'd' for output
+-m, --minconf=     n Minimum number of confirmations required to spend
+                     outputs (default: 1)
+-q, --quiet          Suppress warnings; overwrite files without prompting
+-r, --rbf            Make transaction BIP 125 replaceable (replace-by-fee)
+-v, --verbose        Produce more verbose output
+-y, --yes            Answer 'yes' to prompts, suppress non-essential output
+-R, --rpc-host2=   h Host the other coin daemon is running on (default: none)
+-L, --locktime=    t Lock time (block height or unix seconds)
+                     (default: {bh})
+""".format(oc=g.proto.forks[-1][2].upper(),bh='current block height'),
+	'notes': """\n
+This command creates two transactions: one (with the timelock) to be broadcast
+on the long chain and one on the short chain after a replayable chain fork.
+Only {pnm} addresses may be spent to.
+
+The command must be run on the longest chain.  The user is reponsible for
+ensuring that the current chain is the longest.  The other chain is specified
+on the command line, or it defaults to the most recent replayable fork of the
+current chain.
+
+For the split to have a reasonable chance of succeeding, the long chain should
+be well ahead of the short one (by more than 20 blocks or so) and transactions
+should have a good chance of confirming quickly on both chains.  For this
+larger than normal fees may be required.  Fees may be specified on the command
+line, or network fee estimation may be used.
+
+If the split fails (i.e. the long-chain TX is broadcast and confirmed on the
+short chain), no funds are lost.  A new split attempt can be made with the
+long-chain transaction's output as an input for the new split transaction.
+This process can be repeated as necessary until the split succeeds.
+
+IMPORTANT: Timelock replay protection offers NO PROTECTION against reorg
+attacks on the majority chain or reorg attacks on the minority chain if the
+minority chain is ahead of the timelock.  If the reorg'd minority chain is
+behind the timelock, protection is contingent on getting the non-timelocked
+transaction reconfirmed before the timelock expires. Use at your own risk.
+""".format(pnm=g.proj_name)
+}
+
+cmd_args = opts.init(opts_data,add_opts=['tx_fee','tx_fee_adj','comment_file'])
+
+opt.other_coin = opt.other_coin.upper() if opt.other_coin else g.proto.forks[-1][2].upper()
+if opt.other_coin.lower() not in [e[2] for e in g.proto.forks if e[3] == True]:
+	die(1,"'{}': not a replayable fork of {} chain".format(opt.other_coin,g.coin))
+
+if len(cmd_args) != 2:
+	fs = 'This command requires exactly two {} addresses as arguments'
+	die(1,fs.format(g.proj_name))
+
+from mmgen.obj import MMGenID
+try:
+	mmids = [MMGenID(a,on_fail='die') for a in cmd_args]
+except:
+	die(1,'Command line arguments must be valid MMGen IDs')
+
+if mmids[0] == mmids[1]:
+	die(2,'Both transactions have the same output! ({})'.format(mmids[0]))
+
+from mmgen.tx import MMGenSplitTX
+from mmgen.protocol import CoinProtocol
+
+if opt.tx_fees:
+	g_coin_save = g.coin
+	for idx,g_coin in ((1,opt.other_coin),(0,g_coin_save)):
+		g.coin = g_coin
+		g.proto = CoinProtocol(g.coin,g.testnet)
+		opt.tx_fee = opt.tx_fees.split(',')[idx]
+		opts.opt_is_tx_fee(opt.tx_fee,'transaction fee') or sys.exit(1)
+
+rpc_init(reinit=True)
+
+tx1 = MMGenSplitTX()
+opt.no_blank = True
+
+gmsg("Creating timelocked transaction for long chain ({})".format(g.coin))
+locktime = int(opt.locktime or 0) or g.rpch.getblockcount()
+tx1.create(mmids[0],locktime)
+
+tx1.format()
+tx1.create_fn()
+
+gmsg("\nCreating transaction for short chain ({})".format(opt.other_coin))
+
+if opt.rpc_host2: g.rpc_host = opt.rpc_host2
+if opt.tx_fees:   opt.tx_fee = opt.tx_fees.split(',')[1]
+
+from mmgen.protocol import CoinProtocol
+g.coin = opt.other_coin
+g.proto = CoinProtocol(g.coin,g.testnet)
+reload(sys.modules['mmgen.tx'])
+
+tx2 = MMGenSplitTX()
+tx2.inputs = tx1.inputs
+tx2.inputs.convert_coin()
+
+tx2.create_split(mmids[1])
+
+for tx,desc in ((tx1,'Long chain (timelocked)'),(tx2,'Short chain')):
+	tx.desc = desc + ' transaction'
+	tx.write_to_file(ask_write=False,ask_overwrite=not opt.yes,ask_write_default_yes=False)

+ 2 - 2
mmgen/main_txbump.py

@@ -133,7 +133,7 @@ if not silent:
 if seed_files or kl or kal:
 	txsign(tx,seed_files,kl,kal)
 	tx.write_to_file(ask_write=False)
-	if tx.send():
-		tx.write_to_file(ask_write=False)
+	tx.send(exit_on_fail=True)
+	tx.write_to_file(ask_write=False)
 else:
 	tx.write_to_file(ask_write=not opt.yes,ask_write_default_yes=False,ask_overwrite=not opt.yes)

+ 2 - 1
mmgen/main_txcreate.py

@@ -39,6 +39,7 @@ opts_data = lambda: {
                      per byte (an integer followed by 's').  If omitted, fee
                      will be calculated using {dn}'s 'estimatefee' call
 -i, --info           Display unspent outputs and exit
+-L, --locktime=    t Lock time (block height or unix seconds) (default: 0)
 -m, --minconf=     n Minimum number of confirmations required to spend
                      outputs (default: 1)
 -q, --quiet          Suppress warnings; overwrite files without prompting
@@ -55,5 +56,5 @@ rpc_init()
 
 from mmgen.tx import MMGenTX
 tx = MMGenTX()
-tx.create(cmd_args,do_info=opt.info)
+tx.create(cmd_args,int(opt.locktime or 0),do_info=opt.info)
 tx.write_to_file(ask_write=not opt.yes,ask_overwrite=not opt.yes,ask_write_default_yes=False)

+ 4 - 3
mmgen/main_txdo.py

@@ -52,6 +52,7 @@ opts_data = lambda: {
 -K, --key-generator= m Use method 'm' for public key generation
                        Options: {kgs}
                        (default: {kg})
+-L, --locktime=      t Lock time (block height or unix seconds) (default: 0)
 -m, --minconf=n        Minimum number of confirmations required to spend
                        outputs (default: 1)
 -M, --mmgen-keys-from-file=f Provide keys for {pnm} addresses in a key-
@@ -89,9 +90,9 @@ kl = get_keylist(opt)
 if kl and kal: kl.remove_dup_keys(kal)
 
 tx = MMGenTX(caller='txdo')
-tx.create(cmd_args)
+tx.create(cmd_args,int(opt.locktime or 0))
 txsign(tx,seed_files,kl,kal)
 tx.write_to_file(ask_write=False)
 
-if tx.send():
-	tx.write_to_file(ask_overwrite=False,ask_write=False)
+tx.send(exit_on_fail=True)
+tx.write_to_file(ask_overwrite=False,ask_write=False)

+ 2 - 2
mmgen/main_txsend.py

@@ -64,5 +64,5 @@ if not opt.yes:
 	if tx.add_comment(): # edits an existing comment, returns true if changed
 		tx.write_to_file(ask_write_default_yes=True)
 
-if tx.send():
-	tx.write_to_file(ask_overwrite=False,ask_write=False)
+tx.send(exit_on_fail=True)
+tx.write_to_file(ask_overwrite=False,ask_write=False)

+ 17 - 15
mmgen/opts.py

@@ -253,7 +253,6 @@ def init(opts_f,add_opts=[],opt_filter=None):
 	setattr(opt,'set_by_user',[])
 	for k in g.global_sets_opt:
 		if k in opt.__dict__ and getattr(opt,k) != None:
-#			_typeconvert_from_dfl(k)
 			setattr(opt,k,set_for_type(getattr(opt,k),getattr(g,k),'--'+k))
 			opt.set_by_user.append(k)
 		else:
@@ -277,7 +276,7 @@ def init(opts_f,add_opts=[],opt_filter=None):
 	if g.bob or g.alice:
 		g.testnet = True
 		g.proto = CoinProtocol(g.coin,g.testnet)
-		g.data_dir = os.path.join(g.data_dir_root,'regtest',('alice','bob')[g.bob])
+		g.data_dir = os.path.join(g.data_dir_root,'regtest',g.coin.lower(),('alice','bob')[g.bob])
 		check_or_create_dir(g.data_dir)
 		import regtest as rt
 		g.rpc_host = 'localhost'
@@ -303,6 +302,19 @@ def init(opts_f,add_opts=[],opt_filter=None):
 
 	return args
 
+def opt_is_tx_fee(val,desc):
+	from mmgen.tx import MMGenTX
+	ret = MMGenTX().convert_fee_spec(val,224,on_fail='return')
+	if ret == False:
+		msg("'{}': invalid {} (not a {} amount or satoshis-per-byte specification)".format(
+				val,desc,g.coin.upper()))
+	elif ret != None and ret > g.proto.max_tx_fee:
+		msg("'{}': invalid {} (> max_tx_fee ({} {}))".format(
+				val,desc,g.proto.max_tx_fee,g.coin.upper()))
+	else:
+		return True
+	return False
+
 def check_opts(usr_opts):       # Returns false if any check fails
 
 	def opt_splits(val,sep,n,desc):
@@ -332,19 +344,6 @@ def check_opts(usr_opts):       # Returns false if any check fails
 			return False
 		return True
 
-	def opt_is_tx_fee(val,desc):
-		from mmgen.tx import MMGenTX
-		ret = MMGenTX().convert_fee_spec(val,224,on_fail='return')
-		if ret == False:
-			msg("'{}': invalid {} (not a {} amount or satoshis-per-byte specification)".format(
-					val,desc,g.coin.upper()))
-		elif ret != None and ret > g.proto.max_tx_fee:
-			msg("'{}': invalid {} (> max_tx_fee ({} {}))".format(
-					val,desc,g.proto.max_tx_fee,g.coin.upper()))
-		else:
-			return True
-		return False
-
 	def opt_is_in_list(val,lst,desc):
 		if val not in lst:
 			q,sep = (('',','),("'","','"))[type(lst[0]) == str]
@@ -461,6 +460,9 @@ def check_opts(usr_opts):       # Returns false if any check fails
 			m = "Regtest (Bob and Alice) mode not set up yet.  Run '{}-regtest setup' to initialize."
 			try: os.stat(daemon_dir)
 			except: die(1,m.format(g.proj_name.lower()))
+		elif key == 'locktime':
+			if not opt_is_int(val,desc): return False
+			if not opt_compares(val,'>',0,desc): return False
 		else:
 			if g.debug: Msg("check_opts(): No test for opt '%s'" % key)
 

+ 24 - 5
mmgen/protocol.py

@@ -23,7 +23,7 @@ protocol.py: Coin protocol functions, classes and methods
 import os,hashlib
 from binascii import unhexlify
 from mmgen.util import msg,pmsg
-from mmgen.obj import MMGenObject,BTCAmt,LTCAmt,BCHAmt
+from mmgen.obj import MMGenObject,BTCAmt,LTCAmt,BCHAmt,B2XAmt
 from mmgen.globalvars import g
 
 def hash160(hexnum): # take hex, return hex - OP_HASH160
@@ -69,9 +69,9 @@ class BitcoinProtocol(MMGenObject):
 	daemon_data_subdir = ''
 	sighash_type = 'ALL'
 	block0 = '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f'
-	forks = [
-		(478559,'00000000000000000019f112ec0a9982926f1258cdcc558dd7c3b7e5dc7fa148','bch'),
-		(None,'','b2x')
+	forks = [ # height, hash, name, replayable
+		(478559,'00000000000000000019f112ec0a9982926f1258cdcc558dd7c3b7e5dc7fa148','bch',False),
+		(None,'','b2x',True)
 	]
 	caps = ('rbf','segwit')
 	base_coin = 'BTC'
@@ -160,7 +160,7 @@ class BitcoinCashProtocol(BitcoinProtocol):
 	mmtypes        = ('L','C')
 	sighash_type   = 'ALL|FORKID'
 	forks = [
-		(478559,'000000000000000000651ef99cb9fcbe0dadde1d424bd9f15ff20136191a5eec','btc')
+		(478559,'000000000000000000651ef99cb9fcbe0dadde1d424bd9f15ff20136191a5eec','btc',False)
 	]
 	caps = ()
 	coin_amt        = BCHAmt
@@ -178,6 +178,24 @@ class BitcoinCashTestnetProtocol(BitcoinCashProtocol):
 	data_subdir   = 'testnet'
 	daemon_data_subdir = 'testnet3'
 
+class B2XProtocol(BitcoinProtocol):
+	daemon_name    = 'bitcoind-2x'
+	daemon_data_dir = os.path.join(os.getenv('APPDATA'),'Bitcoin_2X') if g.platform == 'win' \
+						else os.path.join(g.home_dir,'.bitcoin-2x')
+	rpc_port        = 8338
+	coin_amt        = B2XAmt
+	max_tx_fee      = B2XAmt('0.1')
+	forks = [
+		(None,'','btc',True)
+	]
+
+class B2XTestnetProtocol(B2XProtocol):
+	addr_ver_num         = { 'p2pkh': ('6f','mn'), 'p2sh':  ('c4','2') }
+	privkey_pfx          = 'ef'
+	data_subdir          = 'testnet'
+	daemon_data_subdir   = 'testnet5'
+	rpc_port             = 18338
+
 class LitecoinProtocol(BitcoinProtocol):
 	block0         = '12a765e31ffd4059bada1e25190f6e98c99d9714d334efa41a195a7e7e04bfe2'
 	name           = 'litecoin'
@@ -210,6 +228,7 @@ class CoinProtocol(MMGenObject):
 	coins = {
 		'btc': (BitcoinProtocol,BitcoinTestnetProtocol),
 		'bch': (BitcoinCashProtocol,BitcoinCashTestnetProtocol),
+		'b2x': (B2XProtocol,B2XTestnetProtocol),
 		'ltc': (LitecoinProtocol,LitecoinTestnetProtocol),
 #		'eth': (EthereumProtocol,EthereumTestnetProtocol),
 	}

+ 67 - 20
mmgen/regtest.py

@@ -24,38 +24,40 @@ import os,subprocess,time,shutil
 from mmgen.common import *
 PIPE = subprocess.PIPE
 
-data_dir     = os.path.join(g.data_dir_root,'regtest')
+data_dir     = os.path.join(g.data_dir_root,'regtest',g.coin.lower())
 daemon_dir   = os.path.join(data_dir,'regtest')
-rpc_port     = 8552
+rpc_ports    = { 'btc':8552, 'bch':8553, 'b2x':8554, 'ltc':8555 }
+rpc_port     = rpc_ports[g.coin.lower()]
 rpc_user     = 'bobandalice'
 rpc_password = 'hodltothemoon'
-init_amt     = 500
 
 tr_wallet = lambda user: os.path.join(daemon_dir,'wallet.dat.'+user)
 
-common_args = (
+common_args = lambda: (
 	'-rpcuser={}'.format(rpc_user),
 	'-rpcpassword={}'.format(rpc_password),
 	'-rpcport={}'.format(rpc_port),
 	'-regtest',
 	'-datadir={}'.format(data_dir))
 
-def start_daemon(user,quiet=False,daemon=True):
+def start_daemon(user,quiet=False,daemon=True,reindex=False):
 	cmd = (
 		g.proto.daemon_name,
+		'-listen=0',
 		'-keypool=1',
 		'-wallet={}'.format(os.path.basename(tr_wallet(user)))
-	) + common_args
+	) + common_args()
 	if daemon: cmd += ('-daemon',)
+	if reindex: cmd += ('-reindex',)
 	if not g.debug or quiet: vmsg('{}'.format(' '.join(cmd)))
 	p = subprocess.Popen(cmd,stdout=PIPE,stderr=PIPE)
 	err = process_output(p,silent=False)[1]
 	if err:
 		rdie(1,'Error starting the {} daemon:\n{}'.format(g.proto.name.capitalize(),err))
 
-def start_daemon_mswin(user,quiet=False):
+def start_daemon_mswin(user,quiet=False,reindex=False):
 	import threading
-	t = threading.Thread(target=start_daemon,args=[user,quiet,False])
+	t = threading.Thread(target=start_daemon,args=[user,quiet,False,reindex])
 	t.daemon = True
 	t.start()
 	if not opt.verbose: Msg_r(' \b') # blocks w/o this...crazy
@@ -63,7 +65,7 @@ def start_daemon_mswin(user,quiet=False):
 def start_cmd(*args,**kwargs):
 	cmd = args
 	if args[0] == 'cli':
-		cmd = (g.proto.name+'-cli',) + common_args + args[1:]
+		cmd = (g.proto.name+'-cli',) + common_args() + args[1:]
 	if g.debug or not 'quiet' in kwargs:
 		vmsg('{}'.format(' '.join(cmd)))
 	ip = op = ep = (PIPE,None)['no_pipe' in kwargs and kwargs['no_pipe']]
@@ -113,15 +115,16 @@ def get_balances():
 	msg('{:<16} {:12}'.format('Total balance:',tbal))
 
 def create_data_dir():
-	try: os.stat(daemon_dir)
+	try: os.stat(os.path.join(data_dir,'regtest')) # don't use daemon_dir, as data_dir may change
 	except: pass
 	else:
-		if keypress_confirm('Delete your existing MMGen regtest setup and create a new one?'):
+		m = "Delete your existing MMGen regtest setup at '{}' and create a new one?"
+		if keypress_confirm(m.format(data_dir)):
 			shutil.rmtree(data_dir)
 		else:
 			die()
 
-	try: os.mkdir(data_dir)
+	try: os.makedirs(data_dir)
 	except: pass
 
 def process_output(p,silent=False):
@@ -133,9 +136,9 @@ def process_output(p,silent=False):
 		vmsg('stderr: [{}]'.format(err.strip()))
 	return out,err
 
-def start_and_wait(user,silent=False,nonl=False):
+def start_and_wait(user,silent=False,nonl=False,reindex=False):
 	vmsg('Starting {} regtest daemon'.format(g.proto.name))
-	(start_daemon_mswin,start_daemon)[g.platform=='linux'](user)
+	(start_daemon_mswin,start_daemon)[g.platform=='linux'](user,reindex=reindex)
 	wait_for_daemon('ready',silent=silent,nonl=nonl)
 
 def stop_and_wait(silent=False,nonl=False,stop_silent=False,ignore_noconnect_error=False):
@@ -156,9 +159,53 @@ def show_mempool():
 	msg(pformat(eval(p.stdout.read())))
 	p.wait()
 
+def cli(*args):
+	p = start_cmd(*(('cli',) + args))
+	from pprint import pformat
+	Msg_r(p.stdout.read())
+	msg_r(p.stderr.read())
+	p.wait()
+
+def fork(coin):
+	coin = coin.upper()
+	from mmgen.protocol import CoinProtocol
+	forks = CoinProtocol(coin,False).forks
+	if not [f for f in forks if f[2] == g.coin.lower() and f[3] == True]:
+		die(1,"Coin {} is not a replayable fork of coin {}".format(g.coin,coin))
+
+	gmsg('Creating fork from coin {} to coin {}'.format(coin,g.coin))
+	source_data_dir = os.path.join(g.data_dir_root,'regtest',coin.lower())
+
+	try: os.stat(source_data_dir)
+	except: die(1,"Source directory '{}' does not exist!".format(source_data_dir))
+
+	# stop the other daemon
+	global rpc_port,data_dir
+	rpc_port_save,data_dir_save = rpc_port,data_dir
+	rpc_port = rpc_ports[coin.lower()]
+	data_dir = os.path.join(g.data_dir_root,'regtest',coin.lower())
+	if test_daemon() != 'stopped':
+		stop_and_wait(silent=True,stop_silent=True)
+	rpc_port,data_dir = rpc_port_save,data_dir_save
+
+	try: os.makedirs(data_dir)
+	except: pass
+
+	# stop our daemon
+	if test_daemon() != 'stopped':
+		stop_and_wait(silent=True,stop_silent=True)
+
+	create_data_dir()
+	os.rmdir(data_dir)
+	shutil.copytree(source_data_dir,data_dir,symlinks=True)
+	start_and_wait('miner',reindex=True,silent=True)
+	stop_and_wait(silent=True,stop_silent=True)
+	gmsg('Fork {} successfully created'.format(g.coin))
+
 def setup():
-	try: os.mkdir(data_dir)
+	try: os.makedirs(data_dir)
 	except: pass
+
 	if test_daemon() != 'stopped':
 		stop_and_wait(silent=True,stop_silent=True)
 	create_data_dir()
@@ -192,7 +239,7 @@ def get_current_user_win(quiet=False):
 	return None
 
 def get_current_user_unix(quiet=False):
-	p = start_cmd('pgrep','-af','{}.*-rpcuser={}.*'.format(g.proto.daemon_name,rpc_user))
+	p = start_cmd('pgrep','-af','{}.*-rpcport={}.*'.format(g.proto.daemon_name,rpc_port))
 	cmdline = p.stdout.read()
 	if not cmdline: return None
 	for k in ('miner','bob','alice'):
@@ -214,20 +261,20 @@ def user(user=None,quiet=False):
 		wait_for_daemon('ready')
 	if test_daemon() == 'ready':
 		if user == get_current_user(quiet=True):
-			if not quiet: msg('{} is already the current user'.format(user.capitalize()))
+			if not quiet: msg('{} is already the current user for coin {}'.format(user.capitalize(),g.coin))
 			return True
-		gmsg_r('Switching to user {}'.format(user.capitalize()))
+		gmsg_r('Switching to user {} for coin {}'.format(user.capitalize(),g.coin))
 		stop_and_wait(silent=False,nonl=True,stop_silent=True)
 		time.sleep(0.1) # file lock has race condition - TODO: test for lock file
 		start_and_wait(user,nonl=True)
 	else:
-		gmsg_r('Starting regtest daemon with current user {}'.format(user.capitalize()))
+		gmsg_r('Starting regtest daemon for coin {} with current user {}'.format(g.coin,user.capitalize()))
 		start_and_wait(user,nonl=True)
 	gmsg('done')
 
 def stop(silent=False,ignore_noconnect_error=True):
 	if test_daemon() != 'stopped' and not silent:
-		gmsg('Stopping {} regtest daemon'.format(g.proto.name))
+		gmsg('Stopping {} regtest daemon for coin {}'.format(g.proto.name,g.coin))
 	p = start_cmd('cli','stop')
 	err = process_output(p)[1]
 	if err:

+ 1 - 1
mmgen/rpc.py

@@ -35,7 +35,7 @@ class CoinDaemonRPCConnection(object):
 
 		import socket
 		try:
-			socket.create_connection((host,port)).close()
+			socket.create_connection((host,port),timeout=3).close()
 		except:
 			die(1,'Unable to connect to {}:{}'.format(host,port))
 

+ 183 - 48
mmgen/tx.py

@@ -60,6 +60,25 @@ only one output, specify a single output address with no {} amount
 """.strip().format(g.coin),
 }
 
+def strfmt_locktime(num,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
+	# added to any block which has this height or higher.
+	# MMGen note: s/this height or higher/a higher block height/
+	#
+	# If greater than or equal to 500 million, locktime is parsed using the Unix epoch time
+	# format (the number of seconds elapsed since 1970-01-01T00:00 UTC). The transaction can be
+	# added to any block whose block time is greater than the locktime.
+	if num >= 5 * 10**6:
+		return ' '.join(time.strftime('%c',time.gmtime(num)).split()[1:])
+	elif num > 0:
+		return '{}{}'.format(('block height ','')[terse],num)
+	elif num == None:
+		return '(None)'
+	else:
+		die(2,"'{}': invalid locktime value!".format(num))
+
 def select_unspent(unspent,prompt):
 	while True:
 		reply = my_raw_input(prompt).strip()
@@ -258,10 +277,12 @@ class MMGenTX(MMGenObject):
 		self.timestamp   = ''
 		self.chksum      = ''
 		self.fmt_data    = ''
+		self.fn          = ''
 		self.blockcount  = 0
 		self.chain       = None
 		self.coin        = None
 		self.caller      = caller
+		self.locktime    = None
 
 		if filename:
 			self.parse_tx_file(filename,md_only=md_only)
@@ -276,7 +297,7 @@ class MMGenTX(MMGenObject):
 			die(2,'Transaction is for {}, but current chain is {}!'.format(self.chain,g.chain))
 
 	def add_output(self,coinaddr,amt,is_chg=None):
-		self.outputs.append(self.MMGenTxOutput(addr=coinaddr,amt=amt,is_chg=is_chg))
+		self.outputs.append(MMGenTX.MMGenTxOutput(addr=coinaddr,amt=amt,is_chg=is_chg))
 
 	def get_chg_output_idx(self):
 		for i in range(len(self.outputs)):
@@ -287,7 +308,7 @@ class MMGenTX(MMGenObject):
 	def update_output_amt(self,idx,amt):
 		o = self.outputs[idx].__dict__
 		o['amt'] = amt
-		self.outputs[idx] = self.MMGenTxOutput(**o)
+		self.outputs[idx] = MMGenTX.MMGenTxOutput(**o)
 
 	def del_output(self,idx):
 		self.outputs.pop(idx)
@@ -300,7 +321,8 @@ class MMGenTX(MMGenObject):
 	def add_mmaddrs_to_outputs(self,ad_w,ad_f):
 		a = [e.addr for e in self.outputs]
 		d = ad_w.make_reverse_dict(a)
-		d.update(ad_f.make_reverse_dict(a))
+		if ad_f:
+			d.update(ad_f.make_reverse_dict(a))
 		for e in self.outputs:
 			if e.addr and e.addr in d:
 				e.mmid,f = d[e.addr]
@@ -316,13 +338,16 @@ class MMGenTX(MMGenObject):
 					die(2,'{}: duplicate address in transaction {}'.format(attr,io_str))
 				old_attr = attr
 
+	def update_txid(self):
+		self.txid = MMGenTxID(make_chksum_6(unhexlify(self.hex)).upper())
+
 	def create_raw(self):
 		i = [{'txid':e.txid,'vout':e.vout} for e in self.inputs]
 		if self.inputs[0].sequence:
 			i[0]['sequence'] = self.inputs[0].sequence
 		o = dict([(e.addr,e.amt) for e in self.outputs])
 		self.hex = g.rpch.createrawtransaction(i,o)
-		self.txid = MMGenTxID(make_chksum_6(unhexlify(self.hex)).upper())
+		self.update_txid()
 
 	# returns true if comment added or changed
 	def add_comment(self,infile=None):
@@ -484,8 +509,8 @@ class MMGenTX(MMGenObject):
 
 	def decode_io(self,desc,data):
 		io,il = (
-			(self.MMGenTxOutput,self.MMGenTxOutputList),
-			(self.MMGenTxInput,self.MMGenTxInputList)
+			(MMGenTX.MMGenTxOutput,MMGenTX.MMGenTxOutputList),
+			(MMGenTX.MMGenTxInput,MMGenTX.MMGenTxInputList)
 		)[desc=='inputs']
 		return il([io(**dict([(k,d[k]) for k in io.__dict__
 					if k in d and d[k] not in ('',None)])) for d in data])
@@ -519,6 +544,13 @@ class MMGenTX(MMGenObject):
 	def add_timestamp(self):
 		self.timestamp = make_timestamp()
 
+	def get_hex_locktime(self):
+		return int(hexlify(unhexlify(self.hex[-8:])[::-1]),16)
+
+	def set_hex_locktime(self,val):
+		assert type(val) == int,'locktime value not an integer'
+		self.hex = self.hex[:-8] + hexlify(unhexlify('{:08x}'.format(val))[::-1])
+
 	def add_blockcount(self):
 		self.blockcount = int(g.rpch.getblockcount())
 
@@ -526,13 +558,14 @@ class MMGenTX(MMGenObject):
 		self.inputs.check_coin_mismatch()
 		self.outputs.check_coin_mismatch()
 		lines = [
-			'{}{} {} {} {} {}'.format(
+			'{}{} {} {} {} {}{}'.format(
 				(g.coin+' ','')[g.coin=='BTC'],
 				self.chain.upper() if self.chain else 'Unknown',
 				self.txid,
 				self.send_amt,
 				self.timestamp,
-				self.blockcount
+				self.blockcount,
+				('',' LT={}'.format(self.locktime))[bool(self.locktime)]
 			),
 			self.hex,
 			repr([e.__dict__ for e in self.inputs]),
@@ -624,20 +657,25 @@ class MMGenTX(MMGenObject):
 		ret = self.desc == 'signed transaction'
 		return (red,green)[ret](str(ret)) if color else ret
 
-	# protect against an attack where a malicious, compromised or malfunctioning coin daemon could switch
-	# hex transaction data.
+	# check that a malicious, compromised or malfunctioning coin daemon hasn't altered hex tx data:
 	def check_hex_tx_matches_mmgen_tx(self,deserial_tx):
-		m = 'Fatal error: a malicious or malfunctioning coin daemon or other program has altered your data!'
+		m = 'Fatal error: a malicious or malfunctioning coin daemon or other program may have altered your data!'
 
-		if deserial_tx['lock_time'] != 0:
-			rdie(3,'\nLock time is not zero!\n' + m)
+		lt = deserial_tx['lock_time']
+		if lt != int(self.locktime or 0):
+			m2 = '\nTransaction hex locktime ({}) does not match MMGen transaction locktime ({})\n{}'
+			rdie(3,m2.format(lt,self.locktime,m))
 
-		def check_equal(desc,mmio,hexio):
+		def check_equal(desc,hexio,mmio):
 			if mmio != hexio:
 				msg('\nMMGen {}:\n{}'.format(desc,pformat(mmio)))
 				msg('Hex {}:\n{}'.format(desc,pformat(hexio)))
-				m2 = '{} in hex transaction data from coin daemon do not match those in MMGen transaction!\n' + m
-				rdie(3,m2.format(desc.capitalize()))
+				m2 = '{} in hex transaction data from coin daemon do not match those in MMGen transaction!\n'
+				rdie(3,(m2+m).format(desc.capitalize()))
+
+		seq_hex   = map(lambda i: int(i['nSeq'],16),deserial_tx['txins'])
+		seq_mmgen = map(lambda i: i.sequence or g.max_int,self.inputs)
+		check_equal('sequence numbers',seq_hex,seq_mmgen)
 
 		d_hex   = sorted((i['txid'],i['vout']) for i in deserial_tx['txins'])
 		d_mmgen = sorted((i.txid,i.vout) for i in self.inputs)
@@ -649,7 +687,7 @@ class MMGenTX(MMGenObject):
 
 		uh = deserial_tx['unsigned_hex']
 		if str(self.txid) != make_chksum_6(unhexlify(uh)).upper():
-			die(3,'MMGen TxID ({}) does not match hex transaction data!'.format(self.txid))
+			rdie(3,'MMGen TxID ({}) does not match hex transaction data!\n{}'.format(self.txid,m))
 
 	def check_sigs(self,deserial_tx=None): # return False if no sigs, die on error
 		txins = (deserial_tx or DeserializedTX(self.hex))['txins']
@@ -706,7 +744,7 @@ class MMGenTX(MMGenObject):
 		if ret:
 			die(1,'Transaction has been replaced'+('',', and the replacement TX is confirmed')[ret==2]+'!')
 
-	def send(self,prompt_user=True):
+	def send(self,prompt_user=True,exit_on_fail=False):
 
 		if not self.marked_signed():
 			die(1,'Transaction is not signed!')
@@ -745,10 +783,14 @@ class MMGenTX(MMGenObject):
 			elif 'Illegal use of SIGHASH_FORKID' in errmsg:
 				m  = 'The Aug. 1 2017 UAHF is not yet active on this chain.'
 				m += "\nRe-run the script without the --coin=bch option."
+			elif '64: non-final' in errmsg:
+				m2 = "Transaction with locktime '{}' can't be included in this block!"
+				m = m2.format(strfmt_locktime(self.get_hex_locktime()))
 			else:
 				m = errmsg
 			msg(yellow(m))
 			msg(red('Send of MMGen transaction {} failed'.format(self.txid)))
+			if exit_on_fail: sys.exit(1)
 			return False
 		else:
 			if bogus_send:
@@ -768,24 +810,28 @@ class MMGenTX(MMGenObject):
 			ask_write=ask_write,
 			ask_write_default_yes=ask_write_default_yes)
 
+	def create_fn(self):
+		tl = self.get_hex_locktime()
+		self.fn = '{}{}[{!s}{}{}].{}'.format(
+			self.txid,
+			('-'+g.coin,'')[g.coin=='BTC'],
+			self.send_amt,
+			('',',{}'.format(self.btc2spb(self.get_fee())))[self.is_rbf()],
+			('',',tl={}'.format(tl))[bool(tl)],
+			self.ext)
+
 	def write_to_file(  self,
 						add_desc='',
 						ask_write=True,
 						ask_write_default_yes=False,
 						ask_tty=True,
-						ask_overwrite=True,
-						fn=None):
-		if ask_write == False:
-			ask_write_default_yes=True
-		self.format()
-		if not fn:
-			fn = '{}{}[{}{}].{}'.format(
-				self.txid,
-				('-'+g.coin,'')[g.coin=='BTC'],
-				self.send_amt,
-				('',',{}'.format(self.btc2spb(self.get_fee())))[self.is_rbf()],
-				self.ext)
-		write_data_to_file(fn,self.fmt_data,self.desc+add_desc,
+						ask_overwrite=True):
+
+		if ask_write == False: ask_write_default_yes = True
+		if not self.fmt_data:  self.format()
+		if not self.fn:        self.create_fn()
+
+		write_data_to_file(self.fn,self.fmt_data,self.desc+add_desc,
 			ask_overwrite=ask_overwrite,
 			ask_write=ask_write,
 			ask_tty=ask_tty,
@@ -814,9 +860,6 @@ class MMGenTX(MMGenObject):
 	def is_rbf(self):
 		return self.inputs[0].sequence == g.max_int - 2
 
-	def signal_for_rbf(self):
-		self.inputs[0].sequence = g.max_int - 2
-
 	def format_view(self,terse=False):
 		try:
 			rpc_init()
@@ -824,12 +867,6 @@ class MMGenTX(MMGenObject):
 		except:
 			blockcount = None
 
-		hdr_fs = (
-			'TRANSACTION DATA\n\n[ID:{}] [{} {}] [{} UTC] [RBF:{}] [Signed:{}]\n',
-			'Transaction {} {} {} ({} UTC) RBF={} Signed={}\n'
-		)[bool(terse)]
-		nonmm_str = '(non-{pnm} address)'.format(pnm=g.proj_name)
-
 		def get_max_mmwid(io):
 			if io == self.inputs:
 				sel_f = lambda o: len(o.mmid) + 2 # len('()')
@@ -837,6 +874,7 @@ class MMGenTX(MMGenObject):
 				sel_f = lambda o: len(o.mmid) + (2,8)[bool(o.is_chg)] # + len(' (chg)')
 			return  max(max([sel_f(o) for o in io if o.mmid] or [0]),len(nonmm_str))
 
+		nonmm_str = '(non-{pnm} address)'.format(pnm=g.proj_name)
 		max_mmwid = max(get_max_mmwid(self.inputs),get_max_mmwid(self.outputs))
 
 		def format_io(io):
@@ -866,8 +904,18 @@ class MMGenTX(MMGenObject):
 					io_out += '\n'.join([('{:>3} {:<8} {}'.format(*d)) for d in items if d[2]]) + '\n\n'
 			return io_out
 
-		out = hdr_fs.format(self.txid.hl(),self.send_amt.hl(),g.coin,self.timestamp,
-				(red('False'),green('True'))[self.is_rbf()],self.marked_signed(color=True))
+		hdr_fs = (
+			'TRANSACTION DATA\n\nID={} ({} {}) UTC={} RBF={} Sig={} Locktime={}\n',
+			'TX {} ({} {}) UTC={} RBF={} Sig={} Locktime={}\n'
+		)[bool(terse)]
+		out = hdr_fs.format(self.txid.hl(),
+							self.send_amt.hl(),
+							g.coin,
+							self.timestamp,
+							(red('False'),
+							green('True'))[self.is_rbf()],
+							self.marked_signed(color=True),
+							(green('None'),orange(strfmt_locktime(self.locktime,terse=True)))[bool(self.locktime)])
 		if self.chain in ('testnet','regtest'):
 			out += green('Chain: {}\n'.format(self.chain.upper()))
 		if self.coin_txid:
@@ -925,6 +973,10 @@ class MMGenTX(MMGenObject):
 			metadata,self.hex,inputs_data,outputs_data = tx_data
 			metadata = metadata.split()
 
+			if metadata[-1].find('LT=') == 0:
+				desc = 'locktime'
+				self.locktime = int(metadata.pop()[3:])
+
 			self.coin = metadata.pop(0) if len(metadata) == 6 else 'BTC'
 
 			if len(metadata) == 5:
@@ -955,7 +1007,7 @@ class MMGenTX(MMGenObject):
 		if not self.chain and not self.inputs[0].addr.is_for_chain('testnet'):
 			self.chain = 'mainnet'
 
-	def get_fee_from_estimate_or_user(self,estimate_fail_msg_shown=[]):
+	def get_fee_from_user(self,have_estimate_fail=[]):
 
 		if opt.tx_fee:
 			desc = 'User-selected'
@@ -964,9 +1016,9 @@ class MMGenTX(MMGenObject):
 			desc = 'Network-estimated'
 			ret = g.rpch.estimatefee(opt.tx_confs)
 			if ret == -1:
-				if not estimate_fail_msg_shown:
+				if not have_estimate_fail:
 					msg('Network fee estimation for {} confirmations failed'.format(opt.tx_confs))
-					estimate_fail_msg_shown.append(True)
+					have_estimate_fail.append(True)
 				start_fee = None
 			else:
 				start_fee = g.proto.coin_amt(ret) * opt.tx_fee_adj * self.estimate_size() / 1024
@@ -1039,7 +1091,7 @@ class MMGenTX(MMGenObject):
 
 			self.copy_inputs_from_tw(sel_unspent)  # makes self.inputs
 
-			change_amt = self.sum_inputs() - self.send_amt - self.get_fee_from_estimate_or_user()
+			change_amt = self.sum_inputs() - self.send_amt - self.get_fee_from_user()
 
 			if change_amt >= 0:
 				p = 'Transaction produces {} {} in change'.format(change_amt.hl(),g.coin)
@@ -1049,7 +1101,8 @@ class MMGenTX(MMGenObject):
 			else:
 				msg(wmsg['not_enough_coin'].format(abs(change_amt)))
 
-	def create(self,cmd_args,do_info=False):
+	def create(self,cmd_args,locktime,do_info=False):
+		assert type(locktime) == int
 
 		if opt.comment_file: self.add_comment(opt.comment_file)
 
@@ -1072,7 +1125,9 @@ class MMGenTX(MMGenObject):
 
 		change_amt = self.get_inputs_from_user(tw)
 
-		if opt.rbf: self.signal_for_rbf() # only after we have inputs
+		# only after we have inputs
+		if locktime: self.inputs[0].sequence = g.max_int - 1
+		if opt.rbf:  self.inputs[0].sequence = g.max_int - 2
 
 		chg_idx = self.get_chg_output_idx()
 
@@ -1089,6 +1144,12 @@ class MMGenTX(MMGenObject):
 			self.add_comment()  # edits an existing comment
 		self.create_raw()       # creates self.hex, self.txid
 
+		if locktime:
+			msg('Setting nlocktime to {}!'.format(strfmt_locktime(locktime)))
+			self.set_hex_locktime(locktime)
+			self.update_txid()
+			self.locktime = locktime
+
 		self.add_timestamp()
 		self.add_blockcount()
 		self.chain = g.chain
@@ -1165,3 +1226,77 @@ class MMGenBumpTX(MMGenTX):
 			msg('{} {c}: {} fee too large. Maximum fee: <{} {c}'.format(ret,desc,output_amt,c=g.coin))
 			return False
 		return ret
+
+class MMGenSplitTX(MMGenTX):
+
+	def __init__(self):
+		super(type(self),self).__init__()
+
+	def get_fee_from_user(self,have_estimate_fail=[],split_tx=False):
+
+		if split_tx:
+			try:
+				rpc_init(reinit=True)
+			except:
+				ymsg('Connect to {} daemon failed.  Network fee estimation unavailable'.format(g.coin))
+				desc = 'User-selected'
+				try:
+					# TODO: check in opts.py
+					start_fee = opt.tx_fees.split(',')[split_tx]
+				except:
+					start_fee = None
+				return self.get_usr_fee_interactive(start_fee,desc=desc)
+
+		return super(type(self),self).get_fee_from_user(have_estimate_fail=have_estimate_fail)
+
+	def get_outputs_from_cmdline(self,mmid):
+
+		from mmgen.addr import AddrData
+		ad_w = AddrData(source='tw')
+
+		# TODO: check that addr is empty
+
+		if is_mmgen_id(mmid):
+			coin_addr = mmaddr2coinaddr(mmid,ad_w,None) if is_mmgen_id(mmid) else CoinAddr(mmid)
+			self.add_output(coin_addr,g.proto.coin_amt('0'),is_chg=True)
+		else:
+			die(2,'{}: invalid command-line argument'.format(mmid))
+
+		self.add_mmaddrs_to_outputs(ad_w,None)
+
+		if not segwit_is_active() and self.has_segwit_outputs():
+			fs = '{} Segwit address requested on the command line, but Segwit is not active on this chain'
+			rdie(2,fs.format(g.proj_name))
+
+	def create_split(self,mmid):
+
+		self.outputs = self.MMGenTxOutputList()
+		self.get_outputs_from_cmdline(mmid)
+
+		while True:
+			change_amt = self.sum_inputs() - self.get_fee_from_user(split_tx=True)
+			if change_amt >= 0:
+				p = 'Transaction produces {} {} in change'.format(change_amt.hl(),g.coin)
+				if opt.yes or keypress_confirm(p+'.  OK?',default_yes=True):
+					if opt.yes: msg(p)
+					break
+			else:
+				msg(wmsg['not_enough_coin'].format(abs(change_amt)))
+
+		self.update_output_amt(0,change_amt)
+		self.send_amt = change_amt
+
+		if not opt.yes:
+			self.add_comment()  # edits an existing comment
+		self.create_raw()       # creates self.hex, self.txid
+
+		self.add_timestamp()
+		self.add_blockcount() # TODO
+		self.chain = g.chain
+
+		assert self.sum_inputs() - self.sum_outputs() <= g.proto.max_tx_fee
+
+		qmsg('Transaction successfully created')
+
+		if not opt.yes:
+			self.view_with_prompt('View decoded transaction?')

+ 31 - 8
scripts/test-release.sh

@@ -2,7 +2,7 @@
 # Tested on Linux, MinGW-64
 # MinGW's bash 3.1.17 doesn't do ${var^^}
 
-dfl_tests='obj misc btc btc_tn btc_rt bch bch_rt ltc ltc_tn ltc_rt tool gen'
+dfl_tests='obj misc btc btc_tn btc_rt bch bch_rt b2x b2x_rt ltc ltc_tn ltc_rt tool gen'
 PROGNAME=$(basename $0)
 while getopts hinPt OPT
 do
@@ -22,6 +22,8 @@ do
 		echo   "     btc_rt - bitcoin regtest"
 		echo   "     bch    - bitcoin cash (BCH)"
 		echo   "     bch_rt - bitcoin cash (BCH) regtest"
+		echo   "     b2x    - bitcoin 2x (B2X)"
+		echo   "     b2x_rt - bitcoin 2x (B2X) regtest"
 		echo   "     ltc    - litecoin"
 		echo   "     ltc_tn - litecoin testnet"
 		echo   "     ltc_rt - litecoin regtest"
@@ -51,7 +53,7 @@ set -e
 REFDIR=test/ref
 if uname -a | grep -qi mingw; then SUDO='' MINGW=1; else SUDO='sudo' MINGW=''; fi
 
-function check {
+check() {
 	[ "$BRANCH" ] || { echo 'No branch specified.  Exiting'; exit; }
 	[ "$(git diff $BRANCH)" == "" ] || {
 		echo "Unmerged changes from branch '$BRANCH'. Exiting"
@@ -60,7 +62,7 @@ function check {
 	git diff $BRANCH >/dev/null 2>&1 || exit
 }
 
-function install {
+install() {
 	set -x
 	eval "$SUDO rm -rf .test-release"
 	git clone --branch $BRANCH --single-branch . .test-release
@@ -74,7 +76,7 @@ function install {
 	[ "$MINGW" ] && ./setup.py build --compiler=mingw32
 	eval "$SUDO ./setup.py install"
 }
-function do_test {
+do_test() {
 	set +x
 	for i in "$@"; do
 		LS='\n'
@@ -93,7 +95,7 @@ t_obj=(
     'test/objtest.py --coin=ltc --testnet=1 -S')
 f_obj='Data object test complete'
 
-i_misc='Miscellaneous operations'
+i_misc='Miscellaneous operations' # includes autosign!
 s_misc='The bitcoin, bitcoin-abc and litecoin (mainnet) daemons must be running for the following tests'
 t_misc=(
     'test/test.py -On misc')
@@ -120,7 +122,9 @@ f_btc_tn='You may stop the bitcoin testnet daemon if you wish'
 
 i_btc_rt='Bitcoin regtest'
 s_btc_rt="The following tests will test MMGen's regtest (Bob and Alice) mode"
-t_btc_rt=('test/test.py -On regtest')
+t_btc_rt=(
+	'test/test.py -On regtest'
+	'test/test.py -On regtest_split')
 f_btc_rt="Regtest (Bob and Alice) mode tests for BTC completed"
 
 i_bch='Bitcoin cash (BCH)'
@@ -133,6 +137,16 @@ s_bch_rt="The following tests will test MMGen's regtest (Bob and Alice) mode"
 t_bch_rt=('test/test.py --coin=bch -On regtest')
 f_bch_rt="Regtest (Bob and Alice) mode tests for BCH completed"
 
+i_b2x='Bitcoin 2X (B2X)'
+s_b2x='The bitcoin 2X daemon (BTC1) must both be running for the following tests'
+t_b2x=('test/test.py -On --coin=b2x dfl_wallet main ref ref_other')
+f_b2x='You may stop the Bitcoin 2X daemon if you wish'
+
+i_b2x_rt='Bitcoin 2X (B2X) regtest'
+s_b2x_rt="The following tests will test MMGen's regtest (Bob and Alice) mode"
+t_b2x_rt=('test/test.py --coin=b2x -On regtest')
+f_b2x_rt="Regtest (Bob and Alice) mode tests for B2X completed"
+
 i_ltc='Litecoin'
 s_ltc='The litecoin daemon must both be running for the following tests'
 t_ltc=(
@@ -194,12 +208,13 @@ f_gen="gentest tests completed"
 }
 [ "$INSTALL_ONLY" ] && exit
 
-function skip_maybe {
+skip_maybe() {
     echo -n "Enter 's' to skip, or ENTER to continue: "; read
     [ "$REPLY" == 's' ] && return 0
     return 1
 }
-function run_tests {
+
+run_tests() {
 	for t in $1; do
         eval echo -e \${GREEN}'###' Running $(echo \$i_$t) tests\$RESET
         [ "$PAUSE" ] && { eval echo $(echo \$s_$t); skip_maybe && continue; }
@@ -209,9 +224,17 @@ function run_tests {
 	done
 }
 
+check_args() {
+	for i in $tests; do
+		echo "$dfl_tests" | grep -q "\<$i\>" || { echo "$i: unrecognized argument"; exit; }
+	done
+}
+
 tests=$dfl_tests
 [ "$*" ] && tests="$*"
 [ "$NO_PAUSE" ] || PAUSE=1
+
+check_args
 run_tests "$tests"
 
 echo -e "${GREEN}All OK$RESET"

+ 2 - 0
setup.py

@@ -136,6 +136,7 @@ setup(
 			'mmgen.main_passgen',
 			'mmgen.main_addrimport',
 			'mmgen.main_regtest',
+			'mmgen.main_split',
 			'mmgen.main_txcreate',
 			'mmgen.main_txbump',
 			'mmgen.main_txsign',
@@ -158,6 +159,7 @@ setup(
 			'cmds/mmgen-walletchk',
 			'cmds/mmgen-walletconv',
 			'cmds/mmgen-walletgen',
+			'cmds/mmgen-split',
 			'cmds/mmgen-txcreate',
 			'cmds/mmgen-txbump',
 			'cmds/mmgen-txsign',

+ 6 - 0
test/ref/6A52BC-B2X[106.6789,tl=1320969600].rawtx

@@ -0,0 +1,6 @@
+ff8ec9
+B2X MAINNET 6A52BC 106.6789 20171113_152611 434 LT=1320969600
+020000000321efc7591303a97400ed3bb813258762695f40100825255ab0f9d596310c654a0000000000feffffff21efc7591303a97400ed3bb813258762695f40100825255ab0f9d596310c654a0200000000ffffffff21efc7591303a97400ed3bb813258762695f40100825255ab0f9d596310c654a0100000000ffffffff0400e40b54020000001976a914e9721033b4cd4fae9e5510cdc9d7871dcf2be9e488ac30051d21000000001976a914b57ca511b3e5b54a7ec936b0fc6b44f595a7cffe88ac6c206028090000001976a9142794da6d30fc6adced41313ac3bfab900096b44488ac202cb2060000000017a914f12c150f224f045e75799410d6b6653af33568d8878065bc4e
+[{'confs': 1, 'scriptPubKey': '76a9149b1a59d1411e2678a707b371e8e805bbacdde32288ac', 'addr': '1F97Jd89wwmu4ELadesAdGDzg3d8Y6j5iP', 'vout': 0, 'sequence': 4294967294, 'txid': '4a650c3196d5f9b05a25250810405f6962872513b83bed0074a9031359c7ef21', 'mmid': '98831F3A:C:1', 'amt': B2XAmt('100'), 'label': u''}, {'confs': 1, 'scriptPubKey': '76a914abe58e1e45f6176910a4c1ac1ee62328d5cc4fd588ac', 'addr': '1GfuYaKHrhdiVybXMGCcjadSgfjvpdt2x9', 'vout': 2, 'txid': '4a650c3196d5f9b05a25250810405f6962872513b83bed0074a9031359c7ef21', 'mmid': '98831F3A:L:2', 'amt': B2XAmt('200'), 'label': u''}, {'confs': 1, 'scriptPubKey': 'a914dd36f2365178f16504c5c38492a015be59bff33b87', 'addr': '3Mrh5fQwUwV9U2D1VHBrBW6cwqM4ZBdja9', 'vout': 1, 'txid': '4a650c3196d5f9b05a25250810405f6962872513b83bed0074a9031359c7ef21', 'mmid': '98831F3A:S:2', 'amt': B2XAmt('199.9999598'), 'label': u''}]
+[{'is_chg': True, 'addr': '14cHfz8dixc3GL3HFZEbicjinguTkaL1BJ', 'amt': B2XAmt('393.3209406'), 'mmid': '98831F3A:L:4'}, {'addr': '1NHM5xG2x1972hGvPqi1X1bcQV4bg4B6zK', 'amt': B2XAmt('100')}, {'mmid': '98831F3A:C:5', 'amt': B2XAmt('5.5555'), 'addr': '1HYcdCFPmWakX2g8mP6ksxDDokDyRbeaAb'}, {'mmid': '98831F3A:S:3', 'amt': B2XAmt('1.1234'), 'addr': '3PgDafUUfbG4MYCXjKgzrfZTRFogLKS4fZ'}]
+TvwWgaAnrkQFpAxxjBa4PHvJ8NsJDsurtiv2HuzdnXWjQmY7LHyt6PZn5J7BNtB5VzHtBG7bUosCAMFon8yxUe2mYTZoH9e6dpoAz9E6JDZtUNYz9YnF1Z3jFND1X89RuKAk6YVBrfWseeyHR8vZDdaFzBPK5SPos

+ 6 - 0
test/ref/6A52BC-B2X[106.6789,tl=1320969600].testnet.rawtx

@@ -0,0 +1,6 @@
+c14468
+B2X TESTNET 6A52BC 106.6789 20171113_152611 434 LT=1320969600
+020000000321efc7591303a97400ed3bb813258762695f40100825255ab0f9d596310c654a0000000000feffffff21efc7591303a97400ed3bb813258762695f40100825255ab0f9d596310c654a0200000000ffffffff21efc7591303a97400ed3bb813258762695f40100825255ab0f9d596310c654a0100000000ffffffff0400e40b54020000001976a914e9721033b4cd4fae9e5510cdc9d7871dcf2be9e488ac30051d21000000001976a914b57ca511b3e5b54a7ec936b0fc6b44f595a7cffe88ac6c206028090000001976a9142794da6d30fc6adced41313ac3bfab900096b44488ac202cb2060000000017a914f12c150f224f045e75799410d6b6653af33568d8878065bc4e
+[{'confs': 1, 'addr': 'muf4bgD8kyD9qLpCMDqYTBSKY3DqTWZR92', 'vout': 0, 'sequence': 4294967294, 'label': u'', 'mmid': '98831F3A:C:1', 'txid': '4a650c3196d5f9b05a25250810405f6962872513b83bed0074a9031359c7ef21', 'amt': B2XAmt('100'), 'scriptPubKey': '76a9149b1a59d1411e2678a707b371e8e805bbacdde32288ac'}, {'confs': 1, 'addr': 'mwBrqdQGfj4yH6594qAzZVqmYfLdmB1C7W', 'vout': 2, 'label': u'', 'mmid': '98831F3A:L:2', 'txid': '4a650c3196d5f9b05a25250810405f6962872513b83bed0074a9031359c7ef21', 'amt': B2XAmt('200'), 'scriptPubKey': '76a914abe58e1e45f6176910a4c1ac1ee62328d5cc4fd588ac'}, {'confs': 1, 'addr': '2NDQu9QLy6PzVfoqZAQoioT5tABZENihnkH', 'vout': 1, 'label': u'', 'mmid': '98831F3A:S:2', 'txid': '4a650c3196d5f9b05a25250810405f6962872513b83bed0074a9031359c7ef21', 'amt': B2XAmt('199.9999598'), 'scriptPubKey': 'a914dd36f2365178f16504c5c38492a015be59bff33b87'}]
+[{'addr': 'mj8Ey3DcXz3J3SWty8CyYXx3egWAapMVMF', 'mmid': '98831F3A:L:4', 'amt': B2XAmt('393.3209406'), 'is_chg': True}, {'addr': 'n2oJP1M1m2aMookY7QgPLvowGUfJYcFbZ7', 'amt': B2XAmt('100')}, {'mmid': '98831F3A:C:5', 'addr': 'mx4ZvFLNaY21J99kUx58hsRYfjpgPYwpHn', 'amt': B2XAmt('5.5555')}, {'mmid': '98831F3A:S:3', 'addr': '2NFEReQQWH3mQZKq5QTJsUcYidc1r35E2Rg', 'amt': B2XAmt('1.1234')}]
+TvwWgaAnrkQFpAxxjBa4PHvJ8NsJDsurtiv2HuzdnXWjQmY7LHyt6PZn5J7BNtB5VzHtBG7bUosCAMFon8yxUe2mYTZoH9e6dpoAz9E6JDZtUNYz9YnF1Z3jFND1X89RuKAk6YVBrfWseeyHR8vZDdaFzBPK5SPos

+ 176 - 34
test/test.py

@@ -151,22 +151,25 @@ ref_subdir = '' if g.proto.base_coin == 'BTC' else g.proto.name
 altcoin_pfx = '' if g.proto.base_coin == 'BTC' else '-'+g.proto.base_coin
 tn_ext = ('','.testnet')[g.testnet]
 
-fork       = {'bch':'btc','btc':'btc','ltc':'ltc'}[g.coin.lower()]
-tx_fee     = {'btc':'0.0001','bch':'0.001','ltc':'0.01'}[g.coin.lower()]
-txbump_fee = {'btc':'123s','bch':'567s','ltc':'12345s'}[g.coin.lower()]
+coin_sel = g.coin.lower()
+if g.coin == 'B2X': coin_sel = 'btc'
 
-rtFundAmt  = {'btc':'500','bch':'500','ltc':'5500'}[g.coin.lower()]
+fork       = {'bch':'btc','btc':'btc','ltc':'ltc'}[coin_sel]
+tx_fee     = {'btc':'0.0001','bch':'0.001','ltc':'0.01'}[coin_sel]
+txbump_fee = {'btc':'123s','bch':'567s','ltc':'12345s'}[coin_sel]
+
+rtFundAmt  = {'btc':'500','bch':'500','ltc':'5500'}[coin_sel]
 rtFee = {
 	'btc': ('20s','10s','60s','0.0001','10s','20s'),
 	'bch': ('20s','10s','60s','0.0001','10s','20s'),
 	'ltc': ('1000s','500s','1500s','0.05','400s','1000s')
-}[g.coin.lower()]
+}[coin_sel]
 rtBals = {
 	'btc': ('499.999942','399.9998214','399.9998079','399.9996799','13.00000000','986.99957990','999.99957990'),
 	'bch': ('499.9999416','399.9999124','399.99989','399.9997616','276.22339397','723.77626763','999.99966160'),
 	'ltc': ('5499.9971','5399.994085','5399.993545','5399.987145','13.00000000','10986.93714500','10999.93714500'),
-}[g.coin.lower()]
-rtBobOp3 = {'btc':'S:2','bch':'L:3','ltc':'S:2'}[g.coin.lower()]
+}[coin_sel]
+rtBobOp3 = {'btc':'S:2','bch':'L:3','ltc':'S:2'}[coin_sel]
 
 if opt.segwit and 'S' not in g.proto.mmtypes:
 	die(1,'--segwit option incompatible with {}'.format(g.proto.__name__))
@@ -201,12 +204,9 @@ cfgs = {
 		},
 		'segwit': get_segwit_val()
 	},
-	'17': {
-		'tmpdir':        os.path.join('test','tmp17'),
-	},
-	'18': {
-		'tmpdir':        os.path.join('test','tmp18'),
-	},
+	'17': { 'tmpdir': os.path.join('test','tmp17') },
+	'18': { 'tmpdir': os.path.join('test','tmp18') },
+	'19': { 'tmpdir': os.path.join('test','tmp19'), 'wpasswd':'abc' },
 	'1': {
 		'tmpdir':        os.path.join('test','tmp1'),
 		'wpasswd':       'Dorian',
@@ -452,6 +452,7 @@ cfgs = {
 		'ref_tx_file': {
 			'btc': 'FFB367[1.234]{}.rawtx',
 			'bch': '99BE60-BCH[106.6789]{}.rawtx',
+			'b2x': '6A52BC-B2X[106.6789,tl=1320969600]{}.rawtx',
 			'ltc': '75F455-LTC[106.6789]{}.rawtx',
 		},
 		'ic_wallet':       '98831F3A-5482381C-18460FB1[256,1].mmincog',
@@ -671,6 +672,30 @@ cmd_group['regtest'] = (
 	('regtest_stop',               'stopping regtest daemon'),
 )
 
+cmd_group['regtest_split'] = (
+	('regtest_split_setup',        'regtest forking scenario setup'),
+	('regtest_walletgen_bob',      "generating Bob's wallet"),
+	('regtest_addrgen_bob',        "generating Bob's addresses"),
+	('regtest_addrimport_bob',     "importing Bob's addresses"),
+	('regtest_fund_bob',           "funding Bob's wallet"),
+	('regtest_split_fork',         'regtest split fork'),
+	('regtest_split_start_btc',    'start regtest daemon (BTC)'),
+	('regtest_split_start_b2x',    'start regtest daemon (B2X)'),
+	('regtest_split_gen_btc',      'mining a block (BTC)'),
+	('regtest_split_gen_b2x',      'mining 100 blocks (B2X)'),
+	('regtest_split_do_split',     'creating coin splitting transactions'),
+	('regtest_split_sign_b2x',     'signing B2X split transaction'),
+	('regtest_split_sign_btc',     'signing BTC split transaction'),
+	('regtest_split_send_b2x',     'sending B2X split transaction'),
+	('regtest_split_send_btc',     'sending BTC split transaction'),
+	('regtest_split_gen_btc',      'mining a block (BTC)'),
+	('regtest_split_gen_b2x2',     'mining a block (B2X)'),
+	('regtest_split_txdo_timelock_bad_btc', 'sending transaction with bad locktime (BTC)'),
+	('regtest_split_txdo_timelock_good_btc','sending transaction with good locktime (BTC)'),
+	('regtest_split_txdo_timelock_bad_b2x', 'sending transaction with bad locktime (B2X)'),
+	('regtest_split_txdo_timelock_good_b2x','sending transaction with good locktime (B2X)'),
+)
+
 cmd_group['misc'] = (
 	('autosign', 'transaction autosigning (BTC,BCH,LTC)'),
 )
@@ -743,6 +768,11 @@ for a,b in cmd_group['regtest']:
 	cmd_list['regtest'].append(a)
 	cmd_data[a] = (17,b,[[[],17]])
 
+cmd_data['info_regtest_split'] = 'regtest mode with fork and coin split',[17]
+for a,b in cmd_group['regtest_split']:
+	cmd_list['regtest_split'].append(a)
+	cmd_data[a] = (19,b,[[[],19]])
+
 cmd_data['info_misc'] = 'miscellaneous operations',[18]
 for a,b in cmd_group['misc']:
 	cmd_list['misc'].append(a)
@@ -935,7 +965,7 @@ def create_fake_unspent_entry(coinaddr,al_id=None,idx=None,lbl=None,non_mmgen=Fa
 	if 'S' not in g.proto.mmtypes: segwit = False
 	if lbl: lbl = ' ' + lbl
 	spk1,spk2 = (('76a914','88ac'),('a914','87'))[segwit and coinaddr.addr_fmt=='p2sh']
-	amt1,amt2 = {'btc':(10,40),'bch':(10,40),'ltc':(1000,4000)}[g.coin.lower()]
+	amt1,amt2 = {'btc':(10,40),'bch':(10,40),'ltc':(1000,4000)}[coin_sel]
 	return {
 		'account': '{}:{}'.format(g.proto.base_coin.lower(),coinaddr) if non_mmgen \
 			else (u'{}:{}{}'.format(al_id,idx,lbl.decode('utf8'))),
@@ -1032,7 +1062,7 @@ def make_txcreate_cmdline(tx_data):
 	coinaddr = AddrGenerator(t).to_addr(KeyGenerator().to_pubhex(privkey))
 
 	# total of two outputs must be < 10 BTC (<1000 LTC)
-	mods = {'btc':(6,4),'bch':(6,4),'ltc':(600,400)}[g.coin.lower()]
+	mods = {'btc':(6,4),'bch':(6,4),'ltc':(600,400)}[coin_sel]
 	for k in cfgs:
 		cfgs[k]['amts'] = [None,None]
 		for idx,mod in enumerate(mods):
@@ -1501,11 +1531,11 @@ class MMGenTestSuite(object):
 		add = ' #' + tnum if tnum else ''
 		t.written_to_file('Signed transaction' + add, oo=True)
 
-	def txsign(self,name,txfile,wf,pf='',bumpf='',save=True,has_label=False,txdo_handle=None):
+	def txsign(self,name,txfile,wf,pf='',bumpf='',save=True,has_label=False,txdo_handle=None,extra_opts=[]):
 		if txdo_handle:
 			t = txdo_handle
 		else:
-			t = MMGenExpect(name,'mmgen-txsign', ['-d',cfg['tmpdir'],txfile]+([],[wf])[bool(wf)])
+			t = MMGenExpect(name,'mmgen-txsign', extra_opts + ['-d',cfg['tmpdir'],txfile]+([],[wf])[bool(wf)])
 			t.license()
 			t.tx_view()
 		t.passphrase('MMGen wallet',cfg['wpasswd'])
@@ -1522,18 +1552,24 @@ class MMGenTestSuite(object):
 	def txsign_dfl_wallet(self,name,txfile,pf='',save=True,has_label=False):
 		return self.txsign(name,txfile,wf=None,pf=pf,save=save,has_label=has_label)
 
-	def txsend(self,name,sigfile,txdo_handle=None):
+	def txsend(self,name,sigfile,txdo_handle=None,really_send=False,extra_opts=[]):
 		if txdo_handle:
 			t = txdo_handle
 		else:
-			t = MMGenExpect(name,'mmgen-txsend', ['-d',cfg['tmpdir'],sigfile])
+			if really_send: os.environ['MMGEN_BOGUS_SEND'] = ''
+			t = MMGenExpect(name,'mmgen-txsend', extra_opts + ['-d',cfg['tmpdir'],sigfile])
+			if really_send: os.environ['MMGEN_BOGUS_SEND'] = '1'
 			t.license()
 			t.tx_view()
 			t.expect('Add a comment to transaction? (y/N): ','\n')
 		t.expect('Are you sure you want to broadcast this')
 		m = 'YES, I REALLY WANT TO DO THIS'
 		t.expect("'%s' to confirm: " % m,m+'\n')
-		t.expect('BOGUS transaction NOT sent')
+		if really_send:
+			txid = t.expect_getend('Transaction sent: ')
+			assert len(txid) == 64
+		else:
+			t.expect('BOGUS transaction NOT sent')
 		t.written_to_file('Sent transaction')
 		t.ok()
 
@@ -1818,7 +1854,7 @@ class MMGenTestSuite(object):
 		cmp_or_die(hincog_offset,int(o))
 
 	# Miscellaneous tests
-	def autosign(self,name):
+	def autosign(self,name): # tests everything except device detection, mount/unmount
 		if g.platform == 'win':
 			msg('Skipping {} (not supported)'.format(name)); return
 		fdata = (('btc',''),('bch',''),('ltc','litecoin'))
@@ -2102,8 +2138,8 @@ class MMGenTestSuite(object):
 	def regtest_walletgen_alice(self,name): return self.regtest_walletgen(name,'alice')
 
 	@staticmethod
-	def regtest_user_dir(user):
-		return os.path.join(data_dir,'regtest',user)
+	def regtest_user_dir(user,coin=None):
+		return os.path.join(data_dir,'regtest',coin or g.coin.lower(),user)
 
 	def regtest_user_sid(self,user):
 		return os.path.basename(get_file_with_ext('mmdat',self.regtest_user_dir(user)))[:8]
@@ -2183,7 +2219,15 @@ class MMGenTestSuite(object):
 		cmp_or_die(ret,rtBals[6],skip_ok=True)
 		t.ok()
 
-	def regtest_user_txdo(self,name,user,fee,outputs_cl,outputs_prompt,extra_args=[],wf=None,pw='abc',no_send=False,do_label=False):
+	def regtest_user_txdo(  self,name,user,fee,
+							outputs_cl,
+							outputs_prompt,
+							extra_args=[],
+							wf=None,
+							pw='abc',
+							no_send=False,
+							do_label=False,
+							bad_locktime=False):
 		os.environ['MMGEN_BOGUS_SEND'] = ''
 		t = MMGenExpect(name,'mmgen-txdo',
 			['-d',cfg['tmpdir'],'-B','--'+user,'--tx-fee='+fee]
@@ -2205,11 +2249,14 @@ class MMGenTestSuite(object):
 			t.expect('to continue: ','\n')
 		t.passphrase('MMGen wallet',pw)
 		t.written_to_file('Signed transaction')
-		if not no_send:
+		if no_send:
+			t.read()
+			exit_val = 0
+		else:
 			t.expect('to confirm: ','YES, I REALLY WANT TO DO THIS\n')
-			t.expect('Transaction sent')
-		t.read()
-		t.ok()
+			s,exit_val = (('Transaction sent',0),("can't be included",1))[bad_locktime]
+			t.expect(s)
+		t.ok(exit_val)
 
 	def regtest_bob_split1(self,name):
 		sid = self.regtest_user_sid('bob')
@@ -2269,9 +2316,11 @@ class MMGenTestSuite(object):
 		txfile = get_file_with_ext(',{}].sigtx'.format(rtFee[1][:-1]),cfg['tmpdir'],delete=False,no_dot=True)
 		return self.regtest_user_txbump(name,'bob',txfile,rtFee[2],'c')
 
-	def regtest_generate(self,name):
-		t = MMGenExpect(name,'mmgen-regtest',['generate'])
-		t.expect('Mined 1 block')
+	def regtest_generate(self,name,coin=None,num_blocks=1):
+		int(num_blocks)
+		if coin: opt.coin = coin
+		t = MMGenExpect(name,'mmgen-regtest',['generate',str(num_blocks)])
+		t.expect('Mined {} block'.format(num_blocks))
 		t.ok()
 
 	def regtest_get_mempool(self,name):
@@ -2389,7 +2438,95 @@ class MMGenTestSuite(object):
 		t = MMGenExpect(name,'mmgen-regtest',['stop'])
 		t.ok()
 
-	# regtest undocumented admin commands
+	def regtest_split_setup(self,name):
+		if g.coin != 'BTC': die(1,'Test valid only for coin BTC')
+		opt.coin = 'BTC'
+		return self.regtest_setup(name)
+
+	def regtest_split_fork(self,name):
+		opt.coin = 'B2X'
+		t = MMGenExpect(name,'mmgen-regtest',['fork','btc'])
+		t.expect('Creating fork from coin')
+		t.expect('successfully created')
+		t.ok()
+
+	def regtest_split_start(self,name,coin):
+		opt.coin = coin
+		t = MMGenExpect(name,'mmgen-regtest',['bob'])
+		t.expect('Starting')
+		t.expect('done')
+		t.ok()
+
+	def regtest_split_start_btc(self,name): self.regtest_split_start(name,coin='BTC')
+	def regtest_split_start_b2x(self,name): self.regtest_split_start(name,coin='B2X')
+	def regtest_split_gen_btc(self,name):   self.regtest_generate(name,coin='BTC')
+	def regtest_split_gen_b2x(self,name):   self.regtest_generate(name,coin='B2X',num_blocks=100)
+	def regtest_split_gen_b2x2(self,name):  self.regtest_generate(name,coin='B2X')
+
+	def regtest_split_do_split(self,name):
+		opt.coin = 'B2X'
+		sid = self.regtest_user_sid('bob')
+		t = MMGenExpect(name,'mmgen-split',[
+			'--bob',
+			'--outdir='+cfg['tmpdir'],
+			'--tx-fees=0.0001,0.0003',
+			sid+':S:1',sid+':S:2'])
+		t.expect(r"'q'=quit view, .*?:.",'q', regex=True)
+		t.expect('outputs to spend: ','1\n')
+
+		for tx in ('timelocked','split'):
+			for q in ('fee','change'): t.expect('OK? (Y/n): ','y')
+			t.expect('Add a comment to transaction? (y/N): ','n')
+			t.expect('View decoded transaction\? .*?: ','t',regex=True)
+			t.expect('to continue: ','\n')
+
+		t.written_to_file('Long chain (timelocked) transaction')
+		t.written_to_file('Short chain transaction')
+		t.ok()
+
+	def regtest_split_sign(self,name,coin,ext):
+		wf = get_file_with_ext('mmdat',self.regtest_user_dir('bob',coin=coin.lower()))
+		txfile = get_file_with_ext(ext,cfg['tmpdir'],no_dot=True)
+		opt.coin = coin
+		self.txsign(name,txfile,wf,extra_opts=['--bob'])
+
+	def regtest_split_sign_b2x(self,name):
+		return self.regtest_split_sign(name,coin='B2X',ext='533].rawtx')
+
+	def regtest_split_sign_btc(self,name):
+		return self.regtest_split_sign(name,coin='BTC',ext='9997].rawtx')
+
+	def regtest_split_send(self,name,coin,ext):
+		opt.coin = coin
+		txfile = get_file_with_ext(ext,cfg['tmpdir'],no_dot=True)
+		self.txsend(name,txfile,really_send=True,extra_opts=['--bob'])
+
+	def regtest_split_send_b2x(self,name):
+		return self.regtest_split_send(name,coin='B2X',ext='533].sigtx')
+
+	def regtest_split_send_btc(self,name):
+		return self.regtest_split_send(name,coin='BTC',ext='9997].sigtx')
+
+	def regtest_split_txdo_timelock(self,name,coin,locktime,bad_locktime):
+		opt.coin = coin
+		sid = self.regtest_user_sid('bob')
+		self.regtest_user_txdo(
+			name,'bob','0.0001',[sid+':S:5'],'1',pw='abc',
+			extra_args=['--locktime='+str(locktime)],
+			bad_locktime=bad_locktime)
+
+	def regtest_split_txdo_timelock_bad_btc(self,name):
+		self.regtest_split_txdo_timelock(name,'BTC',locktime=8888,bad_locktime=True)
+	def regtest_split_txdo_timelock_good_btc(self,name):
+		self.regtest_split_txdo_timelock(name,'BTC',locktime=1321009871,bad_locktime=False)
+	def regtest_split_txdo_timelock_bad_b2x(self,name):
+		self.regtest_split_txdo_timelock(name,'B2X',locktime=8888,bad_locktime=True)
+	def regtest_split_txdo_timelock_good_b2x(self,name):
+		self.regtest_split_txdo_timelock(name,'B2X',locktime=1321009871,bad_locktime=False)
+
+#	def regtest_user_txdo(self,name,user,fee,outputs_cl,outputs_prompt,extra_args=[],wf=None,pw='abc',no_send=False,do_label=False):
+
+	# undocumented admin commands
 	ref_tx_setup = regtest_setup
 	ref_tx_generate = regtest_generate
 
@@ -2420,7 +2557,12 @@ class MMGenTestSuite(object):
 		outputs_cl = [sid+':{}:3,1.1234'.format(g.proto.mmtypes[-1]), sid+':C:5,5.5555',sid+':L:4',addr+',100']
 		pw = cfg['wpasswd']
 		# create tx in cwd
-		t = MMGenExpect(name,'mmgen-txcreate',['-B','--bob','--tx-fee='+rtFee[0]] + outputs_cl)
+		t = MMGenExpect(name,'mmgen-txcreate',[
+									'-B',
+									'--bob',
+									'--tx-fee='+rtFee[0],
+									'--locktime=1320969600'
+								] + outputs_cl)
 #			[os.path.join(ref_dir,cfg['ref_wallet'])])
 		t.expect(r"'q'=quit view, .*?:.",'M',regex=True) # sort by mmid
 		t.expect(r"'q'=quit view, .*?:.",'q',regex=True)
@@ -2441,7 +2583,7 @@ class MMGenTestSuite(object):
 		with open(fn) as f:
 			lines = f.read().splitlines()
 
-		from mmgen.obj import BTCAmt,LTCAmt,BCHAmt
+		from mmgen.obj import BTCAmt,LTCAmt,BCHAmt,B2XAmt
 		tx = {}
 		for k,i in (('in',3),('out',4)):
 			tx[k] = eval(lines[i])