Browse Source

mmgen-txcreate: automatic change address selection

Instead of selecting a specific change address, i.e. '01ABCDEF:B:7', users can
now omit the address index, specifying '01ABCDEF:B' instead, and the script
will automatically choose the first unused address in the tracking wallet
matching the requested seed ID and address type.

This feature is entirely opt-in.  Existing behavior continues to be supported
without alteration.

Sample invocations:

    # old invocation:
    $ mmgen-txcreate -q 35N9FntsNwy98TmjFHyCpsBVDVUs5wDPfB,0.123 01ABCDEF:S:7

    # new invocation:
    $ mmgen-txcreate -q 35N9FntsNwy98TmjFHyCpsBVDVUs5wDPfB,0.123 01ABCDEF:S

Testing/demo:

    # run the regtest test partially, leaving the coin daemon running:
    $ test/test.py -Dn regtest.view

    # run the 'auto_chg' test group, displaying script output:
    $ test/test.py -DSAe regtest.auto_chg

    # view the addresses in Bob’s tracking wallet, verifying that the first
    # unused ones in each grouping were chosen as change outputs:
    $ PYTHONPATH=. MMGEN_TEST_SUITE=1 cmds/mmgen-tool --bob listaddresses interactive=1

    # When finished, gracefully shut down the daemon:
    $ test/stop-coin-daemons.py btc_rt
The MMGen Project 2 years ago
parent
commit
cbe7498131

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-13.3.dev21
+13.3.dev22

+ 12 - 3
mmgen/help.py

@@ -157,10 +157,14 @@ EXAMPLES:
 
 
     $ {g.prog_name} {sample_addr},0.123 01ABCDEF:{mmtype}:7
     $ {g.prog_name} {sample_addr},0.123 01ABCDEF:{mmtype}:7
 
 
+  Same as above, but select the change address automatically:
+
+    $ {g.prog_name} {sample_addr},0.123 01ABCDEF:{mmtype}
+
   Same as above, but reduce verbosity and specify fee of 20 satoshis
   Same as above, but reduce verbosity and specify fee of 20 satoshis
   per byte:
   per byte:
 
 
-    $ {g.prog_name} -q -f 20s {sample_addr},0.123 01ABCDEF:{mmtype}:7
+    $ {g.prog_name} -q -f 20s {sample_addr},0.123 01ABCDEF:{mmtype}
 
 
   Send entire balance of selected inputs minus fee to an external {proto.name}
   Send entire balance of selected inputs minus fee to an external {proto.name}
   address:
   address:
@@ -178,7 +182,10 @@ All addresses on the command line can be either {proto.name} addresses or MMGen
 IDs in the form <seed ID>:<address type letter>:<index>.
 IDs in the form <seed ID>:<address type letter>:<index>.
 
 
 Outputs are specified in the form <address>,<amount>, with the change output
 Outputs are specified in the form <address>,<amount>, with the change output
-specified by address only.
+specified by address only.  Alternatively, the change output may be an
+addrlist ID in the form <seed ID>:<address type letter>, in which case the
+first unused address in the tracking wallet matching the requested ID will
+be automatically selected as the change output.
 
 
 If the transaction fee is not specified on the command line (see FEE
 If the transaction fee is not specified on the command line (see FEE
 SPECIFICATION below), it will be calculated dynamically using network fee
 SPECIFICATION below), it will be calculated dynamically using network fee
@@ -189,7 +196,9 @@ Network-estimated fees will be multiplied by the value of --tx-fee-adj, if
 specified.
 specified.
 
 
 To send the value of all inputs (minus TX fee) to a single output, specify
 To send the value of all inputs (minus TX fee) to a single output, specify
-a single address with no amount on the command line.
+a single address with no amount on the command line.  Alternatively, an
+addrlist ID may be specified, and the address will be chosen automatically
+as described above for the change output.
 """
 """
 
 
 		def txsign():
 		def txsign():

+ 1 - 1
mmgen/main_txcreate.py

