Skip to content

Commit

Permalink
mmgen-txcreate: automatic change address selection by address type
Browse files Browse the repository at this point in the history
This patch introduces an improvement to commit cbe7498 (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
  • Loading branch information
mmgen committed Nov 26, 2022
1 parent ba291d4 commit 045fdef
Show file tree
Hide file tree
Showing 9 changed files with 137 additions and 17 deletions.
3 changes: 3 additions & 0 deletions mmgen/addr.py
Expand Up @@ -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')
Expand Down
2 changes: 1 addition & 1 deletion mmgen/data/version
@@ -1 +1 @@
13.3.dev22
13.3.dev23
11 changes: 10 additions & 1 deletion mmgen/help.py
Expand Up @@ -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():
Expand Down
2 changes: 1 addition & 1 deletion mmgen/main_txcreate.py
Expand Up @@ -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,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)
Expand Down
2 changes: 1 addition & 1 deletion mmgen/main_txdo.py
Expand Up @@ -28,7 +28,7 @@
'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)
Expand Down
77 changes: 73 additions & 4 deletions mmgen/tw/addresses.py
Expand Up @@ -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
Expand Down Expand Up @@ -242,14 +242,47 @@ 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:
return bool(e.recvd)
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:
Expand Down Expand Up @@ -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:]:
Expand All @@ -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):
Expand Down
15 changes: 12 additions & 3 deletions mmgen/tx/new.py
Expand Up @@ -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
)
Expand Down Expand Up @@ -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')

Expand All @@ -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')
Expand Down
33 changes: 30 additions & 3 deletions test/test_py_d/ts_regtest.py
Expand Up @@ -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)'),
),
}

Expand Down Expand Up @@ -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')
Expand All @@ -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',
Expand All @@ -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)
Expand Down
9 changes: 6 additions & 3 deletions test/test_py_d/ts_shared.py
Expand Up @@ -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'

Expand All @@ -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']:
Expand Down

0 comments on commit 045fdef

Please sign in to comment.