Browse Source

mmgen-txcreate: automatic change address selection by address type

This patch introduces an improvement to commit cbe749813 (automatic change
address selection)

Instead of supplying a Seed ID + address type, users may now specify the
address type alone.  If the tracking wallet contains more than one unused
address matching the user’s criteria, the user is prompted to choose a specific
change address.

As with plain auto change address selection, this feature is entirely opt-in.

Sample invocations:

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

    # new invocation:
    $ mmgen-txcreate -q 35N9FntsNwy98TmjFHyCpsBVDVUs5wDPfB,0.123 B

    # or, alternatively:
    $ mmgen-txcreate -q 35N9FntsNwy98TmjFHyCpsBVDVUs5wDPfB,0.123 bech32

Testing/demo:

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

    # run the auto chg addrtype test, displaying script output:
    $ test/test.py -SAe regtest:bob_auto_chg_addrtype1

    # 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
045fdefd

+ 3 - 0
mmgen/addr.py

@@ -79,6 +79,9 @@ class MMGenAddrType(str,Hilite,InitErrors,MMGenObject):
 	def get_names(cls):
 		return [v.name for v in cls.mmtypes.values()]
 
+def is_mmgen_addrtype(proto,id_str):
+	return get_obj( MMGenAddrType, proto=proto, id_str=id_str, silent=True, return_bool=True )
+
 class MMGenPasswordType(MMGenAddrType):
 	mmtypes = {
 		'P': ati('password', 'password', None, None, None, None, None, 'Password generated from MMGen seed')

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-13.3.dev22
+13.3.dev23

+ 10 - 1
mmgen/help.py

@@ -161,15 +161,24 @@ EXAMPLES:
 
     $ {g.prog_name} {sample_addr},0.123 01ABCDEF:{mmtype}
 
+  Same as above, but select the change address automatically by address type:
+
+    $ {g.prog_name} {sample_addr},0.123 {mmtype}
+
   Same as above, but reduce verbosity and specify fee of 20 satoshis
   per byte:
 
-    $ {g.prog_name} -q -f 20s {sample_addr},0.123 01ABCDEF:{mmtype}
+    $ {g.prog_name} -q -f 20s {sample_addr},0.123 {mmtype}
 
   Send entire balance of selected inputs minus fee to an external {proto.name}
   address:
 
     $ {g.prog_name} {sample_addr}
+
+  Send entire balance of selected inputs minus fee to first unused wallet
+  address of specified type:
+
+    $ {g.prog_name} {mmtype}
 """
 
 		def txcreate():

+ 1 - 1
mmgen/main_txcreate.py

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

+ 73 - 4
mmgen/tw/addresses.py

@@ -12,10 +12,10 @@
 tw.addresses: Tracking wallet listaddresses class for the MMGen suite
 """
 
-from ..util import suf
+from ..util import msg,suf,is_int
 from ..objmethods import MMGenObject
 from ..obj import MMGenListItem,ImmutableAttr,ListItemAttr,TwComment,NonNegativeInt
-from ..addr import CoinAddr,MMGenID
+from ..addr import CoinAddr,MMGenID,MMGenAddrType
 from ..color import red,green
 from .view import TwView
 from .shared import TwMMGenID
@@ -242,6 +242,39 @@ class TwAddresses(TwView):
 	def dump_fn_pfx(self):
 		return 'listaddresses' + (f'-minconf-{self.minconf}' if self.minconf else '')
 
+	@property
+	def sid_ranges(self):
+
+		def gen_sid_ranges():
+
+			from collections import namedtuple
+			sid_range = namedtuple('sid_range',['bot','top'])
+
+			sid_save = None
+			bot = None
+
+			for n,e in enumerate(self.data):
+				if e.twmmid.type == 'mmgen':
+					if e.twmmid.obj.sid != sid_save:
+						if sid_save:
+							yield (sid_save, sid_range(bot, n-1))
+						sid_save = e.twmmid.obj.sid
+						bot = n
+				else:
+					break
+			else:
+				n += 1
+
+			if sid_save:
+				yield (sid_save, sid_range(bot, n-1))
+
+		assert self.sort_key == 'twmmid'
+
+		if not hasattr(self,'_sid_ranges'):
+			self._sid_ranges = dict(gen_sid_ranges())
+
+		return self._sid_ranges
+
 	def is_used(self,coinaddr):
 		for e in self.data:
 			if e.addr == coinaddr:
@@ -249,7 +282,7 @@ class TwAddresses(TwView):
 		else: # addr not in tracking wallet
 			return None
 
-	def get_change_address(self,al_id):
+	def get_change_address(self,al_id,bot=None,top=None):
 		"""
 		Get lowest-indexed unused address in tracking wallet for requested AddrListID.
 		Return values on failure:
@@ -282,7 +315,9 @@ class TwAddresses(TwView):
 		assert self.sort_key == 'twmmid'
 
 		data = self.data
-		start = get_start( bot=0, top=len(data) - 1 )
+		start = get_start(
+			bot = 0             if bot is None else bot,
+			top = len(data) - 1 if top is None else top )
 
 		if start is not None:
 			for d in data[start:]:
@@ -292,6 +327,40 @@ class TwAddresses(TwView):
 				else:
 					return False
 
+	def get_change_address_by_addrtype(self,mmtype):
+		"""
+		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.
+
+		Return values on failure:
+		    None:  no addresses in wallet of requested address type
+		    False: no unused addresses in wallet of requested address type
+		"""
+
+		def choose_address(addrs):
+			from ..ui import line_input
+			prompt = '\nChoose a change address:\n\n{m}\n\nEnter a number> '.format(
+				m = '\n'.join(f'{n:3}) {a.twmmid.hl()}' for n,a in enumerate(addrs,1))
+			)
+			while True:
+				res = line_input(prompt)
+				if is_int(res) and 0 < int(res) <= len(addrs):
+					return addrs[int(res)-1]
+				msg(f'{res}: invalid entry')
+
+		assert isinstance(mmtype,MMGenAddrType)
+
+		res = [self.get_change_address( f'{sid}:{mmtype}', r.bot, r.top ) for sid,r in self.sid_ranges.items()]
+
+		if any(res):
+			res = list(filter(None,res))
+			if len(res) == 1:
+				return res[0]
+			else:
+				return choose_address(res)
+		elif False in res:
+			return False
+
 	class action(TwView.action):
 
 		def s_amt(self,parent):

+ 12 - 3
mmgen/tx/new.py

@@ -20,7 +20,9 @@ from ..obj import get_obj,MMGenList
 from ..util import msg,qmsg,fmt,die,suf,remove_dups,get_extension
 from ..addr import (
 	is_mmgen_id,
+	MMGenAddrType,
 	CoinAddr,
+	is_mmgen_addrtype,
 	is_coin_addr,
 	is_addrlist_id
 )
@@ -181,7 +183,7 @@ class New(Base):
 			coin_addr = mmaddr2coinaddr(arg,ad_w,ad_f,self.proto)
 		elif is_coin_addr(self.proto,arg):
 			coin_addr = CoinAddr(self.proto,arg)
-		elif is_addrlist_id(self.proto,arg):
+		elif is_mmgen_addrtype(self.proto,arg) or is_addrlist_id(self.proto,arg):
 			if self.proto.base_proto_coin != 'BTC':
 				die(2,f'Change addresses not supported for {self.proto.name} protocol')
 
@@ -190,14 +192,21 @@ class New(Base):
 			al.reverse = False
 			al.do_sort('twmmid')
 
-			res = al.get_change_address(arg)
+			if is_mmgen_addrtype(self.proto,arg):
+				arg = MMGenAddrType(self.proto,arg)
+				res = al.get_change_address_by_addrtype(arg)
+				desc = 'of address type'
+			else:
+				res = al.get_change_address(arg)
+				desc = 'from address list'
 
 			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(
+				die(2,'Tracking wallet contains no {t}addresses {d} {a!r}'.format(
 					t = ('unused ','')[res is None],
+					d = desc,
 					a = arg ))
 		else:
 			die(2,f'{arg_in}: invalid command-line argument')

+ 30 - 3
test/test_py_d/ts_regtest.py

@@ -355,9 +355,14 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 		('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_addrtype1', 'creating an automatic change address transaction by addrtype (C)'),
+		('bob_auto_chg_addrtype2', 'creating an automatic change address transaction by addrtype (B)'),
+		('bob_auto_chg_addrtype3', 'creating an automatic change address transaction by addrtype (S)'),
+		('bob_auto_chg_addrtype4', 'creating an automatic change address transaction by addrtype (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)'),
+		('bob_auto_chg_bad4', 'error handling for auto change address transaction by addrtype (no unused addresses)'),
 	),
 	}
 
@@ -1417,16 +1422,17 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 			outputs_cl = [sid+':C:5,0.0135', sid+':L:4'],
 			outputs_list = '1' )
 
-	def _bob_auto_chg(self,al_id,include_dest=True):
+	def _bob_auto_chg(self,arg,include_dest=True,choices=1):
 		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)
+			['-d',self.tr.trash_dir,'-B','--bob', arg] + dest)
 		return self.txcreate_ui_common(t,
 			menu            = [],
 			inputs          = '1',
 			interactive_fee = '20s',
-			auto_chg_al_id  = al_id )
+			auto_chg_arg    = arg,
+			auto_chg_choices = choices )
 
 	def bob_auto_chg1(self):
 		return self._bob_auto_chg(self._user_sid('bob') + ':C')
@@ -1444,6 +1450,22 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 	def bob_auto_chg4(self):
 		return self._bob_auto_chg( self._user_sid('bob') + ':C', include_dest=False )
 
+	def bob_auto_chg_addrtype1(self):
+		return self._bob_auto_chg( 'C', choices=3 )
+
+	def bob_auto_chg_addrtype2(self):
+		if not self.proto.cap('segwit'):
+			return 'skip'
+		return self._bob_auto_chg( 'B', choices=1 )
+
+	def bob_auto_chg_addrtype3(self):
+		if not self.proto.cap('segwit'):
+			return 'skip'
+		return self._bob_auto_chg( 'S', choices=1 )
+
+	def bob_auto_chg_addrtype4(self):
+		return self._bob_auto_chg( 'C', choices=3, include_dest=False )
+
 	def _bob_auto_chg_bad(self,al_id,expect):
 		t = self.spawn(
 			'mmgen-txcreate',
@@ -1467,6 +1489,11 @@ class TestSuiteRegtest(TestSuiteBase,TestSuiteShared):
 			self._user_sid('bob') + ':L',
 			'contains no unused addresses from address list' )
 
+	def bob_auto_chg_bad4(self):
+		return self._bob_auto_chg_bad(
+			'L',
+			'contains no unused addresses of address type' )
+
 	def stop(self):
 		if opt.no_daemon_stop:
 			self.spawn('',msg_only=True)

+ 6 - 3
test/test_py_d/ts_shared.py

@@ -48,7 +48,8 @@ class TestSuiteShared(object):
 			save              = True,
 			tweaks            = [],
 			used_chg_addr_resp = None,
-			auto_chg_al_id    = None ):
+			auto_chg_arg      = None,
+			auto_chg_choices  = 1 ):
 
 		txdo = (caller or self.test_name)[:4] == 'txdo'
 
@@ -59,8 +60,10 @@ class TestSuiteShared(object):
 		if used_chg_addr_resp is not None:
 			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)
+		if auto_chg_arg is not None:
+			if auto_chg_choices > 1:
+				t.expect('Enter a number> ',f'{auto_chg_choices}\n')
+			t.expect(fr'Using .*{auto_chg_arg}:\d+\D.* as.*address','y',regex=True)
 
 		pat = expect_pat
 		for choice in menu + ['q']: