QRs in txn output explorer

This commit is contained in:
scgbckbone 2025-04-20 11:34:15 +02:00
parent 3075cd07b2
commit 6507856861
7 changed files with 121 additions and 42 deletions

View File

@ -7,6 +7,8 @@ This lists the new changes that have not yet been published in a normal release.
- Bugfix: If all change outputs have `nValue=0` they're not shown in UX
- Bugfix: Disallow negative input/output amounts in PSBT
- Enhancement: Add warning for zero value outputs if not OP_RETURNs
- Enhancement: Show QR codes of output addresses in Txn output explorer. Output explorer is offered for txns of all sizes.
# Mk4 Specific Changes

View File

@ -296,7 +296,7 @@ class ApproveTransaction(UserAuthorizedAction):
try:
dest = self.chain.render_address(o.scriptPubKey)
return '%s\n - to address -\n%s\n' % (val, show_single_address(dest))
return '%s\n - to address -\n%s\n' % (val, show_single_address(dest)), dest
except ValueError:
pass
@ -306,12 +306,13 @@ class ApproveTransaction(UserAuthorizedAction):
data_hex, data_ascii = data
to_ret = '%s\n - OP_RETURN -\n%s' % (val, data_hex)
if data_ascii:
return to_ret + " (ascii: %s)\n" % data_ascii
return to_ret + "\n"
to_ret += " (ascii: %s)" % data_ascii
return to_ret + "\n", data_hex
# Handle future things better: allow them to happen at least.
dest = B2A(o.scriptPubKey)
return '%s\n - to script -\n%s\n' % (val, dest)
return '%s\n - to script -\n%s\n' % (val, dest), dest
async def interact(self):
# Prompt user w/ details and get approval
@ -420,7 +421,7 @@ class ApproveTransaction(UserAuthorizedAction):
))
# outputs + change story created here
needs_txn_explorer = self.output_summary_text(msg)
self.output_summary_text(msg)
gc.collect()
if self.psbt.ux_notes:
@ -447,11 +448,9 @@ class ApproveTransaction(UserAuthorizedAction):
dis.progress_bar_show(1) # finish the Validating...
if not hsm_active:
esc = ""
msg.write("Press %s to approve and sign transaction." % OK)
if needs_txn_explorer:
esc += "2"
msg.write(" Press (2) to explore txn.")
esc = "2"
msg.write("Press %s to approve and sign transaction."
" Press (2) to explore txn outputs." % OK)
if (self.input_method == "sd") and CardSlot.both_inserted():
esc += "b"
msg.write(" (B) to write to lower SD slot.")
@ -542,11 +541,16 @@ class ApproveTransaction(UserAuthorizedAction):
dis.fullscreen('Wait...')
rv = ""
end = min(offset + count, self.psbt.num_outputs)
for idx, out in self.psbt.output_iter(offset, end):
addrs = []
change = []
for i, (idx, out) in enumerate(self.psbt.output_iter(offset, end)):
outp = self.psbt.outputs[idx]
item = "Output %d%s:\n\n" % (idx, " (change)" if outp.is_change else "")
item += self.render_output(out)
msg, addr_or_script = self.render_output(out)
item += msg
addrs.append(addr_or_script)
if outp.is_change:
change.append(i)
item += "\n"
rv += item
dis.progress_sofar(idx-offset+1, count)
@ -554,18 +558,28 @@ class ApproveTransaction(UserAuthorizedAction):
rv += 'Press RIGHT to see next group'
if offset:
rv += ', LEFT to go back'
if not version.has_qwerty:
# Q has hint key
rv += ", (4) to show QR code"
rv += ('. %s to quit.' % X)
return rv
return rv, addrs, change, end
start = 0
n = 10
msg = make_msg(start, n)
msg, addrs, change, end = make_msg(start, n)
while True:
ch = await ux_show_story(msg, escape='79'+KEY_RIGHT+KEY_LEFT)
ch = await ux_show_story(msg, title="%d-%d" % (start, end-1),
escape='479'+KEY_RIGHT+KEY_LEFT+KEY_QR,
hint_icons=KEY_QR)
if ch == 'x':
del msg
return
elif ch in "4"+KEY_QR:
from ux import show_qr_codes
await show_qr_codes(addrs, False, start, is_addrs=True, change_idxs=change)
continue
elif (ch in KEY_LEFT+"7"):
if (start - n) < 0:
continue
@ -582,7 +596,7 @@ class ApproveTransaction(UserAuthorizedAction):
# nothing changed - do not recalc msg
continue
msg = make_msg(start, n)
msg, addrs, change, end = make_msg(start, n)
async def save_visualization(self, msg, sign_text=False):
# write story text out, maybe signing it as we go
@ -624,7 +638,6 @@ class ApproveTransaction(UserAuthorizedAction):
MAX_VISIBLE_OUTPUTS = const(10)
MAX_VISIBLE_CHANGE = const(20)
needs_txn_explorer = False
largest_outs = []
largest_change = []
total_change = 0
@ -643,7 +656,8 @@ class ApproveTransaction(UserAuthorizedAction):
else:
if len(largest_outs) < MAX_VISIBLE_OUTPUTS:
largest_outs.append((tx_out.nValue, self.render_output(tx_out)))
rendered, _ = self.render_output(tx_out)
largest_outs.append((tx_out.nValue, rendered))
if len(largest_outs) == MAX_VISIBLE_OUTPUTS:
# descending sort from the biggest value to lowest (sort on out.nValue)
largest_outs = sorted(largest_outs, key=lambda x: x[0], reverse=True)
@ -663,7 +677,8 @@ class ApproveTransaction(UserAuthorizedAction):
if outp.is_change:
ret = (here, self.chain.render_address(tx_out.scriptPubKey))
else:
ret = (here, self.render_output(tx_out))
rendered, _ = self.render_output(tx_out)
ret = (here, rendered)
largest.insert(keep, ret)
# foreign outputs (soon to be other people's coins)
@ -675,7 +690,6 @@ class ApproveTransaction(UserAuthorizedAction):
left = self.psbt.num_outputs - len(largest_outs) - self.psbt.num_change_outputs
if left > 0:
needs_txn_explorer = True
msg.write('.. plus %d smaller output(s), not shown here, which total: ' % left)
# calculate left over value
@ -700,14 +714,9 @@ class ApproveTransaction(UserAuthorizedAction):
left_c = self.psbt.num_change_outputs - len(largest_change)
if left_c:
needs_txn_explorer = True
msg.write('.. plus %d smaller change output(s), not shown here, which total: ' % left_c)
msg.write('%s %s\n\n' % self.chain.render_value(total_change - visible_change_sum))
# if we didn't already show all outputs, then give user a chance to
# view them individually
return needs_txn_explorer
def sign_transaction(psbt_len, flags=0x0, psbt_sha=None):
# transaction (binary) loaded into PSRAM already, checksum checked