@@ -27,7 +27,7 @@ opts_data = {
 	'sets': [('yes', True, 'quiet', True)],
 	'sets': [('yes', True, 'quiet', True)],
 	'text': {
 	'text': {
 		'desc': f'Create a transaction with outputs to specified coin or {g.proj_name} addresses',
 		'desc': f'Create a transaction with outputs to specified coin or {g.proj_name} addresses',
-		'usage':   '[opts]  <addr,amt> ... [change addr] [addr file] ...',
+		'usage':   '[opts]  [<addr,amt> ...] <change addr or addrlist ID> [addr file ...]',
 		'options': """
 		'options': """
 -h, --help            Print this help message
 -h, --help            Print this help message
 --, --longhelp        Print help message for long options (common options)
 --, --longhelp        Print help message for long options (common options)

+ 1 - 1
mmgen/main_txdo.py

@@ -28,7 +28,7 @@ opts_data = {
 	'sets': [('yes', True, 'quiet', True)],
 	'sets': [('yes', True, 'quiet', True)],
 	'text': {
 	'text': {
 		'desc': f'Create, sign and send an {g.proj_name} transaction',
 		'desc': f'Create, sign and send an {g.proj_name} transaction',
-		'usage':   '[opts]  <addr,amt> ... [change addr] [addr file] ... [seed source] ...',
+		'usage':   '[opts]  [<addr,amt> ...] <change addr or addrlist ID> [addr file ...] [seed source ...]',
 		'options': """
 		'options': """
 -h, --help             Print this help message
 -h, --help             Print this help message
 --, --longhelp         Print help message for long options (common options)
 --, --longhelp         Print help message for long options (common options)

+ 46 - 0
mmgen/tw/addresses.py

@@ -249,6 +249,52 @@ class TwAddresses(TwView):
 		else: # addr not in tracking wallet
 		else: # addr not in tracking wallet
 			return None
 			return None
 
 
+	def get_change_address(self,al_id):
+		"""
+		Get lowest-indexed unused address in tracking wallet for requested AddrListID.
+		Return values on failure:
+		    None:  no addresses in wallet with requested AddrListID
+		    False: no unused addresses in wallet with requested AddrListID
+		"""
+
+		def get_start():
+			"""
+			bisecting algorithm to find first entry with requested al_id
+
+			Since 'btc' > 'F' and pre_target sorts below the first twmmid of the al_id
+			stringwise, we can just search on raw twmmids.
+			"""
+			pre_target = al_id + ':0'
+			bot = 0
+			top = len(data) - 1
+			n = top >> 1
+
+			while True:
+
+				if bot == top:
+					return bot if data[bot].al_id == al_id else None
+
+				if data[n].twmmid < pre_target:
+					bot = n + 1
+				else:
+					top = n
+
+				n = (top + bot) >> 1
+
+		self.reverse = False
+		self.do_sort('twmmid')
+
+		data = self.data
+		start = get_start()
+
+		if start is not None:
+			for d in data[start:]:
+				if d.al_id == al_id:
+					if not d.recvd:
+						return d
+				else:
+					return False
+
 	class action(TwView.action):
 	class action(TwView.action):
 
 
 		def s_amt(self,parent):
 		def s_amt(self,parent):

+ 26 - 2
mmgen/tx/new.py

@@ -18,7 +18,7 @@ from .base import Base
 from ..color import pink,yellow
 from ..color import pink,yellow
 from ..obj import get_obj,MMGenList
 from ..obj import get_obj,MMGenList
 from ..util import msg,qmsg,fmt,die,suf,remove_dups,get_extension
 from ..util import msg,qmsg,fmt,die,suf,remove_dups,get_extension
-from ..addr import is_mmgen_id,CoinAddr,is_coin_addr
+from ..addr import is_mmgen_id,CoinAddr,is_coin_addr,AddrListID,is_addrlist_id
 
 
 def mmaddr2coinaddr(mmaddr,ad_w,ad_f,proto):
 def mmaddr2coinaddr(mmaddr,ad_w,ad_f,proto):
 
 
@@ -69,6 +69,7 @@ class New(Base):
 		ERROR: No change address specified.  If you wish to create a transaction with
 		ERROR: No change address specified.  If you wish to create a transaction with
 		only one output, specify a single output address with no {} amount
 		only one output, specify a single output address with no {} amount
 	"""
 	"""
+	chg_autoselected = False
 
 
 	def update_output_amt(self,idx,amt):
 	def update_output_amt(self,idx,amt):
 		o = self.outputs[idx]._asdict()
 		o = self.outputs[idx]._asdict()
@@ -180,6 +181,18 @@ class New(Base):
 			coin_addr = mmaddr2coinaddr(addr,ad_w,ad_f,self.proto)
 			coin_addr = mmaddr2coinaddr(addr,ad_w,ad_f,self.proto)
 		elif is_coin_addr(self.proto,addr):
 		elif is_coin_addr(self.proto,addr):
 			coin_addr = CoinAddr(self.proto,addr)
 			coin_addr = CoinAddr(self.proto,addr)
+		elif is_addrlist_id(self.proto,addr):
+			if self.proto.base_proto_coin != 'BTC':
+				die(2,f'Change addresses not supported for {self.proto.name} protocol')
+			from ..tw.addresses import TwAddresses
+			res = (await TwAddresses(self.proto,get_data=True)).get_change_address(addr)
+			if res:
+				coin_addr = res.addr
+				self.chg_autoselected = True
+			else:
+				die(2,'Tracking wallet contains no {t}addresses from address list {a!r}'.format(
+					t = ('unused ','')[res is None],
+					a = addr ))
 		else:
 		else:
 			die(2,f'{addr}: invalid {err_desc} {{!r}}'.format(f'{addr},{amt}' if amt else addr))
 			die(2,f'{addr}: invalid {err_desc} {{!r}}'.format(f'{addr},{amt}' if amt else addr))
 
 
@@ -236,9 +249,20 @@ class New(Base):
 		self.check_dup_addrs('outputs')
 		self.check_dup_addrs('outputs')
 
 
 		if self.chg_output is not None:
 		if self.chg_output is not None:
-			if len(self.outputs) > 1:
+			if self.chg_autoselected:
+				self.confirm_autoselected_addr(self.chg_output)
+			elif len(self.outputs) > 1:
 				await self.warn_chg_addr_used(self.chg_output)
 				await self.warn_chg_addr_used(self.chg_output)
 
 
+	def confirm_autoselected_addr(self,chg):
+		from ..ui import keypress_confirm
+		if not keypress_confirm(
+				'Using {a} as {b} address. OK?'.format(
+					a = chg.mmid.hl(),
+					b = 'single output' if len(self.outputs) == 1 else 'change' ),
+				default_yes = True ):
+			die(1,'Exiting at user request')
+
 	async def warn_chg_addr_used(self,chg):
 	async def warn_chg_addr_used(self,chg):
 		from ..tw.addresses import TwAddresses
 		from ..tw.addresses import TwAddresses
 		if (await TwAddresses(self.proto,get_data=True)).is_used(chg.addr):
 		if (await TwAddresses(self.proto,get_data=True)).is_used(chg.addr):

+ 73 - 0
test/test_py_d/ts_regtest.py

@@ -164,6 +164,7 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 		('subgroup.txhist',     ['main']),
 		('subgroup.txhist',     ['main']),
 		('subgroup.label',      ['main']),
 		('subgroup.label',      ['main']),
 		('subgroup.view',       ['label']),
 		('subgroup.view',       ['label']),
+		('subgroup.auto_chg',   ['view']),
 		('stop',                'stopping regtest daemon'),
 		('stop',                'stopping regtest daemon'),
 	)
 	)
 	cmd_subgroups = {
 	cmd_subgroups = {
@@ -346,6 +347,18 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 		('alice_txcreate_info',           'txcreate -i'),
 		('alice_txcreate_info',           'txcreate -i'),
 		('alice_txcreate_info_term',      'txcreate -i (pexpect_spawn)'),
 		('alice_txcreate_info_term',      'txcreate -i (pexpect_spawn)'),
 	),
 	),
+	'auto_chg': (
+		'automatic change address selection',
+		('bob_split3',        'splitting Bob’s funds'),
+		('generate',          'mining a block'),
+		('bob_auto_chg1',     'creating an automatic change address transaction (C)'),
+		('bob_auto_chg2',     'creating an automatic change address transaction (B)'),
+		('bob_auto_chg3',     'creating an automatic change address transaction (S)'),
+		('bob_auto_chg4',     'creating an automatic change address transaction (single address)'),
+		('bob_auto_chg_bad1', 'error handling for auto change address transaction (bad ID FFFFFFFF:C)'),
+		('bob_auto_chg_bad2', 'error handling for auto change address transaction (bad ID 00000000:C)'),
+		('bob_auto_chg_bad3', 'error handling for auto change address transaction (no unused addresses)'),
+	),
 	}
 	}
 
 
 	def __init__(self,trunner,cfgs,spawn):
 	def __init__(self,trunner,cfgs,spawn):
@@ -1394,6 +1407,66 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 			msgfile = os.path.join(self.tmpdir,'signatures.json')
 			msgfile = os.path.join(self.tmpdir,'signatures.json')
 		)
 		)
 
 
+	def bob_split3(self):
+		if not self.proto.cap('segwit'):
+			return 'skip'
+		sid = self._user_sid('bob')
+		return self.user_txdo(
+			user = 'bob',
+			fee = '23s',
+			outputs_cl = [sid+':C:5,0.0135', sid+':L:4'],
+			outputs_list = '1' )
+
+	def _bob_auto_chg(self,al_id,include_dest=True):
+		dest = [self.burn_addr+',0.01'] if include_dest else []
+		t = self.spawn(
+			'mmgen-txcreate',
+			['-d',self.tr.trash_dir,'-B','--bob', al_id] + dest)
+		return self.txcreate_ui_common(t,
+			menu            = [],
+			inputs          = '1',
+			interactive_fee = '20s',
+			auto_chg_al_id  = al_id )
+
+	def bob_auto_chg1(self):
+		return self._bob_auto_chg(self._user_sid('bob') + ':C')
+
+	def bob_auto_chg2(self):
+		if not self.proto.cap('segwit'):
+			return 'skip'
+		return self._bob_auto_chg(self._user_sid('bob') + ':B')
+
+	def bob_auto_chg3(self):
+		if not self.proto.cap('segwit'):
+			return 'skip'
+		return self._bob_auto_chg(self._user_sid('bob') + ':S')
+
+	def bob_auto_chg4(self):
+		return self._bob_auto_chg( self._user_sid('bob') + ':C', include_dest=False )
+
+	def _bob_auto_chg_bad(self,al_id,expect):
+		t = self.spawn(
+			'mmgen-txcreate',
+			['-d',self.tr.trash_dir,'-B','--bob', self.burn_addr+',0.01', al_id] )
+		t.req_exit_val = 2
+		t.expect(expect)
+		return t
+
+	def bob_auto_chg_bad1(self):
+		return self._bob_auto_chg_bad(
+			'FFFFFFFF:C',
+			'contains no addresses' )
+
+	def bob_auto_chg_bad2(self):
+		return self._bob_auto_chg_bad(
+			'00000000:C',
+			'contains no addresses' )
+
+	def bob_auto_chg_bad3(self):
+		return self._bob_auto_chg_bad(
+			self._user_sid('bob') + ':L',
+			'contains no unused addresses' )
+
 	def stop(self):
 	def stop(self):
 		if opt.no_daemon_stop:
 		if opt.no_daemon_stop:
 			self.spawn('',msg_only=True)
 			self.spawn('',msg_only=True)

