From 8b3603b15ffe9877b60f61e0c5e7900a4e3120a2 Mon Sep 17 00:00:00 2001 From: scgbckbone Date: Thu, 22 May 2025 10:42:33 +0200 Subject: [PATCH] ownership: search particular named wallet via BIP-21 wallet query param --- releases/Next-ChangeLog.md | 5 +- shared/multisig.py | 6 +- shared/nfc.py | 8 +-- shared/ownership.py | 22 +++++-- shared/ux_q1.py | 2 +- testing/test_decoders.py | 34 ++++++----- testing/test_ownership.py | 114 +++++++++++++++++++++++++++++++++++++ 7 files changed, 165 insertions(+), 26 deletions(-) diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md index 36bdade3..e70390e6 100644 --- a/releases/Next-ChangeLog.md +++ b/releases/Next-ChangeLog.md @@ -22,7 +22,10 @@ Spending policies for "Single Signers" adds new spending policy options: - Enhancement: Show QR codes of output addresses in transaction output explorer. Explorer is now offered for transactions of all sizes, not just complex ones. - Enhancement: Added file rename, when listing contents of SD card. -- Enhancement: Added ability to restore Coldcard backup via USB (requires updated ckcc, other) +- Enhancement: Added ability to restore Coldcard backup via USB (TODO version of updated ckcc) +- Enhancement: Address ownership allows to specify particular multisig wallet in which to search. + `wallet` query parameter is provided via [BIP-21](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki) + example: `tb1q4d67p7stxml3kdudrgkg5mgaxsrgzcqzjrrj4gg62nxtvnsnvqjsxjkej0?wallet=my_wal` - Bugfix: If all change outputs have `nValue=0`, they were not shown in UX. - Bugfix: Disallow negative input/output amounts in PSBT. - Bugfix: Fix filesystem initialization after Wife LFS or Destroy Seed. diff --git a/shared/multisig.py b/shared/multisig.py index b17d0442..83993d41 100644 --- a/shared/multisig.py +++ b/shared/multisig.py @@ -245,10 +245,11 @@ class MultisigWallet(WalletABC): return rv @classmethod - def iter_wallets(cls, M=None, N=None, not_idx=None, addr_fmts=None): + def iter_wallets(cls, M=None, N=None, not_idx=None, addr_fmts=None, name=None): # yield MS wallets we know about, that match at least right M,N if known. # - this is only place we should be searching this list, please!! # addr_fmts: list of address formats we're intersted in + # name: string ms wallet name lst = settings.get('multisig', []) for idx, rec in enumerate(lst): @@ -256,6 +257,9 @@ class MultisigWallet(WalletABC): # ignore one by index continue + if name and (rec[0] != name): + continue + if M or N: # peek at M/N has_m, has_n = tuple(rec[1]) diff --git a/shared/nfc.py b/shared/nfc.py index 2a48c2c1..b01d4bc9 100644 --- a/shared/nfc.py +++ b/shared/nfc.py @@ -736,7 +736,7 @@ class NFCHandler: m = m.decode() what, vals = decode_bip21_text(m) if what == 'addr': - return vals[1] + return vals winner = await self._nfc_reader(f, 'Unable to find address from NFC data.') @@ -744,10 +744,10 @@ class NFCHandler: async def verify_address_nfc(self): # Get an address or complete bip-21 url even and search it... slow. - winner = await self.read_address() - if winner: + _, addr, args = await self.read_address() + if addr: from ownership import OWNERSHIP - await OWNERSHIP.search_ux(winner) + await OWNERSHIP.search_ux(addr, args) async def read_extended_private_key(self): f = lambda x: x.decode().strip() if b"prv" in x else None diff --git a/shared/ownership.py b/shared/ownership.py index ef680a5a..eb3434dd 100644 --- a/shared/ownership.py +++ b/shared/ownership.py @@ -211,19 +211,31 @@ class OwnershipCache: return doit @classmethod - def filter(cls, addr): + def filter(cls, addr, args): # Filter possible candidates! # - if you start w/ testnet, we'll follow that from multisig import MultisigWallet from public_constants import AFC_SCRIPT, AF_P2WPKH_P2SH, AF_P2SH, AF_P2WSH_P2SH ch = chains.current_chain() + args = args or {} addr_fmt = ch.possible_address_fmt(addr) if not addr_fmt: # might be valid address over on testnet vs mainnet raise UnknownAddressExplained('That address is not valid on ' + ch.name) + # user has specified specific (named) wallet + named_wal = args.get("wallet", None) + if named_wal: + # quick search without deserialization + res = list(MultisigWallet.iter_wallets(name=named_wal)) + if not res: + raise UnknownAddressExplained("Wallet '%s' not defined." % named_wal) + + # only return desired named wallet, no other wallets are searched + return res + possibles = [] if addr_fmt & AFC_SCRIPT: # multisig or script at least... must exist already @@ -291,10 +303,10 @@ class OwnershipCache: return None, None @classmethod - def search(cls, addr): + def search(cls, addr, args=None): from glob import dis - matches = OWNERSHIP.filter(addr) + matches = OWNERSHIP.filter(addr, args) # build cache files for both external & internal chain cachefs = [] @@ -332,7 +344,7 @@ class OwnershipCache: ' without finding a match.' % (c, len(matches))) @classmethod - async def search_ux(cls, addr): + async def search_ux(cls, addr, args): # Provide a simple UX. Called functions do fullscreen, progress bar stuff. from ux import ux_show_story, show_qr_code from charcodes import KEY_QR @@ -340,7 +352,7 @@ class OwnershipCache: from public_constants import AFC_BECH32, AFC_BECH32M try: - _, wallet, subpath = cls.search(addr) + _, wallet, subpath = cls.search(addr, args) is_ms = isinstance(wallet, MultisigWallet) sp = wallet.render_path(*subpath) diff --git a/shared/ux_q1.py b/shared/ux_q1.py index bd7a488b..0f5255e0 100644 --- a/shared/ux_q1.py +++ b/shared/ux_q1.py @@ -1128,7 +1128,7 @@ async def ux_visualize_bip21(proto, addr, args): if ch == '1': from ownership import OWNERSHIP - await OWNERSHIP.search_ux(addr) + await OWNERSHIP.search_ux(addr, args) async def ux_visualize_wif(wif_str, kp, compressed, testnet): # TODO: remove until we support signing w/ WIF keys IMHO diff --git a/testing/test_decoders.py b/testing/test_decoders.py index 50aa4225..1eb16bfb 100644 --- a/testing/test_decoders.py +++ b/testing/test_decoders.py @@ -54,40 +54,46 @@ def test_detector_bin(fname, expect, encoding, try_decode): @pytest.mark.parametrize('url', [ 'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R', +'BCRT1QUPYD58NDSH7LUT0ET0VTRQ432JVU9JTDX8FGYV', 'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R?label=Luke-Jr', 'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R?amount=20.3&label=Luke-Jr', -'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R?amount=50&label=Luke-Jr&message=Donation%20for%20project%20xyz', +'BCRT1QUPYD58NDSH7LUT0ET0VTRQ432JVU9JTDX8FGYV?amount=50&label=Luke-Jr&message=Donation%20for%20project%20xyz', 'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R?req-somethingyoudontunderstand=50&req-somethingelseyoudontget=999', 'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R?somethingyoudontunderstand=50&somethingelseyoudontget=999', +'tb1q4d67p7stxml3kdudrgkg5mgaxsrgzcqzjrrj4gg62nxtvnsnvqjsxjkej0?wallet=my_wal', +'tb1q4d67p7stxml3kdudrgkg5mgaxsrgzcqzjrrj4gg62nxtvnsnvqjsxjkej0?wallet=my wal', +'tb1q4d67p7stxml3kdudrgkg5mgaxsrgzcqzjrrj4gg62nxtvnsnvqjsxjkej0?wallet=my%20wal', +'tb1q4d67p7stxml3kdudrgkg5mgaxsrgzcqzjrrj4gg62nxtvnsnvqjsxjkej0?wallet=my:wal', +'tb1q4d67p7stxml3kdudrgkg5mgaxsrgzcqzjrrj4gg62nxtvnsnvqjsxjkej0?wallet=my-wal', 'mtHSVByP9EYZmB26jASDdPVm19gvpecb5R?label=total%20due:%20500', ]) @pytest.mark.parametrize('bip21', range(2)) -@pytest.mark.parametrize('addr_fmt', range(2)) -def test_detector_url(url, bip21, addr_fmt, try_decode): - a1, a2 = ('mtHSVByP9EYZmB26jASDdPVm19gvpecb5R', - 'BCRT1QUPYD58NDSH7LUT0ET0VTRQ432JVU9JTDX8FGYV') +def test_detector_url(url, bip21, try_decode): + target = url.split('?', 1)[0] + if target[:2].lower() in ["tb", "bc"]: + target = target.lower() if bip21: - url = 'bitcoin:' + url - - if addr_fmt: - url = url.replace(a1, a2) - expect_addr = a2.lower() - else: - expect_addr = a1 + url = f"bitcoin:{url}" ft, vals = try_decode(url) assert ft == 'addr' proto, addr, args = vals - assert addr == expect_addr + assert addr == target assert proto == ('bitcoin' if bip21 else None) p = urlparse(url) assert (p.path == addr) or (p.path.lower() == addr.lower()) + # below nest values to the list xargs = parse_qs(p.query) if args: - assert xargs.keys() == args.keys() + assert len(xargs) == len(args) + for k, v in args.items(): + val = xargs[k] + assert len(val) == 1 + # unwrap value + assert val[0] == v else: assert not xargs diff --git a/testing/test_ownership.py b/testing/test_ownership.py index f7150d7f..15a25a92 100644 --- a/testing/test_ownership.py +++ b/testing/test_ownership.py @@ -571,4 +571,118 @@ def test_20_more_build_after_match(sim_exec, import_ms_wallet, clear_ms, wipe_ca assert "1 wallet(s)" in l assert 'without finding a match' in l + +def test_named_wallet_search_fail(load_shared_mod, goto_home, pick_menu_item, nfc_write, + cap_story): + addr = fake_address(AF_P2WSH, True) + addr = f"{addr}?wallet=unknown" + cc_ndef = load_shared_mod('cc_ndef', '../shared/ndef.py') + n = cc_ndef.ndefMaker() + n.add_text(addr) + ccfile = n.bytes() + + # run simulator w/ --set nfc=1 --eff + goto_home() + pick_menu_item('Advanced/Tools') + pick_menu_item('NFC Tools') + pick_menu_item('Verify Address') + open('debug/nfc-addr.ndef', 'wb').write(ccfile) + nfc_write(ccfile) + + time.sleep(1) + title, story = cap_story() + assert addr.split("?", 1)[0] == addr_from_display_format(story.split("\n\n")[0]) + assert "Wallet 'unknown' not defined." in story + + +@pytest.mark.parametrize('valid', [True, False]) +@pytest.mark.parametrize('method', ["qr", "nfc"]) +def test_named_wallet_search(valid, method, clear_ms, import_ms_wallet, is_q1, + load_shared_mod, goto_home, pick_menu_item, scan_a_qr, + cap_story, need_keypress, nfc_write, use_testnet, + wipe_cache, settings_set): + + from test_multisig import make_ms_address, HARD + + if method == "qr" and (not is_q1): + raise pytest.skip("QR Mk") + + wipe_cache() # very different codepaths + settings_set('accts', []) + use_testnet() + M, N = 2, 3 + clear_ms() + ms_data = {} + # all ms wallets have same address format, different M/N + for i in range(3): + idx = 5 + if i == 2: + idx = 763 + name = f'msnw{i}' + keys = import_ms_wallet(M+i, N+i, AF_P2WSH, name=name, accept=True) + # last address + addr, scriptPubKey, script, details = make_ms_address( + M+i, keys, is_change=0, idx=idx, addr_fmt=AF_P2WSH, + testnet=True, path_mapper=lambda cosigner: [HARD(45), 0, idx] + ) + ms_data[name] = (addr, scriptPubKey, script, keys) + + if valid: + # msnw2 -> last added wallet + addr, *_ = ms_data["msnw2"] + else: + # will fail, even tho address is present in different wallet + # with wallet= only specified wallet is searched + addr, *_ = ms_data["msnw0"] + + # will only search specified wallet + addr = f"{addr}?wallet=msnw2" + + if method == 'qr': + goto_home() + pick_menu_item('Scan Any QR Code') + scan_a_qr(addr) + time.sleep(1) + + title, story = cap_story() + + assert addr.split("?", 1)[0] == addr_from_display_format(story.split("\n\n")[0]) + assert '(1) to verify ownership' in story + need_keypress('1') + + elif method == 'nfc': + cc_ndef = load_shared_mod('cc_ndef', '../shared/ndef.py') + n = cc_ndef.ndefMaker() + n.add_text(addr) + ccfile = n.bytes() + + # run simulator w/ --set nfc=1 --eff + goto_home() + pick_menu_item('Advanced/Tools') + pick_menu_item('NFC Tools') + pick_menu_item('Verify Address') + open('debug/nfc-addr.ndef', 'wb').write(ccfile) + nfc_write(ccfile) + # press_select() + + else: + raise ValueError(method) + + time.sleep(1) + title, story = cap_story() + assert addr.split("?", 1)[0] == addr_from_display_format(story.split("\n\n")[0]) + + if valid: + assert title == ('Verified Address' if is_q1 else "Verified!") + assert 'Found in wallet' in story + assert 'Derivation path' in story + + assert "msnw2" in story + + else: + assert title == 'Unknown Address' + assert 'Searched 1528' in story # max + assert "1 wallet(s)" in story + assert 'without finding a match' in story + # EOF