View File

@ -335,7 +335,7 @@ class Display:
return
def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert,
is_addr=False, force_msg=False):
is_addr=False, force_msg=False, is_change=False):
# 'sidebar' is a pre-formated obj to show to right of QR -- oled life
# - 'msg' will appear to right if very short, else under in tiny
# - ignores "is_addr" because exactly zero space to do anything special
@ -384,11 +384,14 @@ class Display:
if not sidebar and not msg:
pass
elif not sidebar and len(msg) > (5*7):
elif not sidebar and ((len(msg) > (5*7)) or is_change):
# use FontTiny and word wrap (will just split if no spaces)
# native segwit addresses and taproot
# if is_change=True also p2pkh and p2sh fall into this category as space is needed for "CHANGE"
x = bw + lm + 4
ww = ((128 - x)//4) - 1 # char width avail
y = 1
parts = list(word_wrap(msg, ww))
if len(parts) > 8:
parts = parts[:8]
@ -399,9 +402,13 @@ class Display:
for line in parts:
self.text(x, y, line, FontTiny)
y += 8
if is_addr and is_change:
self.text(x+4, y+8, "CHANGE", FontTiny)
else:
# hand-positioned for known cases
# - sidebar = (text, #of char per line)
# p2pkh and p2sh addresses (if is_change=False)
x, y = 73, (0 if is_alnum else 2)
dy = 10 if is_alnum else 12
sidebar, ll = sidebar if sidebar else (msg, 7)

View File

@ -657,7 +657,7 @@ class Display:
return prev_x
def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert, partial_bar=None,
is_addr=False, force_msg=False):
is_addr=False, force_msg=False, is_change=False):
# Show a QR code on screen w/ some text under it
# - invert not supported on Q1
# - sidebar not supported here (see users.py)
@ -774,6 +774,10 @@ class Display:
else:
self.text(-1, 0, idx_hint)
if is_addr and is_change:
for i, c in enumerate("CHANGE"):
self.text(0, i, c)
# pass a max brightness flag here, which will be cleared after next show
self.show(max_bright=True)
else:

View File

@ -18,7 +18,8 @@ class QRDisplaySingle(UserInteraction):
# Show a single QR code for (typically) a list of addresses, or a single value.
def __init__(self, addrs, is_alnum, start_n=0, sidebar=None, msg=None,
is_addrs=False, force_msg=False, allow_nfc=True, is_secret=False):
is_addrs=False, force_msg=False, allow_nfc=True, is_secret=False,
change_idxs=None):
self.is_alnum = is_alnum
self.idx = 0 # start with first address
self.invert = False # looks better, but neither mode is ideal
@ -32,6 +33,7 @@ class QRDisplaySingle(UserInteraction):
self.allow_nfc = allow_nfc
# only used for NFC sharing secret material - full chip wipe if is_secret=True
self.is_secret = is_secret
self.change_idxs = change_idxs or []
def calc_qr(self, msg):
# Version 2 would be nice, but can't hold what we need, even at min error correction,
@ -62,6 +64,11 @@ class QRDisplaySingle(UserInteraction):
# numbers, letters, etc.
return str(self.start_n + self.idx) if len(self.addrs) > 1 else None
def is_change(self):
if self.idx in self.change_idxs:
return True
return False
def redraw(self):
# Redraw screen.
from glob import dis
@ -92,7 +99,8 @@ class QRDisplaySingle(UserInteraction):
dis.draw_qr_display(self.qr_data, msg, self.is_alnum,
self.sidebar, self.idx_hint(), self.invert,
is_addr=self.is_addrs, force_msg=self.force_msg)
is_addr=self.is_addrs, force_msg=self.force_msg,
is_change=self.is_change())
async def interact_bare(self):
from glob import NFC, dis

View File