+ 5 - 1
test/test_py_d/ts_shared.py

@@ -47,7 +47,8 @@ class TestSuiteShared(object):
 			view              = 't',
 			view              = 't',
 			save              = True,
 			save              = True,
 			tweaks            = [],
 			tweaks            = [],
-			used_chg_addr_resp = None ):
+			used_chg_addr_resp = None,
+			auto_chg_al_id    = None ):
 
 
 		txdo = (caller or self.test_name)[:4] == 'txdo'
 		txdo = (caller or self.test_name)[:4] == 'txdo'
 
 
@@ -58,6 +59,9 @@ class TestSuiteShared(object):
 		if used_chg_addr_resp is not None:
 		if used_chg_addr_resp is not None:
 			t.expect('reuse harms your privacy.*:.*',used_chg_addr_resp,regex=True)
 			t.expect('reuse harms your privacy.*:.*',used_chg_addr_resp,regex=True)
 
 
+		if auto_chg_al_id is not None:
+			t.expect(fr'Using .*{auto_chg_al_id}:\d+\D.* as.*address','y',regex=True)
+
 		pat = expect_pat
 		pat = expect_pat
 		for choice in menu + ['q']:
 		for choice in menu + ['q']:
 			t.expect(pat,choice,regex=True)
 			t.expect(pat,choice,regex=True)