ownership: search particular named wallet via BIP-21 wallet query param

This commit is contained in:
scgbckbone 2025-05-22 10:42:33 +02:00 committed by doc-hex
parent e8ba25fd04
commit 8b3603b15f
7 changed files with 165 additions and 26 deletions

View File

@ -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.

View File

@ -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])

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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=<wal> 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