@ -603,9 +603,15 @@ def verify_qr_address(cap_screen_qr, cap_screen, is_q1):
# plus text version of address, if any, is right.
from ckcc_protocol.constants import AFC_BECH32
def doit(addr_fmt, expect_addr=None):
def doit(addr_fmt, expect_addr=None, is_change=None):
qr = cap_screen_qr().decode('ascii')
if isinstance(addr_fmt, str):
try:
addr_fmt = unmap_addr_fmt[addr_fmt]
except KeyError:
addr_fmt = msg_sign_unmap_addr_fmt[addr_fmt]
if (addr_fmt & AFC_BECH32) or (addr_fmt & AFC_BECH32M):
qr = qr.lower()
@ -616,9 +622,21 @@ def verify_qr_address(cap_screen_qr, cap_screen, is_q1):
# - insists on some spaces
full = cap_screen()
if is_q1:
txt = ''.join(full.split()[2:]).replace('~', '')
if is_change:
for c, line in zip("CHANGE", full.split('\n')):
assert line.startswith(c)
elif is_change is False:
for c, line in zip("CHANGE", full.split('\n')):
assert not line.startswith(c)
txt = ''.join(l for l in full.split() if len(l)>4).replace('~', '')
else:
txt = ''.join(full.split())
if is_change:
assert "CHANGE" in full
elif is_change is False:
assert "CHANGE" not in full
txt = ''.join(full.split()).replace('CHANGE', '')
if txt:
assert txt == qr
@ -2413,7 +2431,7 @@ def goto_address_explorer(goto_home, pick_menu_item, need_keypress,
return doit
@pytest.fixture
def txout_explorer(cap_story, press_cancel, need_keypress, is_q1):
def txout_explorer(cap_story, press_cancel, need_keypress, is_q1, verify_qr_address):
def doit(data, chain="XTN"):
time.sleep(.1)
title, story = cap_story()
@ -2431,11 +2449,26 @@ def txout_explorer(cap_story, press_cancel, need_keypress, is_q1):
assert len(ss) == (len(d) * 2) + 1
assert "Press RIGHT to see next group" in ss[-1]
if i:
assert " LEFT to go back." in ss[-1]
assert " LEFT to go back" in ss[-1]
else:
assert "LEFT" not in ss[-1]
for i, (sa, sb, (af, amount, change)) in enumerate(zip(ss[:-1:2], ss[1::2], d), start=i):
if not is_q1:
assert "(4) to show QR code" in ss[-1]
# collect QR codes first
need_keypress(KEY_QR if is_q1 else "4")
qr_addr_list = []
for af, amount, change in d:
qr = verify_qr_address(af, is_change=bool(change))
qr_addr_list.append(qr)
need_keypress(KEY_RIGHT if is_q1 else "9")
time.sleep(.5)
press_cancel() # QR code on screen - exit
start = i
for i, (sa, sb, (af, amount, change)) in enumerate(zip(ss[:-1:2], ss[1::2], d), start=start):
if change:
assert f"Output {i} (change):" == sa
else:
@ -2443,6 +2476,9 @@ def txout_explorer(cap_story, press_cancel, need_keypress, is_q1):
txt_amount, _, addr = sb.split("\n")
addr = addr_from_display_format(addr)
# verify QR matches what is on screen
assert addr == qr_addr_list[i-start]
assert txt_amount == f'{amount / 100000000:.8f} {chain}'
if af == "p2pkh":
if chain == "BTC":

View File

@ -3062,7 +3062,19 @@ def test_txout_explorer_op_return(finalize, data, fake_txn, start_sign, cap_stor
time.sleep(.1)
_, story = cap_story()
ss = story.split("\n\n")
for i, (sa, sb, (amount, d)) in enumerate(zip(ss[:-1:2], ss[1::2], data), start=20):
# collect QR codes first
need_keypress(KEY_QR if is_q1 else "4")
qr_list = []
for _ in range(len(data)):
qr = cap_screen_qr().decode('ascii')
qr_list.append(qr)
need_keypress(KEY_RIGHT if is_q1 else "9")
time.sleep(.5)
press_cancel() # QR code on screen - exit
for i, (sa, sb, (amount, data)) in enumerate(zip(ss[:-1:2], ss[1::2], data), start=20):
assert f"Output {i}:" == sa
try:
val, name, dd = sb.split("\n")
@ -3073,11 +3085,12 @@ def test_txout_explorer_op_return(finalize, data, fake_txn, start_sign, cap_stor
assert f'{amount / 100000000:.8f} XTN' == val
if dd:
hex_str, ascii_str = dd.split(" ", 1)
assert f"(ascii: {d.decode()})" == ascii_str
assert d.hex() == hex_str
assert hex_str == qr_list[i-20]
assert f"(ascii: {data.decode()})" == ascii_str
assert data.hex() == hex_str
else:
assert d.hex()[:200] == dd0
assert d.hex()[-200:] == dd1
assert data.hex()[:200] == dd0
assert data.hex()[-200:] == dd1
press_cancel() # exit txn out explorer
end_sign(finalize=finalize)