From 045fdefd73e132da667219a1cb8b9a82d886e4ca Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Sat, 26 Nov 2022 18:55:57 +0000 Subject: [PATCH] mmgen-txcreate: automatic change address selection by address type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- mmgen/addr.py | 3 ++ mmgen/data/version | 2 +- mmgen/help.py | 11 +++++- mmgen/main_txcreate.py | 2 +- mmgen/main_txdo.py | 2 +- mmgen/tw/addresses.py | 77 ++++++++++++++++++++++++++++++++++-- mmgen/tx/new.py | 15 +++++-- test/test_py_d/ts_regtest.py | 33 ++++++++++++++-- test/test_py_d/ts_shared.py | 9 +++-- 9 files changed, 137 insertions(+), 17 deletions(-) diff --git a/mmgen/addr.py b/mmgen/addr.py index 965f3e65..83967983 100755 --- a/mmgen/addr.py +++ b/mmgen/addr.py @@ -79,6 +79,9 @@ def __new__(cls,proto,id_str,errmsg=None): 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') diff --git a/mmgen/data/version b/mmgen/data/version index b7a0f6ac..d83b7105 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -13.3.dev22 +13.3.dev23 diff --git a/mmgen/help.py b/mmgen/help.py index 422cb4b5..be93104e 100755 --- a/mmgen/help.py +++ b/mmgen/help.py @@ -161,15 +161,24 @@ def txcreate_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(): diff --git a/mmgen/main_txcreate.py b/mmgen/main_txcreate.py index f62a6b7e..aa47c29f 100755 --- a/mmgen/main_txcreate.py +++ b/mmgen/main_txcreate.py @@ -27,7 +27,7 @@ 'sets': [('yes', True, 'quiet', True)], 'text': { 'desc': f'Create a transaction with outputs to specified coin or {g.proj_name} addresses', - 'usage': '[opts] [ ...] [addr file ...]', + 'usage': '[opts] [ ...] [addr file ...]', 'options': """ -h, --help Print this help message --, --longhelp Print help message for long options (common options) diff --git a/mmgen/main_txdo.py b/mmgen/main_txdo.py index 665ec5be..c0184da2 100755 --- a/mmgen/main_txdo.py +++ b/mmgen/main_txdo.py @@ -28,7 +28,7 @@ 'sets': [('yes', True, 'quiet', True)], 'text': { 'desc': f'Create, sign and send an {g.proj_name} transaction', - 'usage': '[opts] [ ...] [addr file ...] [seed source ...]', + 'usage': '[opts] [ ...] [addr file ...] [seed source ...]', 'options': """ -h, --help Print this help message --, --longhelp Print help message for long options (common options) diff --git a/mmgen/tw/addresses.py b/mmgen/tw/addresses.py index 783df26e..0a94efa3 100755 --- a/mmgen/tw/addresses.py +++ b/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 @@ async def set_dates(self,addrs): 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 @@ def is_used(self,coinaddr): 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 @@ def get_start(bot,top): 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 @@ def get_start(bot,top): 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): diff --git a/mmgen/tx/new.py b/mmgen/tx/new.py index 90eca720..20257301 100755 --- a/mmgen/tx/new.py +++ b/mmgen/tx/new.py @@ -20,7 +20,9 @@ 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 @@ async def process_cmd_arg(self,arg_in,ad_f,ad_w): 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 @@ async def process_cmd_arg(self,arg_in,ad_f,ad_w): 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') diff --git a/test/test_py_d/ts_regtest.py b/test/test_py_d/ts_regtest.py index 6cd6eb8c..dffe0863 100755 --- a/test/test_py_d/ts_regtest.py +++ b/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 @@ def bob_split3(self): 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 @@ def bob_auto_chg3(self): 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 @@ def bob_auto_chg_bad3(self): 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) diff --git a/test/test_py_d/ts_shared.py b/test/test_py_d/ts_shared.py index 69230071..43df0683 100755 --- a/test/test_py_d/ts_shared.py +++ b/test/test_py_d/ts_shared.py @@ -48,7 +48,8 @@ def txcreate_ui_common(self,t, 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 @@ def txcreate_ui_common(self,t, 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']: