diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 2ec8c570..0d8c3cdd 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -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 diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index 7aafcbcb..d4274a11 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -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 diff --git a/src/seedsigner/gui/renderer.py b/src/seedsigner/gui/renderer.py index 8aaaef4a..9534a774 100644 --- a/src/seedsigner/gui/renderer.py +++ b/src/seedsigner/gui/renderer.py @@ -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() - - - - diff --git a/src/seedsigner/gui/screens/screen.py b/src/seedsigner/gui/screens/screen.py index 306ba968..d0005fb9 100644 --- a/src/seedsigner/gui/screens/screen.py +++ b/src/seedsigner/gui/screens/screen.py @@ -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, )) diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index ac2230b8..e1b88db0 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -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) diff --git a/src/seedsigner/gui/screens/tools_screens.py b/src/seedsigner/gui/screens/tools_screens.py index b2188e0d..d420baab 100644 --- a/src/seedsigner/gui/screens/tools_screens.py +++ b/src/seedsigner/gui/screens/tools_screens.py @@ -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, )) diff --git a/src/seedsigner/helpers/embit_utils.py b/src/seedsigner/helpers/embit_utils.py index bdd95b7f..c33153e8 100644 --- a/src/seedsigner/helpers/embit_utils.py +++ b/src/seedsigner/helpers/embit_utils.py @@ -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() diff --git a/src/seedsigner/models/decode_qr.py b/src/seedsigner/models/decode_qr.py index f5e30302..a020c312 100644 --- a/src/seedsigner/models/decode_qr.py +++ b/src/seedsigner/models/decode_qr.py @@ -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) diff --git a/src/seedsigner/models/encode_qr.py b/src/seedsigner/models/encode_qr.py index 0e6493d5..6efa715d 100644 --- a/src/seedsigner/models/encode_qr.py +++ b/src/seedsigner/models/encode_qr.py @@ -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): diff --git a/src/seedsigner/models/qr_type.py b/src/seedsigner/models/qr_type.py index 7ef0ca67..6ec1f5ec 100644 --- a/src/seedsigner/models/qr_type.py +++ b/src/seedsigner/models/qr_type.py @@ -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" diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 7d9941f3..5c9e4b99 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -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", diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index 48139065..b9cfa064 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -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) diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index 2774a3de..2b83d03b 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -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) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 0570b143..a4236cb0 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -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) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 3bf92bee..dfd59eb3 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -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) diff --git a/tests/base.py b/tests/base.py index 708d64f7..0628fb1a 100644 --- a/tests/base.py +++ b/tests/base.py @@ -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. diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 037a7905..b498fa2e 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -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 += """""" @@ -227,14 +238,15 @@ def test_generate_screenshots(target_locale): screencap_view(view_cls, view_name, view_args) readme += """
""" - readme += f"""""" + readme += f"""""" readme += """
{view_name}

{view_name}

\n""" readme += "" # 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) diff --git a/tests/test_embit_utils.py b/tests/test_embit_utils.py index 2e462bc2..3f22b93d 100644 --- a/tests/test_embit_utils.py +++ b/tests/test_embit_utils.py @@ -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])) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 9d801884..fde32231 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -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), + ])