Merge pull request #262 from kdmukai/message_signing

[New Feature] QR-based message signing
This commit is contained in:
Nick Klockenga
2023-08-11 21:35:22 -04:00
committed by GitHub
19 changed files with 1181 additions and 410 deletions
+3
View File
@@ -114,6 +114,8 @@ class Controller(Singleton):
image_entropy_final_image: Image = None
address_explorer_data: dict = None
sign_message_data: dict = None
# TODO: end refactor section
# Destination placeholder for when we need to jump out to a side flow but intend to
@@ -123,6 +125,7 @@ class Controller(Singleton):
FLOW__VERIFY_MULTISIG_ADDR = "multisig_addr"
FLOW__VERIFY_SINGLESIG_ADDR = "singlesig_addr"
FLOW__ADDRESS_EXPLORER = "address_explorer"
FLOW__SIGN_MESSAGE = "sign_message"
resume_main_flow: str = None
back_stack: BackStack = None
+245 -137
View File
@@ -1,6 +1,8 @@
import math
import os
import pathlib
import re
from time import time
from dataclasses import dataclass
from decimal import Decimal
@@ -275,7 +277,7 @@ class TextArea(BaseComponent):
height: int = None # None = special case: autosize to min height
screen_x: int = 0
screen_y: int = 0
min_text_x: int = None
min_text_x: int = 0 # Text can not start at x any less than this
background_color: str = GUIConstants.BACKGROUND_COLOR
font_name: str = GUIConstants.BODY_FONT_NAME
font_size: int = GUIConstants.BODY_FONT_SIZE
@@ -292,129 +294,74 @@ class TextArea(BaseComponent):
if not self.width:
self.width = self.canvas_width
if self.screen_x + self.width > self.canvas_width:
self.width = self.canvas_width - self.screen_x
if self.font_size < 18 and (not self.supersampling_factor or self.supersampling_factor == 1):
self.supersampling_factor = 2
self.font = Fonts.get_font(self.font_name, int(self.supersampling_factor * self.font_size))
self.supersampled_width = self.supersampling_factor * self.width
if self.height is None:
self.supersampled_height = None
else:
self.supersampled_height = self.supersampling_factor * self.height
self.line_spacing = GUIConstants.BODY_LINE_SPACING
# We have to figure out if and where to make line breaks in the text so that it
# fits in its bounding rect (plus accounting for edge padding) using its given
# font.
# Measure from left baseline ("ls")
# TODO: getbbox() seems to ignore "\n" so isn't properly factored into height
# calcs and yields incorrect full_text_width. For now must specify self.height to
# render properly. Centering will be wrong.
(left, top, full_text_width, bottom) = self.font.getbbox(self.text, anchor="ls")
self.text_font_height = -1 * top
self.bbox_height = self.text_font_height + bottom
# Do initial calcs without worrying about supersampling.
self.text_lines = reflow_text_for_width(
text=self.text,
width=self.width - 2*self.edge_padding,
font_name=self.font_name,
font_size=self.font_size,
allow_text_overflow=self.allow_text_overflow,
)
# Stores each line of text and its rendering starting x-coord
self.text_lines = []
self.text_width = 0
def _add_text_line(text, width):
if self.is_text_centered:
text_x = int((self.supersampled_width - width) / 2)
else:
text_x = self.supersampling_factor * self.edge_padding
if self.min_text_x is not None and text_x < self.min_text_x:
text_x = self.min_text_x
self.text_lines.append({"text": text, "text_x": text_x})
# Calculate the actual font height from the "baseline" anchor ("_s")
font = Fonts.get_font(self.font_name, self.font_size)
if width > self.text_width:
self.text_width = width
# Note: from the baseline anchor, `top` is a negative number while `bottom`
# conveys the pixels used below the baseline (e.g. in "py").
(left, top, right, bottom) = font.getbbox(self.text + "A", anchor="ls") # For consistency, ensure we have a full-height character above baseline
self.text_height_above_baseline = -1 * top
self.text_height_below_baseline = bottom
if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor):
# The whole text fits on one line
_add_text_line(self.text, full_text_width)
# Initialize the text rendering relative to the baseline
self.text_y = self.text_height_above_baseline
if self.height is None:
self.text_y = self.text_font_height
self.supersampled_height = self.bbox_height
self.height = int(self.bbox_height / self.supersampling_factor)
else:
# Vertical starting point calc is easy in this case
self.text_y = self.text_font_height + int((self.supersampled_height - self.text_font_height)/2)
self.text_width = full_text_width
# Other components, like IconTextLine will need to know how wide the actual
# rendered text will be, separate from the TextArea's defined overall `width`.
self.text_width = max(line["text_width"] for line in self.text_lines)
# Calculate the actual height
if len(self.text_lines) == 1:
total_text_height = self.text_height_above_baseline + self.text_height_below_baseline
else:
# Multiply for the number of lines plus the spacer
total_text_height = self.text_height_above_baseline * len(self.text_lines) + self.line_spacing * (len(self.text_lines) - 1)
if re.findall(f"[gjpqy]", self.text_lines[-1]["text"]):
# Last line has at least one char that dips below baseline
total_text_height += self.text_height_below_baseline
if self.height is None:
# Autoscale height to text lines
self.height = total_text_height
else:
# Have to calc how to break text into multiple lines
def _binary_len_search(min_index, max_index):
# Try the middle of the range
index = math.ceil((max_index + min_index) / 2)
if index == 0:
# Handle edge case where there's only one word in the last line
index = 1
tw, th = self.font.getsize(" ".join(words[0:index]))
if tw > self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor):
# Candidate line is still too long. Restrict search range down.
if min_index + 1 == index:
if index == 1:
# It's just one long, unbreakable word. There's no good
# solution here. Just accept it as is and let it render off
# the edges.
return (index, tw)
else:
# There's still room to back down the min_index in the next
# round.
index -= 1
return _binary_len_search(min_index=min_index, max_index=index)
elif index == max_index:
# We have converged
return (index, tw)
if total_text_height > self.height:
if not self.allow_text_overflow:
raise TextDoesNotFitException(f"Text cannot fit in target rect with this font/size\n\ttotal_text_height: {total_text_height} | self.height: {self.height}")
else:
# Candidate line is possibly shorter than necessary.
return _binary_len_search(min_index=index, max_index=max_index)
if len(self.text.split()) == 1 and not self.allow_text_overflow:
# No whitespace chars to split on!
raise TextDoesNotFitException("Text cannot fit in target rect with this font/size")
for line in self.text.split("\n"):
words = line.split()
if not words:
# It's a blank line
_add_text_line("", 0)
else:
while words:
(index, tw) = _binary_len_search(0, len(words))
_add_text_line(" ".join(words[0:index]), tw)
words = words[index:]
# TODO: Don't render blank lines as full height
total_text_height = self.bbox_height * len(self.text_lines) + self.line_spacing * (len(self.text_lines) - 1)
if self.height is None:
# Autoscale height to text lines
self.supersampled_height = total_text_height
self.height = int(self.supersampled_height / self.supersampling_factor)
self.text_y = self.text_font_height
# Just let it render off the edge, but preserve the top portion
pass
else:
self.supersampled_height = self.height * self.supersampling_factor
if total_text_height > self.height * self.supersampling_factor + 2*GUIConstants.COMPONENT_PADDING * self.supersampling_factor:
if not self.allow_text_overflow:
raise TextDoesNotFitException("Text cannot fit in target rect with this font/size")
else:
# Just let it render off the edge, but preserve the top portion
self.text_y = self.text_font_height
# Vertically center the text's starting point
if len(self.text_lines) == 1:
# For consistency when used in TopNav and elsewhere, ignore the
# text's pixels below the baseline.
# In other words: "Home" and "Something" will get the same text_y,
# even though the "g" dips below baseline.
self.text_y += int(self.height - (total_text_height - self.text_height_below_baseline))/2
else:
# Vertically center the multiline text's starting point
self.text_y = self.text_font_height + int((self.supersampled_height - total_text_height)/2)
# Make sure the width/height that get referenced outside this obj are
# specified and restored to their normal scaling factor.
self.width = int(self.text_width / self.supersampling_factor)
self.text_font_height = int(self.text_font_height / self.supersampling_factor)
# Vertically center for the full height.
self.text_y += int(self.height - (total_text_height))/2
def render(self):
@@ -422,17 +369,51 @@ class TextArea(BaseComponent):
# with bicubic resampling.
# TODO: Store resulting super-sampled image as a member var in __post_init__ and
# just re-paste it here.
img = Image.new("RGBA", (self.supersampled_width, self.supersampled_height), self.background_color)
if self.font_size < 20 and (not self.supersampling_factor or self.supersampling_factor == 1):
self.supersampling_factor = 2
resample_padding = 10 if self.supersampling_factor > 1.0 else 0
img = Image.new(
"RGB",
(
self.width * self.supersampling_factor,
(self.height + 2*resample_padding) * self.supersampling_factor
),
self.background_color
)
draw = ImageDraw.Draw(img)
cur_y = self.text_y
# draw.line((0, resample_padding * self.supersampling_factor, self.width * self.supersampling_factor, resample_padding * self.supersampling_factor), fill="blue", width=1)
# draw.line((0, (resample_padding + self.height) * self.supersampling_factor, self.width * self.supersampling_factor, (resample_padding + self.height) * self.supersampling_factor), fill="red", width=1)
cur_y = (self.text_y + resample_padding) * self.supersampling_factor
supersampled_font = Fonts.get_font(self.font_name, int(self.supersampling_factor * self.font_size))
if self.is_text_centered:
anchor = "ms"
else:
anchor = "ls"
# Position where we'll render each line of text
text_x = self.edge_padding
for line in self.text_lines:
draw.text((line["text_x"], cur_y), line["text"], fill=self.font_color, font=self.font, anchor="ls")
cur_y += self.bbox_height + self.line_spacing
if self.is_text_centered:
# We'll render with a centered anchor so we just need the midpoint
text_x = int(self.width/2)
if text_x - int(line["text_width"]/2) < self.min_text_x:
# The left edge of the centered text will protrude too far; nudge it right
text_x = self.min_text_x + int(line["text_width"]/2)
resized = img.resize((int(self.supersampled_width / self.supersampling_factor), self.height), Image.LANCZOS)
resized = resized.filter(ImageFilter.SHARPEN)
self.canvas.paste(resized, (self.screen_x, self.screen_y))
draw.text((text_x * self.supersampling_factor, cur_y), line["text"], fill=self.font_color, font=supersampled_font, anchor=anchor)
cur_y += (self.text_height_above_baseline + self.line_spacing) * self.supersampling_factor
# Crop off the top_padding and resize the result down to onscreen size
if self.supersampling_factor > 1.0:
resized = img.resize((self.width, self.height + 2*resample_padding), Image.LANCZOS)
sharpened = resized.filter(ImageFilter.SHARPEN)
img = sharpened.crop((0, resample_padding, self.width, self.height + 2*resample_padding))
self.canvas.paste(img, (self.screen_x, self.screen_y))
@@ -529,8 +510,8 @@ class IconTextLine(BaseComponent):
value_textarea_screen_y = self.screen_y
if self.label_text:
label_padding_y = GUIConstants.COMPONENT_PADDING
value_textarea_screen_y += self.label_textarea.text_font_height + label_padding_y
label_padding_y = int(GUIConstants.COMPONENT_PADDING / 2)
value_textarea_screen_y += self.label_textarea.height + label_padding_y
self.value_textarea = TextArea(
image_draw=self.image_draw,
@@ -550,11 +531,11 @@ class IconTextLine(BaseComponent):
if self.label_text:
if not self.height:
self.height = self.label_textarea.height + label_padding_y + self.value_textarea.height
max_textarea_width = max(self.label_textarea.width, self.value_textarea.width)
max_textarea_width = max(self.label_textarea.text_width, self.value_textarea.text_width)
else:
if not self.height:
self.height = self.value_textarea.height
max_textarea_width = self.value_textarea.width
max_textarea_width = self.value_textarea.text_width
# Now we can update the icon's y position
if self.icon_name:
@@ -563,17 +544,12 @@ class IconTextLine(BaseComponent):
self.height = max(self.icon.height, self.height)
if self.is_text_centered:
if self.icon_name:
total_width = max_textarea_width + self.icon.width + self.icon_horizontal_spacer
self.icon.screen_x = self.screen_x + int((self.canvas_width - self.screen_x - total_width) / 2)
if self.label_text:
self.label_textarea.screen_x = self.icon.screen_x + self.icon.width + self.icon_horizontal_spacer
self.value_textarea.screen_x = self.icon.screen_x + self.icon.width + self.icon_horizontal_spacer
# else:
# if self.label_text:
# self.label_textarea.screen_x = self.screen_x + int((self.canvas_width - self.screen_x - max_textarea_width + (max_textarea_width - self.label_textarea.width))/2)
# self.value_textarea.screen_x = self.screen_x + int((self.canvas_width - self.screen_x - max_textarea_width + (max_textarea_width - self.value_textarea.width))/2)
if self.is_text_centered and self.icon_name:
total_width = max_textarea_width + self.icon.width + self.icon_horizontal_spacer
self.icon.screen_x = self.screen_x + int((self.canvas_width - self.screen_x - total_width) / 2)
if self.label_text:
self.label_textarea.screen_x = self.icon.screen_x + self.icon.width + self.icon_horizontal_spacer
self.value_textarea.screen_x = self.icon.screen_x + self.icon.width + self.icon_horizontal_spacer
self.width = self.canvas_width
@@ -587,6 +563,7 @@ class IconTextLine(BaseComponent):
self.icon.render()
@dataclass
class ToastOverlay(BaseComponent):
icon_name: str = None
@@ -912,7 +889,7 @@ class BtcAmount(BaseComponent):
# font = smaller_digit_font
(left, top, text_width, bottom) = font.getbbox(btc_text, anchor="ls")
text_height = -1 * top
text_height = -1 * top + bottom
text_y = self.paste_image.height - int((self.paste_image.height - text_height)/2)
draw.text(
@@ -939,7 +916,7 @@ class BtcAmount(BaseComponent):
if self.total_sats > 1e9:
font = smaller_digit_font
(left, top, text_width, bottom) = font.getbbox(sats_text, anchor="ls")
text_height = -1 * top
text_height = -1 * top + bottom
text_y = self.paste_image.height - int((self.paste_image.height - text_height)/2)
draw.text(
xy=(
@@ -976,7 +953,7 @@ class BtcAmount(BaseComponent):
cur_x = btc_icon.width + int(GUIConstants.COMPONENT_PADDING/4)
(left, top, text_width, bottom) = smaller_digit_font.getbbox(btc_text, anchor="ls")
text_height = -1 * top
text_height = -1 * top + bottom
text_y = self.paste_image.height - int((self.paste_image.height - text_height)/2)
draw.text(
@@ -1042,7 +1019,7 @@ class BtcAmount(BaseComponent):
)
unit_textarea.render()
final_x = cur_x + GUIConstants.COMPONENT_PADDING + unit_textarea.width
final_x = cur_x + GUIConstants.COMPONENT_PADDING + unit_text_width
self.paste_image = self.paste_image.crop((0, 0, final_x, self.paste_image.height))
self.paste_coords = (
@@ -1316,10 +1293,10 @@ class TopNav(BaseComponent):
height=GUIConstants.TOP_NAV_BUTTON_SIZE,
)
min_x = 0
min_text_x = 0
if self.show_back_button:
# Don't let the title intrude on the BACK button
min_x = self.left_button.screen_x + self.left_button.width + GUIConstants.COMPONENT_PADDING
min_text_x = self.left_button.screen_x + self.left_button.width + GUIConstants.COMPONENT_PADDING
if self.icon_name:
self.title = IconTextLine(
@@ -1338,7 +1315,8 @@ class TopNav(BaseComponent):
self.title = TextArea(
screen_x=0,
screen_y=0,
min_text_x=min_x,
min_text_x=min_text_x,
width=self.width,
height=self.height,
text=self.text,
is_text_centered=True,
@@ -1418,3 +1396,133 @@ def calc_bezier_curve(p1: Tuple[int,int], p2: Tuple[int,int], p3: Tuple[int,int]
return points
def reflow_text_for_width(text: str,
width: int,
font_name=GUIConstants.BODY_FONT_NAME,
font_size=GUIConstants.BODY_FONT_SIZE,
allow_text_overflow: bool=False) -> list[dict]:
"""
Reflows text to fit within `width` by breaking long lines up.
Returns a List with each reflowed line of text as its own entry.
Note: It is up to the calling code to handle any height considerations for the
resulting lines of text.
"""
# We have to figure out if and where to make line breaks in the text so that it
# fits in its bounding rect (plus accounting for edge padding) using its given
# font.
start = time()
font = Fonts.get_font(font_name=font_name, size=font_size)
# Measure from left baseline ("ls")
(left, top, full_text_width, bottom) = font.getbbox(text, anchor="ls")
# Stores each line of text and its rendering starting x-coord
text_lines = []
def _add_text_line(text, text_width):
text_lines.append({"text": text, "text_width": text_width})
if "\n" not in text and full_text_width < width:
# The whole text fits on one line
_add_text_line(text, full_text_width)
else:
# Have to calc how to break text into multiple lines
def _binary_len_search(min_index, max_index):
# Try the middle of the range
index = math.ceil((max_index + min_index) / 2)
if index == 0:
# Handle edge case where there's only one word in the last line
index = 1
# Measure rendered width from "left" anchor (anchor="l_")
(left, top, right, bottom) = font.getbbox(" ".join(words[0:index]), anchor="ls")
line_width = right - left
if line_width >= width:
# Candidate line is still too long. Restrict search range down.
if min_index + 1 == index:
if index == 1:
# It's just one long, unbreakable word. There's no good
# solution here. Just accept it as is and let it render off
# the edges.
return (index, line_width)
else:
# There's still room to back down the min_index in the next
# round.
index -= 1
return _binary_len_search(min_index=min_index, max_index=index)
elif index == max_index:
# We have converged
return (index, line_width)
else:
# Candidate line is possibly shorter than necessary.
return _binary_len_search(min_index=index, max_index=max_index)
if len(text.split()) == 1 and not allow_text_overflow:
# No whitespace chars to split on!
raise TextDoesNotFitException("Text cannot fit in target rect with this font+size")
# Now we're ready to go line-by-line into our line break binary search!
for line in text.split("\n"):
words = line.split()
if not words:
# It's a blank line
_add_text_line("", 0)
else:
while words:
(index, tw) = _binary_len_search(0, len(words))
_add_text_line(" ".join(words[0:index]), tw)
words = words[index:]
return text_lines
def reflow_text_into_pages(text: str,
width: int,
height: int,
font_name=GUIConstants.BODY_FONT_NAME,
font_size=GUIConstants.BODY_FONT_SIZE,
line_spacer: int = GUIConstants.BODY_LINE_SPACING,
allow_text_overflow: bool=False) -> list[str]:
"""
Invokes `reflow_text_for_width` above to convert long text into width-limited
individual text lines and then calculates how many lines will fit on a "page" and
groups the output accordingly.
Returns a list of strings where each string is a page's worth of line-breaked text.
"""
reflowed_lines_dicts = reflow_text_for_width(text=text,
width=width,
font_name=font_name,
font_size=font_size,
allow_text_overflow=allow_text_overflow)
lines = []
for line_dict in reflowed_lines_dicts:
lines.append(line_dict["text"])
print(f"""{line_dict["text_width"]:3}: {line_dict["text"]}""")
font = Fonts.get_font(font_name=font_name, size=font_size)
# Measure the font's height above baseline and how for below it certain characters
# (e.g. lowercase "g") can render.
(left, top, right, bottom) = font.getbbox("Agjpqy", anchor="ls")
font_height_above_baseline = -1 * top
font_height_below_baseline = bottom
# I'm sure there's a smarter way to do this...
lines_per_page = 0
for i in range(1, height):
if height > font_height_above_baseline * i + line_spacer * (i-1) + font_height_below_baseline:
lines_per_page = i
else:
break
pages = []
for i in range(0, len(lines), lines_per_page):
pages.append("\n".join(lines[i:i+lines_per_page]))
return pages
-118
View File
@@ -84,125 +84,7 @@ class Renderer(ConfigurableSingleton):
self.disp.ShowImage(crop, 0, 0)
# TODO: Remove all references
def show_image_with_text(self, image, text, font=None, text_color="GREY", text_background=None):
image_copy = image.copy().convert("RGBA")
text_overlay = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0))
text_overlay_draw = ImageDraw.Draw(text_overlay)
if not font:
font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BODY_FONT_SIZE)
tw, th = text_overlay_draw.textsize(text, font=font)
if text_background:
text_overlay_draw.rectangle(((240 - tw) / 2 - 3, 240 - th, (240 - tw) / 2 + tw + 3, 240), fill=text_background)
text_overlay_draw.text(((240 - tw) / 2, 240 - th - 1), text, fill=text_color, font=font)
self.show_image(image_copy, alpha_overlay=text_overlay)
# TODO: Should probably move this to screens.py
def draw_modal(self, lines = [], title = "", bottom = "") -> None:
self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0)
if len(title) > 0:
tw, th = self.draw.textsize(title, font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 2), title, fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
if len(bottom) > 0:
tw, th = self.draw.textsize(bottom, font=Fonts.get_font("Assistant-Medium", 18))
self.draw.text(((240 - tw) / 2, 210), bottom, fill=self.color, font=Fonts.get_font("Assistant-Medium", 18))
if len(lines) == 1:
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26))
self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
elif len(lines) == 2:
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 125), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
elif len(lines) == 3:
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26))
self.draw.text(((240 - tw) / 2, 55), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
elif len(lines) == 4:
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 55), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[3], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 160), lines[3], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
self.show_image()
return
# TODO: Should probably move this to templates.py
def draw_prompt_yes_no(self, lines = [], title = "", bottom = "") -> None:
self.draw_prompt_custom("", "Yes ", "No ", lines, title, bottom)
return
# TODO: Should probably move this to templates.py
def draw_prompt_custom(self, a_txt, b_txt, c_txt, lines = [], title = "", bottom = "") -> None:
self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0)
if len(title) > 0:
tw, th = self.draw.textsize(title, font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 2), title, fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
if len(bottom) > 0:
tw, th = self.draw.textsize(bottom, font=Fonts.get_font("Assistant-Medium", 18))
self.draw.text(((240 - tw) / 2, 210), bottom, fill=self.color, font=Fonts.get_font("Assistant-Medium", 18))
if len(lines) == 1:
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26))
self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
elif len(lines) == 2:
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 125), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
elif len(lines) == 3:
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26))
self.draw.text(((240 - tw) / 2, 20), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
elif len(lines) == 4:
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 20), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[3], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 160), lines[3], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
a_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(a_txt)[0]
self.draw.text((a_x_offset, 39 + 0), a_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25))
b_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(b_txt)[0]
self.draw.text((b_x_offset , 39 + 60), b_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25))
c_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(c_txt)[0]
self.draw.text((c_x_offset , 39 + 120), c_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25))
self.show_image()
return
def display_blank_screen(self):
self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0)
self.show_image()
+2 -1
View File
@@ -847,7 +847,7 @@ class LargeIconStatusScreen(ButtonListScreen):
self.status_icon.screen_x = int((self.canvas_width - self.status_icon.width) / 2)
self.components.append(self.status_icon)
next_y = self.status_icon.screen_y + self.status_icon.height + 4
next_y = self.status_icon.screen_y + self.status_icon.height + int(GUIConstants.COMPONENT_PADDING/2)
if self.status_headline:
self.warning_headline_textarea = TextArea(
text=self.status_headline,
@@ -863,6 +863,7 @@ class LargeIconStatusScreen(ButtonListScreen):
height=self.buttons[0].screen_y - next_y,
text=self.text,
width=self.canvas_width,
edge_padding=GUIConstants.EDGE_PADDING, # Don't render all the way up to the far left/right edges
screen_y=next_y,
allow_text_overflow=self.allow_text_overflow,
))
+85 -11
View File
@@ -11,8 +11,7 @@ from seedsigner.models.threads import BaseThread, ThreadsafeCounter
from .screen import RET_CODE__BACK_BUTTON, BaseScreen, BaseTopNavScreen, ButtonListScreen, KeyboardScreen, WarningEdgesMixin
from ..components import (Button, FontAwesomeIconConstants, Fonts, FormattedAddress, IconButton,
IconTextLine, SeedSignerIconConstants, TextArea, GUIConstants,
calc_text_centering)
IconTextLine, SeedSignerIconConstants, TextArea, GUIConstants, reflow_text_into_pages)
from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay
from seedsigner.hardware.buttons import HardwareButtons, HardwareButtonsConstants
@@ -528,6 +527,22 @@ class SeedWordsScreen(WarningEdgesMixin, ButtonListScreen):
@dataclass
class SeedBIP85SelectChildIndexScreen(KeyboardScreen):
def __post_init__(self):
self.title = "BIP-85 Index"
self.user_input = ""
# Specify the keys in the keyboard
self.rows = 3
self.cols = 5
self.keys_charset = "0123456789"
self.show_save_button = True
super().__post_init__()
@dataclass
class SeedWordsBackupTestPromptScreen(ButtonListScreen):
def __post_init__(self):
@@ -1326,7 +1341,7 @@ class AddressVerificationSigTypeScreen(ButtonListScreen):
@dataclass
class SeedSingleSigAddressVerificationSelectSeedScreen(ButtonListScreen):
class SeedSelectSeedScreen(ButtonListScreen):
text: str = ""
def __post_init__(self):
@@ -1482,16 +1497,75 @@ class MultisigWalletDescriptorScreen(ButtonListScreen):
allow_text_overflow=True,
))
@dataclass
class SeedBIP85SelectChildIndexScreen(KeyboardScreen):
class SeedSignMessageConfirmMessageScreen(ButtonListScreen):
page_num: int = None
def __post_init__(self):
self.title = "BIP-85 Index"
self.user_input = ""
from seedsigner.controller import Controller
renderer = Renderer.get_instance()
start_y = GUIConstants.TOP_NAV_HEIGHT + GUIConstants.COMPONENT_PADDING
end_y = renderer.canvas_height - GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT - GUIConstants.COMPONENT_PADDING
message_height = end_y - start_y
# Specify the keys in the keyboard
self.rows = 3
self.cols = 5
self.keys_charset = "0123456789"
self.show_save_button = True
self.sign_message_data = Controller.get_instance().sign_message_data
if "paged_message" not in self.sign_message_data:
paged = reflow_text_into_pages(
text=self.sign_message_data["message"],
width=renderer.canvas_width - 2*GUIConstants.EDGE_PADDING,
height=message_height,
)
self.sign_message_data["paged_message"] = paged
if self.page_num >= len(self.sign_message_data["paged_message"]):
raise Exception("Bug in paged_message calculation")
if len(self.sign_message_data["paged_message"]) == 1:
self.title = "Review Message"
else:
self.title = f"""Message (pt {self.page_num + 1}/{len(self.sign_message_data["paged_message"])})"""
self.is_bottom_list = True
self.is_button_text_centered = True
self.button_data = ["Next"]
super().__post_init__()
message_display = TextArea(
text=self.sign_message_data["paged_message"][self.page_num],
is_text_centered=False,
allow_text_overflow=True,
screen_y=start_y,
)
self.components.append(message_display)
@dataclass
class SeedSignMessageConfirmAddressScreen(ButtonListScreen):
derivation_path: str = None
address: str = None
def __post_init__(self):
self.title = "Confirm Address"
self.is_bottom_list = True
self.is_button_text_centered = True
self.button_data = ["Sign Message"]
super().__post_init__()
derivation_path_display = IconTextLine(
icon_name=SeedSignerIconConstants.DERIVATION,
icon_color=GUIConstants.INFO_COLOR,
label_text="derivation path",
value_text=self.derivation_path,
is_text_centered=True,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
)
self.components.append(derivation_path_display)
address_display = FormattedAddress(
address=self.address,
max_lines=3,
screen_y=derivation_path_display.screen_y + derivation_path_display.height + 2*GUIConstants.COMPONENT_PADDING,
)
self.components.append(address_display)
+1 -4
View File
@@ -166,7 +166,7 @@ class ToolsCalcFinalWordFinalizePromptScreen(ButtonListScreen):
self.components.append(TextArea(
text=f"The {self.mnemonic_length}th word is built from {self.num_entropy_bits} more entropy bits plus auto-calculated checksum.",
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
screen_y=self.top_nav.height + int(GUIConstants.COMPONENT_PADDING/2),
))
@@ -252,7 +252,6 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
edge_padding=0,
screen_x=bit_display_x,
screen_y=screen_y,
height=bit_font_height,
is_text_centered=False,
))
@@ -266,7 +265,6 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
edge_padding=0,
screen_x=checksum_x,
screen_y=screen_y,
height=bit_font_height,
is_text_centered=False,
))
@@ -291,7 +289,6 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
edge_padding=0,
screen_x=bit_display_x,
screen_y=screen_y,
height=bit_font_height,
is_text_centered=False,
))
+89 -2
View File
@@ -1,9 +1,14 @@
import embit
from embit import bip32
from binascii import b2a_base64
from hashlib import sha256
from embit import bip32, compact, ec
from embit.bip32 import HDKey
from embit.descriptor import Descriptor
from embit.networks import NETWORKS
from embit.networks import NETWORKS
from embit.util import secp256k1
from seedsigner.models.settings_definition import SettingsConstants
@@ -93,3 +98,85 @@ def get_multisig_address(descriptor: Descriptor, index: int = 0, is_change: bool
elif descriptor.is_taproot:
# TODO: Not yet implemented!
raise Exception("Taproot verification not yet implemented!")
def get_embit_network_name(settings_name):
""" Convert SeedSigner SettingsConstants for `network` to embit's NETWORK key """
lookup = {
SettingsConstants.MAINNET: "main",
SettingsConstants.TESTNET: "test",
SettingsConstants.REGTEST: "regtest",
}
return lookup.get(settings_name)
def parse_derivation_path(derivation_path: str) -> dict:
"""
Parses a derivation path into its related SettingsConstants equivalents.
Primarily only supports single sig derivation paths.
May return None for fields it cannot parse.
"""
# Support either m/44'/... or m/44h/... style
derivation_path = derivation_path.replace("'", "h")
sections = derivation_path.split("/")
if sections[1] == "48h":
# So far this helper is only meant for single sig message signing
raise Exception("Not implemented")
lookups = {
"script_types": {
"84h": SettingsConstants.NATIVE_SEGWIT,
"49h": SettingsConstants.NESTED_SEGWIT,
"86h": SettingsConstants.TAPROOT,
},
"networks": {
"0h": SettingsConstants.MAINNET,
"1h": [SettingsConstants.TESTNET, SettingsConstants.REGTEST],
}
}
details = dict()
details["script_type"] = lookups["script_types"].get(sections[1])
if not details["script_type"]:
details["script_type"] = SettingsConstants.CUSTOM_DERIVATION
details["network"] = lookups["networks"].get(sections[2])
details["is_change"] = sections[-2] == "1"
details["index"] = int(sections[-1])
details["clean_match"] = True
for k, v in details.items():
if v is None:
# At least one field couldn't be parsed
details["clean_match"] = False
break
return details
def sign_message(seed_bytes: bytes, derivation: str, msg: bytes, compressed: bool = True, embit_network: str = "main") -> bytes:
"""
from: https://github.com/cryptoadvance/specter-diy/blob/b58a819ef09b2bca880a82c7e122618944355118/src/apps/signmessage/signmessage.py
"""
"""Sign message with private key"""
msghash = sha256(
sha256(
b"\x18Bitcoin Signed Message:\n" + compact.to_bytes(len(msg)) + msg
).digest()
).digest()
root = bip32.HDKey.from_seed(seed_bytes, version=NETWORKS[embit_network]["xprv"])
prv = root.derive(derivation).key
sig = secp256k1.ecdsa_sign_recoverable(msghash, prv._secret)
flag = sig[64]
sig = ec.Signature(sig[:64])
c = 4 if compressed else 0
flag = bytes([27 + flag + c])
ser = flag + secp256k1.ecdsa_signature_serialize_compact(sig._sig)
return b2a_base64(ser).strip().decode()
+70 -3
View File
@@ -83,6 +83,9 @@ class DecodeQR:
elif self.qr_type == QRType.BITCOIN_ADDRESS:
self.decoder = BitcoinAddressQrDecoder() # Single Segment bitcoin address
elif self.qr_type == QRType.SIGN_MESSAGE:
self.decoder = SignMessageQrDecoder() # Single Segment sign message request
elif self.qr_type == QRType.WALLET__SPECTER:
self.decoder = SpecterWalletQrDecoder() # Specter Desktop Wallet Export decoder
@@ -131,6 +134,9 @@ class DecodeQR:
return rt
# TODO: Refactor all of these specific `get_` to just something generic like
# `get_data` and let each QRDecoder class return whatever it needs to as a
# str, tuple, dict, etc?
def get_psbt(self):
if self.complete:
data = self.get_data_psbt()
@@ -187,6 +193,15 @@ class DecodeQR:
return self.decoder.get_address_type()
def get_qr_data(self) -> dict:
"""
This provides a single access point for external code to retrieve the QR data,
regardless of which decoder is actually instantiated.
"""
# TODO: Implement this approach across all decoders
return self.decoder.get_qr_data()
def get_wallet_descriptor(self):
if self.is_wallet_descriptor:
if self.qr_type in [QRType.OUTPUT__UR, QRType.ACCOUNT__UR, QRType.BYTES__UR]:
@@ -267,6 +282,11 @@ class DecodeQR:
return self.qr_type == QRType.BITCOIN_ADDRESS
@property
def is_sign_message(self):
return self.qr_type == QRType.SIGN_MESSAGE
@property
def is_wallet_descriptor(self):
check = self.qr_type in [QRType.WALLET__SPECTER, QRType.WALLET__UR, QRType.WALLET__CONFIGFILE, QRType.WALLET__GENERIC, QRType.OUTPUT__UR]
@@ -357,6 +377,10 @@ class DecodeQR:
elif DecodeQR.is_bitcoin_address(s):
return QRType.BITCOIN_ADDRESS
# message signing
elif DecodeQR.is_sign_message(s):
return QRType.SIGN_MESSAGE
# config data
if s.startswith("settings::"):
return QRType.SETTINGS
@@ -476,7 +500,13 @@ class DecodeQR:
return True
else:
return False
@staticmethod
def is_sign_message(s):
return type(s) == str and s.startswith("signmessage")
@staticmethod
def multisig_setup_file_to_descriptor(text) -> str:
# sample text file, parse the contents and create descriptor
@@ -586,6 +616,10 @@ class BaseQrDecoder:
def add(self, segment, qr_type):
raise Exception("Not implemented in child class")
def get_qr_data(self) -> dict:
# TODO: standardize this approach across all decoders (example: SignMessageQrDecoder)
raise Exception("get_qr_data must be implemented in decoder child class")
@@ -854,6 +888,40 @@ class SettingsQrDecoder(BaseSingleFrameQrDecoder):
class SignMessageQrDecoder(BaseSingleFrameQrDecoder):
def __init__(self):
super().__init__()
self.message = None
self.derivation_path = None
def add(self, segment, qr_type=QRType.SIGN_MESSAGE):
"""
Expected QR data format:
signmessage {derivation_path} ascii:{message}
"""
parts = segment.split()
self.derivation_path = parts[1].replace("h", "'")
fmt = parts[2].split(":")[0]
self.message = segment.split(f"{fmt}:")[1]
# TODO: support formats other than ascii?
if fmt != "ascii":
print(f"Sign message: Unsupported format: {fmt}")
return DecodeQRStatus.INVALID
self.complete = True
self.collected_segments = 1
return DecodeQRStatus.COMPLETE
def get_qr_data(self) -> dict:
return dict(derivation_path=self.derivation_path, message=self.message)
class BitcoinAddressQrDecoder(BaseSingleFrameQrDecoder):
"""
Decodes single frame representing a bitcoin address
@@ -1019,8 +1087,7 @@ class GenericWalletQrDecoder(BaseSingleFrameQrDecoder):
class MultiSigConfigFileQRDecoder(GenericWalletQrDecoder):
class MultiSigConfigFileQRDecoder(GenericWalletQrDecoder):
def add(self, segment, qr_type=QRType.WALLET__CONFIGFILE):
descriptor = DecodeQR.multisig_setup_file_to_descriptor(segment)
return super().add(descriptor,qr_type=QRType.WALLET__CONFIGFILE)
+31 -18
View File
@@ -39,6 +39,7 @@ class EncodeQR:
qr_density: str = SettingsConstants.DENSITY__MEDIUM
wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH
bitcoin_address: str = None
signed_message: str = None
def __post_init__(self):
self.qr = QR()
@@ -98,9 +99,13 @@ class EncodeQR:
self.encoder = CompactSeedQrEncoder(seed_phrase=self.seed_phrase,
wordlist_language_code=self.wordlist_language_code)
# Misc formats
elif self.qr_type == QRType.BITCOIN_ADDRESS:
self.encoder = BitcoinAddressEncoder(address=self.bitcoin_address)
elif self.qr_type == QRType.SIGN_MESSAGE:
self.encoder = SignedMessageEncoder(signed_message=self.signed_message)
else:
raise Exception('QR Type not supported')
@@ -155,6 +160,17 @@ class BaseQrEncoder:
class BaseStaticQrEncoder(BaseQrEncoder):
def seq_len(self):
return 1
@property
def is_complete(self):
return True
class BasePsbtQrEncoder(BaseQrEncoder):
def __init__(self, psbt: PSBT):
self.psbt = psbt
@@ -264,7 +280,7 @@ class SpecterPsbtQrEncoder(BasePsbtQrEncoder):
class SeedQrEncoder(BaseQrEncoder):
class SeedQrEncoder(BaseStaticQrEncoder):
def __init__(self, seed_phrase: List[str], wordlist_language_code: str):
super().__init__()
self.seed_phrase = seed_phrase
@@ -274,10 +290,6 @@ class SeedQrEncoder(BaseQrEncoder):
raise Exception('Wordlist Required')
def seq_len(self):
return 1
def next_part(self):
data = ""
# Output as Numeric data format
@@ -287,11 +299,6 @@ class SeedQrEncoder(BaseQrEncoder):
return data
@property
def is_complete(self):
return True
class CompactSeedQrEncoder(SeedQrEncoder):
def next_part(self):
@@ -323,23 +330,29 @@ class CompactSeedQrEncoder(SeedQrEncoder):
class BitcoinAddressEncoder(BaseQrEncoder):
class BitcoinAddressEncoder(BaseStaticQrEncoder):
def __init__(self, address: str):
super().__init__()
self.address = address
def seq_len(self):
return 1
def next_part(self):
return self.address
@property
def is_complete(self):
return True
class SignedMessageEncoder(BaseStaticQrEncoder):
"""
Assumes that a signed message will fit in a single-frame QR
"""
def __init__(self, signed_message: str):
super().__init__()
self.signed_message = signed_message
def next_part(self):
return self.signed_message
class XpubQrEncoder(BaseQrEncoder):
+2
View File
@@ -21,6 +21,8 @@ class QRType:
BITCOIN_ADDRESS = "bitcoin_address"
SIGN_MESSAGE = "sign_message"
WALLET__SPECTER = "wallet__specter"
WALLET__UR = "wallet__ur"
WALLET__CONFIGFILE = "wallet__configfile"
@@ -158,6 +158,7 @@ class SettingsConstants:
SETTING__CAMERA_ROTATION = "camera_rotation"
SETTING__COMPACT_SEEDQR = "compact_seedqr"
SETTING__BIP85_CHILD_SEEDS = "bip85_child_seeds"
SETTING__MESSAGE_SIGNING = "message_signing"
SETTING__PRIVACY_WARNINGS = "privacy_warnings"
SETTING__DIRE_WARNINGS = "dire_warnings"
SETTING__QR_BRIGHTNESS_TIPS = "qr_brightness_tips"
@@ -455,6 +456,12 @@ class SettingsDefinition:
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__DISABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__MESSAGE_SIGNING,
display_name="Message signing",
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__DISABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__PRIVACY_WARNINGS,
abbreviated_name="priv_warn",
+19 -19
View File
@@ -26,10 +26,14 @@ class PSBTSelectSeedView(View):
if not self.controller.psbt:
# Shouldn't be able to get here
raise Exception("No PSBT currently loaded")
if self.controller.psbt_seed:
if PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=self.controller.psbt_seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)):
# skip the seed prompt if a seed was previous selected and has matching input fingerprint
return Destination(PSBTOverviewView)
seeds = self.controller.storage.seeds
button_data = []
for seed in seeds:
button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
if not PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)):
@@ -42,11 +46,6 @@ class PSBTSelectSeedView(View):
button_data.append(self.TYPE_12WORD)
button_data.append(self.TYPE_24WORD)
if self.controller.psbt_seed:
if PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=self.controller.psbt_seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)):
# skip the seed prompt if a seed was previous selected and has matching input fingerprint
return Destination(PSBTOverviewView)
selected_menu_num = self.run_screen(
ButtonListScreen,
title="Select Signer",
@@ -177,6 +176,8 @@ class PSBTUnsupportedScriptTypeWarningView(View):
skip_current_view=True, # Prevent going BACK to WarningViews
)
class PSBTNoChangeWarningView(View):
def run(self):
selected_menu_num = WarningScreen(
@@ -246,7 +247,7 @@ class PSBTAddressDetailsView(View):
if not psbt_parser:
# Should not be able to get here
return Destination(MainMenuView)
raise Exception("Routing error")
title = "Will Send"
if psbt_parser.num_destinations > 1:
@@ -268,18 +269,17 @@ class PSBTAddressDetailsView(View):
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if self.address_num < len(psbt_parser.destination_addresses) - 1:
# Show the next receive addr
return Destination(PSBTAddressDetailsView, view_args={"address_num": self.address_num + 1})
elif psbt_parser.change_amount > 0:
# Move on to display change
return Destination(PSBTChangeDetailsView, view_args={"change_address_num": 0})
else:
if self.address_num < len(psbt_parser.destination_addresses) - 1:
# Show the next receive addr
return Destination(PSBTAddressDetailsView, view_args={"address_num": self.address_num + 1})
elif psbt_parser.change_amount > 0:
# Move on to display change
return Destination(PSBTChangeDetailsView, view_args={"change_address_num": 0})
else:
# There's no change output to verify. Move on to sign the PSBT.
return Destination(PSBTFinalizeView)
# There's no change output to verify. Move on to sign the PSBT.
return Destination(PSBTFinalizeView)
+16 -1
View File
@@ -7,7 +7,7 @@ from seedsigner.models.decode_qr import DecodeQR
from seedsigner.models.seed import Seed
from seedsigner.models.settings import SettingsConstants
from seedsigner.views.settings_views import SettingsIngestSettingsQRView
from seedsigner.views.view import BackStackView, ErrorView, MainMenuView, NotYetImplementedView, View, Destination
from seedsigner.views.view import BackStackView, ErrorView, MainMenuView, NotYetImplementedView, OptionDisabledView, View, Destination
@@ -135,6 +135,21 @@ class ScanView(View):
}
)
elif self.decoder.is_sign_message:
if self.settings.get_value(SettingsConstants.SETTING__MESSAGE_SIGNING) == SettingsConstants.OPTION__ENABLED:
from seedsigner.views.seed_views import SeedSignMessageStartView
qr_data = self.decoder.get_qr_data()
return Destination(
SeedSignMessageStartView,
view_args=dict(
derivation_path=qr_data["derivation_path"],
message=qr_data["message"],
)
)
else:
return Destination(OptionDisabledView, view_args=dict(error_msg="Message signing is currently disabled in Settings"))
else:
return Destination(NotYetImplementedView)
+259 -59
View File
@@ -68,6 +68,94 @@ class SeedsMenuView(View):
class SeedSelectSeedView(View):
"""
Reusable seed selection UI. Prompts the user to select amongst the already-loaded
seeds OR to load a seed.
* `flow`: indicates which user flow is in progress during seed selection (e.g.
verify single sig addr or sign message).
"""
SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE)
TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD)
TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD)
def __init__(self, flow: str = Controller.FLOW__VERIFY_SINGLESIG_ADDR):
super().__init__()
self.flow = flow
def run(self):
seeds = self.controller.storage.seeds
if self.flow == Controller.FLOW__VERIFY_SINGLESIG_ADDR:
title = "Verify Address"
if not seeds:
text = "Load the seed to verify"
else:
text = "Select seed to verify"
elif self.flow == Controller.FLOW__SIGN_MESSAGE:
title = "Sign Message"
if not seeds:
text = "Load the seed to sign with"
else:
text = "Select seed to sign with"
else:
raise Exception(f"Unsupported `flow` specified: {self.flow}")
button_data = []
for seed in seeds:
button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
if seed.passphrase is not None:
# TODO: Include lock icon on right side of button
pass
button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT, "blue"))
button_data.append(self.SCAN_SEED)
button_data.append(self.TYPE_12WORD)
button_data.append(self.TYPE_24WORD)
selected_menu_num = self.run_screen(
seed_screens.SeedSelectSeedScreen,
title=title,
text=text,
is_button_text_centered=False,
button_data=button_data,
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if len(seeds) > 0 and selected_menu_num < len(seeds):
# User selected one of the n seeds
view_args = dict(seed_num=selected_menu_num)
if self.flow == Controller.FLOW__VERIFY_SINGLESIG_ADDR:
return Destination(SeedAddressVerificationView, view_args=view_args)
elif self.flow == Controller.FLOW__SIGN_MESSAGE:
self.controller.sign_message_data["seed_num"] = selected_menu_num
return Destination(SeedSignMessageConfirmMessageView)
self.controller.resume_main_flow = self.flow
if button_data[selected_menu_num] == self.SCAN_SEED:
from seedsigner.views.scan_views import ScanView
return Destination(ScanView)
elif button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]:
from seedsigner.views.seed_views import SeedMnemonicEntryView
if button_data[selected_menu_num] == self.TYPE_12WORD:
self.controller.storage.init_pending_mnemonic(num_words=12)
else:
self.controller.storage.init_pending_mnemonic(num_words=24)
return Destination(SeedMnemonicEntryView)
"""****************************************************************************
Loading seeds, passphrases, etc
****************************************************************************"""
@@ -290,7 +378,7 @@ class SeedReviewPassphraseView(View):
seed_num = self.controller.storage.finalize_pending_seed()
return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True)
class SeedDiscardView(View):
KEEP = "Keep Seed"
@@ -342,6 +430,7 @@ class SeedOptionsView(View):
VERIFY_ADDRESS = "Verify Addr"
EXPORT_XPUB = "Export Xpub"
EXPLORER = "Address Explorer"
SIGN_MESSAGE = "Sign Message"
BACKUP = ("Backup Seed", None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT)
BIP85_CHILD_SEED = "BIP-85 Child Seed"
DISCARD = ("Discard Seed", None, None, "red")
@@ -368,6 +457,10 @@ class SeedOptionsView(View):
# derivation path is specified.
return Destination(SeedExportXpubScriptTypeView, view_args=dict(seed_num=self.seed_num, sig_type=SettingsConstants.SINGLE_SIG), skip_current_view=True)
elif self.controller.resume_main_flow == Controller.FLOW__SIGN_MESSAGE:
self.controller.sign_message_data["seed_num"] = self.seed_num
return Destination(SeedSignMessageConfirmMessageView, skip_current_view=True)
if self.controller.psbt:
if PSBTParser.has_matching_input_fingerprint(self.controller.psbt, self.seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)):
if self.controller.resume_main_flow and self.controller.resume_main_flow == Controller.FLOW__PSBT:
@@ -391,6 +484,9 @@ class SeedOptionsView(View):
button_data.append(self.EXPLORER)
button_data.append(self.BACKUP)
if self.settings.get_value(SettingsConstants.SETTING__MESSAGE_SIGNING) == SettingsConstants.OPTION__ENABLED:
button_data.append(self.SIGN_MESSAGE)
if self.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__ENABLED:
button_data.append(self.BIP85_CHILD_SEED)
@@ -422,6 +518,12 @@ class SeedOptionsView(View):
self.controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER
return Destination(SeedExportXpubScriptTypeView, view_args=dict(seed_num=self.seed_num, sig_type=SettingsConstants.SINGLE_SIG))
elif button_data[selected_menu_num] == self.SIGN_MESSAGE:
from seedsigner.views.scan_views import ScanView
self.controller.sign_message_data = dict(seed_num=self.seed_num)
self.controller.resume_main_flow = Controller.FLOW__SIGN_MESSAGE
return Destination(ScanView)
elif button_data[selected_menu_num] == self.BACKUP:
return Destination(SeedBackupView, view_args=dict(seed_num=self.seed_num))
@@ -1458,7 +1560,7 @@ class AddressVerificationStartView(View):
else:
sig_type = SettingsConstants.SINGLE_SIG
destination = Destination(SeedSingleSigAddressVerificationSelectSeedView, skip_current_view=True)
destination = Destination(SeedSelectSeedView, skip_current_view=True)
elif self.controller.unverified_address["script_type"] == SettingsConstants.TAPROOT:
# TODO: add Taproot support
@@ -1501,7 +1603,7 @@ class AddressVerificationSigTypeView(View):
elif button_data[selected_menu_num] == SINGLE_SIG:
sig_type = SettingsConstants.SINGLE_SIG
destination = Destination(SeedSingleSigAddressVerificationSelectSeedView)
destination = Destination(SeedSelectSeedView, view_args=dict(flow=Controller.FLOW__VERIFY_SINGLESIG_ADDR))
elif button_data[selected_menu_num] == MULTISIG:
sig_type = SettingsConstants.MULTISIG
@@ -1523,61 +1625,6 @@ class AddressVerificationSigTypeView(View):
class SeedSingleSigAddressVerificationSelectSeedView(View):
def run(self):
seeds = self.controller.storage.seeds
SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE)
TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD)
TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD)
button_data = []
text = "Load the seed to verify"
for seed in seeds:
button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT))
text = "Select seed to verify"
button_data.append(SCAN_SEED)
button_data.append(TYPE_12WORD)
button_data.append(TYPE_24WORD)
selected_menu_num = seed_screens.SeedSingleSigAddressVerificationSelectSeedScreen(
title="Verify Address",
text=text,
is_button_text_centered=False,
button_data=button_data
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if len(seeds) > 0 and selected_menu_num < len(seeds):
# User selected one of the n seeds
return Destination(
SeedAddressVerificationView,
view_args=dict(
seed_num=selected_menu_num,
)
)
self.controller.resume_main_flow = Controller.FLOW__VERIFY_SINGLESIG_ADDR
if button_data[selected_menu_num] == SCAN_SEED:
from seedsigner.views.scan_views import ScanSeedQRView
return Destination(ScanSeedQRView)
elif button_data[selected_menu_num] in [TYPE_12WORD, TYPE_24WORD]:
from seedsigner.views.seed_views import SeedMnemonicEntryView
if button_data[selected_menu_num] == TYPE_12WORD:
self.controller.storage.init_pending_mnemonic(num_words=12)
else:
self.controller.storage.init_pending_mnemonic(num_words=24)
return Destination(SeedMnemonicEntryView)
class SeedAddressVerificationView(View):
"""
Creates a worker thread to brute-force calculate addresses. Writes its
@@ -1773,7 +1820,8 @@ class AddressVerificationSuccessView(View):
LargeIconStatusScreen(
status_headline="Address Verified",
text=f"""{address[:7]} = {source}'s {"change" if verified_index_is_change else "receive"} address #{verified_index}."""
text=f"""{address[:7]} = {source}'s {"change" if verified_index_is_change else "receive"} address #{verified_index}.""",
show_back_button=False,
).display()
return Destination(MainMenuView)
@@ -1854,3 +1902,155 @@ class MultisigWalletDescriptorView(View):
return Destination(ToolsAddressExplorerAddressTypeView)
return Destination(MainMenuView)
"""****************************************************************************
Sign Message Views
****************************************************************************"""
class SeedSignMessageStartView(View):
"""
Routes users straight through to the "Sign" screen if a signing `seed_num` has
already been selected. Otherwise routes to `SeedSelectSeedView` to select or
load a seed first.
"""
def __init__(self, derivation_path: str, message: str):
super().__init__()
self.derivation_path = derivation_path
self.message = message
data = self.controller.sign_message_data
if not data:
data = {}
self.controller.sign_message_data = data
data["derivation_path"] = derivation_path
data["message"] = message
# May be None
self.seed_num = data.get("seed_num")
def run(self):
if self.seed_num is not None:
# We already know which seed we're signing with
return Destination(SeedSignMessageConfirmMessageView, skip_current_view=True)
else:
return Destination(SeedSelectSeedView, view_args=dict(flow=Controller.FLOW__SIGN_MESSAGE), skip_current_view=True)
class SeedSignMessageConfirmMessageView(View):
def __init__(self, page_num: int = 0):
super().__init__()
self.page_num = page_num # Note: zero-indexed numbering!
self.seed_num = self.controller.sign_message_data.get("seed_num")
if self.seed_num is None:
raise Exception("Routing error: seed_num hasn't been set")
def run(self):
from seedsigner.gui.screens.seed_screens import SeedSignMessageConfirmMessageScreen
selected_menu_num = self.run_screen(
SeedSignMessageConfirmMessageScreen,
page_num=self.page_num,
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
if self.page_num == 0:
# We're exiting this flow entirely
self.controller.resume_main_flow = None
self.controller.sign_message_data = None
return Destination(BackStackView)
# User clicked "Next"
if self.page_num == len(self.controller.sign_message_data["paged_message"]) - 1:
# We've reached the end of the paged message
return Destination(SeedSignMessageConfirmAddressView)
else:
return Destination(SeedSignMessageConfirmMessageView, view_args=dict(page_num=self.page_num + 1))
class SeedSignMessageConfirmAddressView(View):
def __init__(self):
super().__init__()
data = self.controller.sign_message_data
self.seed_num = data.get("seed_num")
self.derivation_path = data.get("derivation_path")
if self.seed_num is None or not self.derivation_path:
raise Exception("Routing error: sign_message_data hasn't been set")
# calculate the actual receive address
seed = self.controller.storage.seeds[self.seed_num]
addr_format = embit_utils.parse_derivation_path(self.derivation_path)
if not addr_format["clean_match"]:
raise Exception("Signing messages for custom derivation paths not supported")
if addr_format["network"] != SettingsConstants.MAINNET:
# We're in either Testnet or Regtest or...?
if self.settings.get_value(SettingsConstants.SETTING__NETWORK) in [SettingsConstants.TESTNET, SettingsConstants.REGTEST]:
addr_format["network"] = self.settings.get_value(SettingsConstants.SETTING__NETWORK)
else:
from seedsigner.views.view import NetworkMismatchErrorView
self.set_redirect(Destination(NetworkMismatchErrorView, view_args=dict(text=f"Current network setting ({self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK)}) doesn't match {self.derivation_path}")))
# cleanup. Note: We could leave this in place so the user can resume the
# flow, but for now we avoid complications and keep things simple.
self.controller.resume_main_flow = None
self.controller.sign_message_data = None
return
xpub = seed.get_xpub(wallet_path=self.derivation_path, network=addr_format["network"])
embit_network = embit_utils.get_embit_network_name(addr_format["network"])
self.address = embit_utils.get_single_sig_address(xpub=xpub, script_type=addr_format["script_type"], index=addr_format["index"], is_change=addr_format["is_change"], embit_network=embit_network)
def run(self):
from seedsigner.gui.screens.seed_screens import SeedSignMessageConfirmAddressScreen
selected_menu_num = self.run_screen(
SeedSignMessageConfirmAddressScreen,
derivation_path=self.derivation_path,
address=self.address,
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
# User clicked "Sign Message"
return Destination(SeedSignMessageSignedMessageQRView)
class SeedSignMessageSignedMessageQRView(View):
"""
Displays the signed message as a QR code.
"""
def __init__(self):
super().__init__()
data = self.controller.sign_message_data
self.seed_num = data["seed_num"]
seed = self.controller.get_seed(self.seed_num)
derivation_path = data["derivation_path"]
message: str = data["message"]
self.signed_message = embit_utils.sign_message(seed_bytes=seed.seed_bytes, derivation=derivation_path, msg=message.encode())
def run(self):
qr_encoder = EncodeQR(qr_type=QRType.SIGN_MESSAGE, signed_message=self.signed_message)
self.run_screen(
QRDisplayScreen,
qr_encoder=qr_encoder,
)
# cleanup
self.controller.resume_main_flow = None
self.controller.sign_message_data = None
# Exiting/Canceling the QR display screen always returns Home
return Destination(MainMenuView, skip_current_view=True)
+90 -7
View File
@@ -4,7 +4,7 @@ from typing import Type
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants
from seedsigner.gui.screens import RET_CODE__POWER_BUTTON, RET_CODE__BACK_BUTTON
from seedsigner.gui.screens.screen import BaseScreen, DireWarningScreen, LargeButtonScreen, PowerOffScreen, PowerOffNotRequiredScreen, ResetScreen, WarningScreen
from seedsigner.models.settings import Settings
from seedsigner.models.settings import Settings, SettingsConstants
from seedsigner.models.threads import BaseThread
@@ -42,7 +42,12 @@ class BackStackView:
"Cancel" - End task and return to entry point (destructive)
"""
class View:
def __init__(self) -> None:
def _initialize(self):
"""
Whether the View is a regular class initialized by __init__() or a dataclass
initialized by __post_init__(), this method will be called to set up the View's
instance variables.
"""
# Import here to avoid circular imports
from seedsigner.controller import Controller
from seedsigner.gui import Renderer
@@ -56,7 +61,44 @@ class View:
self.canvas_height = self.renderer.canvas_height
self.screen = None
self._redirect: 'Destination' = None
def __init__(self):
self._initialize()
def __post_init__(self):
self._initialize()
@property
def has_redirect(self) -> bool:
if not hasattr(self, '_redirect'):
# Easy for a View to forget to call super().__init__()
raise Exception(f"{self.__class__.__name__} did not call super().__init__()")
return self._redirect is not None
def set_redirect(self, destination: 'Destination'):
"""
Enables early `__init__()` / `__post_init__()` logic to redirect away from the
current View.
Set a redirect Destination and then immediately `return` to exit `__init__()` or
`__post_init__()`. When the `Destination.run()` is called, it will see the redirect
and immediately return that new Destination to the Controller without running
the View's `run()`.
"""
# Always insure skip_current_view is set for a redirect
destination.skip_current_view = True
self._redirect = destination
def get_redirect(self) -> 'Destination':
return self._redirect
def run_screen(self, Screen_cls: Type[BaseScreen], **kwargs) -> int | str:
"""
@@ -108,6 +150,8 @@ class Destination:
def _run_view(self):
if self.view.has_redirect:
return self.view.get_redirect()
return self.view.run()
@@ -262,14 +306,14 @@ class NotYetImplementedView(View):
@dataclass
class ErrorView(View):
"""
"""
title: str = "Error"
show_back_button: bool = True
status_headline: str = None
text: str = None
button_text: str = None
next_destination: Destination = Destination(MainMenuView, clear_history=True)
def run(self):
self.run_screen(
WarningScreen,
@@ -277,15 +321,35 @@ class ErrorView(View):
status_headline=self.status_headline,
text=self.text,
button_data=[self.button_text],
show_back_button=self.show_back_button,
)
return self.next_destination
@dataclass
class NetworkMismatchErrorView(ErrorView):
title: str = "Network Mismatch"
show_back_button: bool = False
button_text: str = "Change Settings"
next_destination: Destination = None
def __post_init__(self):
super().__post_init__()
if not self.text:
self.text = f"Current network setting ({self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK)}) doesn't match current action."
if not self.next_destination:
from seedsigner.views.settings_views import SettingsEntryUpdateSelectionView
self.next_destination = Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=SettingsConstants.SETTING__NETWORK), clear_history=True)
@dataclass
class UnhandledExceptionView(View):
def __init__(self, error: list[str]):
self.error = error
error: list[str]
def run(self):
@@ -300,3 +364,22 @@ class UnhandledExceptionView(View):
)
return Destination(MainMenuView, clear_history=True)
@dataclass
class OptionDisabledView(View):
error_msg: str
def run(self):
WarningScreen(
title="Option Disabled",
status_headline=None,
text=self.error_msg,
button_data=["OK"],
show_back_button=False,
allow_text_overflow=True, # Fit what we can, let the rest go off the edges
).display()
return Destination(MainMenuView, clear_history=True)
+9
View File
@@ -151,6 +151,15 @@ class FlowTest(BaseTest):
# View.run() call below.
sequence.pop(0)
if destination.view.has_redirect:
# TODO: Migrate all View redirects to use `View.set_redirect()`
# in their `__init__()` rather than `run()` and then refactor
# here to explicitly require `has_redirect` to be True.
# For now: Support the newer `set_redirect()` routing while
# still letting redirects get returned by `View.run()` further
# below.
return destination.view.get_redirect()
# Some Views reach into their Screen's variables directly (e.g.
# Screen.buttons to preserve the scroll position), so we need to mock out the
# Screen instance that is created by the View.
+39 -27
View File
@@ -1,7 +1,7 @@
import embit
import os
import sys
from mock import Mock, patch, MagicMock
from mock import Mock, MagicMock
# Prevent importing modules w/Raspi hardware dependencies.
# These must precede any SeedSigner imports.
@@ -15,16 +15,13 @@ sys.modules['seedsigner.hardware.microsd'] = MagicMock()
from seedsigner.controller import Controller
from seedsigner.gui.renderer import Renderer
from seedsigner.hardware.buttons import HardwareButtons
from seedsigner.hardware.camera import Camera
from seedsigner.models.decode_qr import DecodeQR
from seedsigner.models.qr_type import QRType
from seedsigner.models.seed import Seed
from seedsigner.models.settings import Settings
from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
from seedsigner.views import (MainMenuView, PowerOptionsView, RestartView, NotYetImplementedView, UnhandledExceptionView,
psbt_views, scan_views, seed_views, settings_views, tools_views)
from seedsigner.views.view import View
psbt_views, seed_views, settings_views, tools_views)
from seedsigner.views.view import NetworkMismatchErrorView, PowerOffView, View
from .utils import ScreenshotComplete, ScreenshotRenderer
@@ -47,6 +44,9 @@ def test_generate_screenshots(target_locale):
Renderer.configure_instance = Mock()
Renderer.get_instance = Mock(return_value=screenshot_renderer)
# Additional mocks needed
PowerOffView.PowerOffThread = Mock() # Don't let this View actually send the `shutdown` command!
controller = Controller.get_instance()
# Set up some test data that we'll need in the `Controller` for certain Views
@@ -71,21 +71,14 @@ def test_generate_screenshots(target_locale):
# Multisig wallet descriptor for the multisig in the above PSBT
MULTISIG_WALLET_DESCRIPTOR = """wsh(sortedmulti(1,[22bde1a9/48h/1h/0h/2h]tpubDFfsBrmpj226ZYiRszYi2qK6iGvh2vkkghfGB2YiRUVY4rqqedHCFEgw12FwDkm7rUoVtq9wLTKc6BN2sxswvQeQgp7m8st4FP8WtP8go76/{0,1}/*,[73c5da0a/48h/1h/0h/2h]tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ/{0,1}/*))#3jhtf6yx"""
controller.multisig_wallet_descriptor = embit.descriptor.Descriptor.from_string(MULTISIG_WALLET_DESCRIPTOR)
def screencap_view(view_cls: View, view_name: str, view_args: dict={}):
screenshot_renderer.set_screenshot_filename(f"{view_name}.png")
try:
print(f"Running {view_name}")
view_cls(**view_args).run()
except ScreenshotComplete:
# Slightly hacky way to exit ScreenshotRenderer as expected
pass
print(f"Completed {view_name}")
except Exception as e:
# Something else went wrong
print(repr(e))
raise e
# Message signing data
controller.sign_message_data = {
"seed_num": 0,
"derivation_path": "m/84h/0h/0h/0/0",
"message": "I attest that I control this bitcoin address blah blah blah",
}
# Automatically populate all Settings options Views
settings_views_list = []
settings_views_list.append(settings_views.SettingsMenuView)
@@ -108,10 +101,12 @@ def test_generate_screenshots(target_locale):
MainMenuView,
PowerOptionsView,
RestartView,
#PowerOffView # this test is too real; pi will power-off
PowerOffView,
NotYetImplementedView,
(UnhandledExceptionView, dict(error=UnhandledExceptionViewFood)),
(settings_views.SettingsIngestSettingsQRView, dict(data="settings::v1 name=factory_reset"))
(settings_views.SettingsIngestSettingsQRView, dict(data="settings::v1 name=factory_reset")),
NetworkMismatchErrorView,
],
"Seed Views": [
@@ -164,6 +159,9 @@ def test_generate_screenshots(target_locale):
seed_views.LoadMultisigWalletDescriptorView,
seed_views.MultisigWalletDescriptorView,
(seed_views.SeedDiscardView, dict(seed_num=0)),
seed_views.SeedSignMessageConfirmMessageView,
seed_views.SeedSignMessageConfirmAddressView,
],
"PSBT Views": [
psbt_views.PSBTSelectSeedView, # this will fail, be rerun below
@@ -203,12 +201,25 @@ def test_generate_screenshots(target_locale):
"Settings Views": settings_views_list,
}
screenshot_renderer.set_screenshot_path(screenshot_root)
readme = f"""# SeedSigner Screenshots\n"""
def screencap_view(view_cls: View, view_name: str, view_args: dict={}):
screenshot_renderer.set_screenshot_filename(f"{view_name}.png")
try:
print(f"Running {view_name}")
view_cls(**view_args).run()
except ScreenshotComplete:
# Slightly hacky way to exit ScreenshotRenderer as expected
pass
print(f"Completed {view_name}")
except Exception as e:
# Something else went wrong
print(repr(e))
raise e
for section_name, screenshot_list in screenshot_sections.items():
subdir = section_name.lower().replace(" ", "_")
screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, subdir))
readme += "\n\n---\n\n"
readme += f"## {section_name}\n\n"
readme += """<table style="border: 0;">"""
@@ -227,14 +238,15 @@ def test_generate_screenshots(target_locale):
screencap_view(view_cls, view_name, view_args)
readme += """ <table align="left" style="border: 1px solid gray;">"""
readme += f"""<tr><td align="center">{view_name}<br/><br/><img src="{view_name}.png"></td></tr>"""
readme += f"""<tr><td align="center">{view_name}<br/><br/><img src="{subdir}/{view_name}.png"></td></tr>"""
readme += """</table>\n"""
readme += "</td></tr></table>"
# many screens don't work, leaving a missing image, re-run here for now
controller.psbt_seed = None
screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, "psbt_views"))
screencap_view(psbt_views.PSBTSelectSeedView, 'PSBTSelectSeedView', {})
with open(os.path.join(screenshot_renderer.screenshot_path, "README.md"), 'w') as readme_file:
with open(os.path.join(screenshot_root, "README.md"), 'w') as readme_file:
readme_file.write(readme)
+65
View File
@@ -325,3 +325,68 @@ def test_get_multisig_address():
print(f' {func.__name__}(descriptor=Descriptor.from_string("{descriptor}"), index={args[1]}, is_change={args[2]}, embit_network="{args[3]}") raises Exception"')
func(descriptor=descriptor, index=args[1], is_change=args[2], embit_network=args[3])
def test_parse_derivation_path():
# Shouldn't care if input uses "'" or "h"
derivation_path = "m/84'/0'/0'/0/0"
result = embit_utils.parse_derivation_path(derivation_path)
assert(result["script_type"] == SC.NATIVE_SEGWIT)
assert(result["network"] == SC.MAINNET)
result = embit_utils.parse_derivation_path(derivation_path.replace("'", "h"))
assert(result["script_type"] == SC.NATIVE_SEGWIT)
assert(result["network"] == SC.MAINNET)
# Now exhaustively test supported permutations
vectors_args = {
(SC.MAINNET, SC.NATIVE_SEGWIT, False): "m/84'/0'/0'/0/5",
(SC.TESTNET, SC.NATIVE_SEGWIT, False): "m/84'/1'/0'/0/5",
(SC.REGTEST, SC.NATIVE_SEGWIT, False): "m/84'/1'/0'/0/5",
(SC.MAINNET, SC.NATIVE_SEGWIT, True): "m/84'/0'/0'/1/5",
(SC.TESTNET, SC.NATIVE_SEGWIT, True): "m/84'/1'/0'/1/5",
(SC.REGTEST, SC.NATIVE_SEGWIT, True): "m/84'/1'/0'/1/5",
(SC.MAINNET, SC.NESTED_SEGWIT, False): "m/49'/0'/0'/0/5",
(SC.TESTNET, SC.NESTED_SEGWIT, False): "m/49'/1'/0'/0/5",
(SC.REGTEST, SC.NESTED_SEGWIT, False): "m/49'/1'/0'/0/5",
(SC.MAINNET, SC.NESTED_SEGWIT, True): "m/49'/0'/0'/1/5",
(SC.TESTNET, SC.NESTED_SEGWIT, True): "m/49'/1'/0'/1/5",
(SC.REGTEST, SC.NESTED_SEGWIT, True): "m/49'/1'/0'/1/5",
(SC.MAINNET, SC.TAPROOT, False): "m/86'/0'/0'/0/5",
(SC.TESTNET, SC.TAPROOT, False): "m/86'/1'/0'/0/5",
(SC.REGTEST, SC.TAPROOT, False): "m/86'/1'/0'/0/5",
(SC.MAINNET, SC.TAPROOT, True): "m/86'/0'/0'/1/5",
(SC.TESTNET, SC.TAPROOT, True): "m/86'/1'/0'/1/5",
(SC.REGTEST, SC.TAPROOT, True): "m/86'/1'/0'/1/5",
# Try a typical custom derivation path (Unchained Capital)
(SC.MAINNET, SC.CUSTOM_DERIVATION, False): "m/45'/0'/0'/0/5",
(SC.TESTNET, SC.CUSTOM_DERIVATION, False): "m/45'/1'/0'/0/5",
(SC.REGTEST, SC.CUSTOM_DERIVATION, False): "m/45'/1'/0'/0/5",
(SC.MAINNET, SC.CUSTOM_DERIVATION, True): "m/45'/0'/0'/1/5",
(SC.TESTNET, SC.CUSTOM_DERIVATION, True): "m/45'/1'/0'/1/5",
(SC.REGTEST, SC.CUSTOM_DERIVATION, True): "m/45'/1'/0'/1/5",
# CRAZY custom derivation path
(None, SC.CUSTOM_DERIVATION, False): "m/879345978543'/908327034508534983495'/9085098430894380959043'/0/5",
}
for expected_result, derivation_path in vectors_args.items():
actual_result = embit_utils.parse_derivation_path(derivation_path)
if expected_result[0] == SC.MAINNET:
assert(actual_result["network"] == expected_result[0])
assert(actual_result["clean_match"] is True)
elif expected_result[0] is None:
assert(actual_result["network"] is None)
assert(actual_result["clean_match"] is False)
else:
# Testnet and regtest are returned as a list since the parser can't tell which is intended
assert(expected_result[0] in actual_result["network"])
assert(actual_result["clean_match"] is True)
assert(actual_result["script_type"] == expected_result[1])
assert(actual_result["is_change"] == expected_result[2])
assert(actual_result["index"] == int(derivation_path.split("/")[-1]))
+149 -3
View File
@@ -1,12 +1,14 @@
import pytest
# Must import test base before the Controller
from base import BaseTest, FlowTest, FlowStep
from base import FlowTestRunScreenNotExecutedException, FlowTestInvalidButtonDataSelectionException
import pytest
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON
from seedsigner.models.settings import SettingsConstants
from seedsigner.models.seed import Seed
from seedsigner.views.view import MainMenuView
from seedsigner.views import seed_views, scan_views
from seedsigner.views.view import MainMenuView, View, NetworkMismatchErrorView
from seedsigner.views import seed_views, scan_views, settings_views
@@ -288,3 +290,147 @@ class TestSeedFlows(FlowTest):
FlowStep(seed_views.LoadSeedView),
]
)
class TestMessageSigningFlows(FlowTest):
MAINNET_DERIVATION_PATH = "m/84h/0h/0h/0/0"
TESTNET_DERIVATION_PATH = "m/84h/1h/0h/0/0"
SHORT_MESSAGE = "I attest that I control this bitcoin address blah blah blah"
MULTIPAGE_MESSAGE = """Chancellor on brink of second bailout for banks
Billions may be needed as lending squeeze tightens
Alistair Darling has been forced to consider a second bailout for banks as the lending drought worsens.
The Chancellor will decide within weeks whether to pump billions more into the economy as evidence mounts that the £37 billion part-nationalisation last year has failed to keep credit flowing. Options include cash injections, offering banks cheaper state guarantees to raise money privately or buying up “toxic assets”, The Times has learnt."""
def load_seed_into_decoder(self, view: scan_views.ScanView):
view.decoder.add_data("0000" * 11 + "0003")
def load_signmessage_into_decoder(self, view:View, derivation_path: str, message: str):
view.decoder.add_data(f"signmessage {derivation_path} ascii:{message}")
def load_short_message_into_decoder(self, view: View):
self.load_signmessage_into_decoder(view, self.MAINNET_DERIVATION_PATH, self.SHORT_MESSAGE)
def load_testnet_message_into_decoder(self, view: View):
self.load_signmessage_into_decoder(view, self.TESTNET_DERIVATION_PATH, self.SHORT_MESSAGE)
def load_multipage_message_into_decoder(self, view: View):
self.load_signmessage_into_decoder(view, self.MAINNET_DERIVATION_PATH, self.MULTIPAGE_MESSAGE)
def inject_mesage_as_paged_message(self, view: View):
# Because the Screen won't actually run, we have to do the Screen's work here
from seedsigner.gui.components import reflow_text_into_pages, GUIConstants
paged = reflow_text_into_pages(
text=self.controller.sign_message_data["message"],
width=240 - 2*GUIConstants.EDGE_PADDING,
height=240 - GUIConstants.TOP_NAV_HEIGHT - 3*GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT,
)
self.controller.sign_message_data["paged_message"] = paged
def test_sign_message_flow(self):
"""
Should scan a `signmessage` QR and complete the message review, address review,
and signing flow.
"""
# Ensure message signing is enabled
self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__ENABLED)
# Scenario 1: Load the mesage first, then the seed
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
FlowStep(scan_views.ScanView, before_run=self.load_short_message_into_decoder), # simulate read message QR; ret val is ignored
FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True),
FlowStep(seed_views.SeedSelectSeedView, button_data_selection=seed_views.SeedSelectSeedView.SCAN_SEED),
FlowStep(scan_views.ScanView, before_run=self.load_seed_into_decoder), # simulate read SeedQR; ret val is ignored
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
FlowStep(seed_views.SeedSignMessageConfirmMessageView, before_run=self.inject_mesage_as_paged_message, screen_return_value=0),
FlowStep(seed_views.SeedSignMessageConfirmAddressView, screen_return_value=0),
FlowStep(seed_views.SeedSignMessageSignedMessageQRView, screen_return_value=0),
FlowStep(MainMenuView),
])
# Scenario 2: Scan the seed first, then select Sign Message
self.controller.discard_seed(0)
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
FlowStep(scan_views.ScanView, before_run=self.load_seed_into_decoder), # simulate read SeedQR; ret val is ignored
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.SIGN_MESSAGE),
FlowStep(scan_views.ScanView, before_run=self.load_short_message_into_decoder), # simulate read message QR; ret val is ignored
FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True),
FlowStep(seed_views.SeedSignMessageConfirmMessageView, before_run=self.inject_mesage_as_paged_message, screen_return_value=0),
FlowStep(seed_views.SeedSignMessageConfirmAddressView, screen_return_value=0),
FlowStep(seed_views.SeedSignMessageSignedMessageQRView, screen_return_value=0),
FlowStep(MainMenuView),
])
# Scenario 3: Load a long, multipage message
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
FlowStep(scan_views.ScanView, before_run=self.load_multipage_message_into_decoder), # simulate read message QR; ret val is ignored
FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True),
FlowStep(seed_views.SeedSelectSeedView, button_data_selection=seed_views.SeedSelectSeedView.SCAN_SEED),
FlowStep(scan_views.ScanView, before_run=self.load_seed_into_decoder), # simulate read SeedQR; ret val is ignored
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
FlowStep(seed_views.SeedSignMessageConfirmMessageView, before_run=self.inject_mesage_as_paged_message, screen_return_value=0), # page 1/5
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 2/5
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 3/5
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 4/5
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 5/5
# Arrive at the address confirmation, then go backwards to re-review the paged message
FlowStep(seed_views.SeedSignMessageConfirmAddressView, screen_return_value=RET_CODE__BACK_BUTTON), # then back to page 5/5
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=RET_CODE__BACK_BUTTON), # back to page 4/5
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=RET_CODE__BACK_BUTTON), # back to page 3/5
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=RET_CODE__BACK_BUTTON), # back to page 2/5
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=RET_CODE__BACK_BUTTON), # back to page 1/5
# Now proceed forward again to the end
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 1/5
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 2/5
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 3/5
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 4/5
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 5/5
FlowStep(seed_views.SeedSignMessageConfirmAddressView, screen_return_value=0),
FlowStep(seed_views.SeedSignMessageSignedMessageQRView, screen_return_value=0),
FlowStep(MainMenuView),
])
def test_sign_message_network_mismatch_flow(self):
"""
Should redirect to NetworkMismatchErrorView if a message's derivation path network doesn't match the current network.
The error view should then forward to the Network Settings update View.
"""
# Ensure message signing is enabled
self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__ENABLED)
# Ensure we're configured for mainnet
self.settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.MAINNET)
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
FlowStep(scan_views.ScanView, before_run=self.load_testnet_message_into_decoder), # simulate read message QR; ret val is ignored
FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True),
FlowStep(seed_views.SeedSelectSeedView, button_data_selection=seed_views.SeedSelectSeedView.SCAN_SEED),
FlowStep(scan_views.ScanView, before_run=self.load_seed_into_decoder), # simulate read SeedQR; ret val is ignored
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
FlowStep(seed_views.SeedSignMessageConfirmMessageView, before_run=self.inject_mesage_as_paged_message, screen_return_value=0),
FlowStep(seed_views.SeedSignMessageConfirmAddressView, is_redirect=True),
FlowStep(NetworkMismatchErrorView),
FlowStep(settings_views.SettingsEntryUpdateSelectionView),
])