diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index 51c8b0f7..1e7f18a6 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -313,7 +313,6 @@ class TextArea(BaseComponent): ) # Calculate the actual font height from the "baseline" anchor ("_s") - height_calc_start = time() font = Fonts.get_font(self.font_name, self.font_size) # Note: from the baseline anchor, `top` is a negative number while `bottom` @@ -325,8 +324,6 @@ class TextArea(BaseComponent): # Initialize the text rendering relative to the baseline self.text_y = self.text_height_above_baseline - print(f"{time() - height_calc_start}s to calc self.text_font_height") - # 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) @@ -1485,15 +1482,31 @@ def reflow_text_for_width(text: str, -def break_lines_into_pages(lines: list[str], +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) -> list[str]: + line_spacer: int = GUIConstants.BODY_LINE_SPACING, + allow_text_overflow: bool=False) -> list[str]: """ - After splitting a long text into width-limited individual text lines, now calculate - how many lines will fit on a "page" and group accordingly. + 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 via the "baseline" anchor ("_s") (left, top, right, bottom) = font.getbbox("Agjpqy", anchor="ls") @@ -1503,7 +1516,7 @@ def break_lines_into_pages(lines: list[str], # 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) + bottom: + if height > font_height_above_baseline * i + line_spacer * (i-1) + font_height_below_baseline: lines_per_page = i else: break diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index bb213bbc..069bd04d 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -11,7 +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, reflow_text_for_width, break_lines_into_pages) + IconTextLine, SeedSignerIconConstants, TextArea, GUIConstants, reflow_text_into_pages) from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay from seedsigner.hardware.buttons import HardwareButtons, HardwareButtonsConstants @@ -1512,22 +1512,10 @@ class SeedSignMessageConfirmMessageScreen(ButtonListScreen): self.sign_message_data = Controller.get_instance().sign_message_data if "paged_message" not in self.sign_message_data: - reflowed_lines_dicts = reflow_text_for_width( + paged = reflow_text_into_pages( text=self.sign_message_data["message"], width=renderer.canvas_width - 2*GUIConstants.EDGE_PADDING, - font_name=GUIConstants.BODY_FONT_NAME, - font_size=GUIConstants.BODY_FONT_SIZE, - allow_text_overflow=True, - ) - - lines = [] - for line_dict in reflowed_lines_dicts: - lines.append(line_dict["text"]) - print(f"""{line_dict["text_width"]:3}: {line_dict["text"]}""") - - paged = break_lines_into_pages( - lines=lines, - height=message_height + height=message_height, ) self.sign_message_data["paged_message"] = paged diff --git a/src/seedsigner/helpers/embit_utils.py b/src/seedsigner/helpers/embit_utils.py index 66997527..c33153e8 100644 --- a/src/seedsigner/helpers/embit_utils.py +++ b/src/seedsigner/helpers/embit_utils.py @@ -113,6 +113,13 @@ def get_embit_network_name(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") @@ -136,14 +143,18 @@ def parse_derivation_path(derivation_path: str) -> dict: 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]) - if details["script_type"] and details["network"] and sections[3] == "0h": - details["clean_match"] = True - else: - details["clean_match"] = False + 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 diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index c7d3c8e3..6d0dea85 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -76,6 +76,12 @@ class SeedSelectSeedView(View): * `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", FontAwesomeIconConstants.QRCODE) + TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + button_data = [] + + def __init__(self, flow: str = Controller.FLOW__VERIFY_SINGLESIG_ADDR): super().__init__() self.flow = flow @@ -84,11 +90,6 @@ class SeedSelectSeedView(View): def run(self): seeds = self.controller.storage.seeds - SCAN_SEED = ("Scan a seed", FontAwesomeIconConstants.QRCODE) - TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - button_data = [] - if self.flow == Controller.FLOW__VERIFY_SINGLESIG_ADDR: title = "Verify Address" if not seeds: @@ -106,24 +107,26 @@ class SeedSelectSeedView(View): else: raise Exception(f"Unsupported `flow` specified: {self.flow}") + self.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, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) + self.button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) + + self.button_data.append(self.SCAN_SEED) + self.button_data.append(self.TYPE_12WORD) + self.button_data.append(self.TYPE_24WORD) - button_data.append(SCAN_SEED) - button_data.append(TYPE_12WORD) - button_data.append(TYPE_24WORD) - - selected_menu_num = seed_screens.SeedSelectSeedScreen( + selected_menu_num = self.run_screen( + seed_screens.SeedSelectSeedScreen, title=title, text=text, is_button_text_centered=False, - button_data=button_data - ).display() + button_data=self.button_data + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -140,13 +143,13 @@ class SeedSelectSeedView(View): self.controller.resume_main_flow = self.flow - if button_data[selected_menu_num] == SCAN_SEED: + if self.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 [TYPE_12WORD, TYPE_24WORD]: + elif self.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] == TYPE_12WORD: + if self.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) @@ -1995,7 +1998,7 @@ class SeedSignMessageStartView(View): class SeedSignMessageConfirmMessageView(View): def __init__(self, page_num: int = 0): super().__init__() - self.page_num = page_num + self.page_num = page_num # Note: zero-indexed numbering! self.data = self.controller.sign_message_data self.seed_num = self.data.get("seed_num") @@ -2012,7 +2015,7 @@ class SeedSignMessageConfirmMessageView(View): ) if selected_menu_num == RET_CODE__BACK_BUTTON: - if self.page_num == 1: + if self.page_num == 0: # We're exiting this flow entirely self.controller.resume_main_flow = None self.controller.sign_message_data = None @@ -2021,7 +2024,7 @@ class SeedSignMessageConfirmMessageView(View): # 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, skip_current_view=True) + return Destination(SeedSignMessageConfirmAddressView) else: return Destination(SeedSignMessageConfirmMessageView, view_args=dict(page_num=self.page_num + 1)) 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..f7964e96 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -3,9 +3,10 @@ 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.view import MainMenuView, View from seedsigner.views import seed_views, scan_views @@ -288,3 +289,104 @@ class TestSeedFlows(FlowTest): FlowStep(seed_views.LoadSeedView), ] ) + + + + def test_sign_message_flow(self): + """ + """ + # Ensure message signing is enabled + self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__ENABLED) + + def load_seed_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("0000" * 11 + "0003") + + derivation_path = "m/84h/0h/0h/0/0" + 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_standard_message_into_decoder(view: View): + view.decoder.add_data(f"signmessage {derivation_path} ascii:{message}") + + def load_multipage_message_into_decoder(view: View): + view.decoder.add_data(f"signmessage {derivation_path} ascii:{multipage_message}") + + def inject_mesage_as_paged_message(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 + + # 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=load_standard_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=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=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=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=load_standard_message_into_decoder), # simulate read message QR; ret val is ignored + FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True), + FlowStep(seed_views.SeedSignMessageConfirmMessageView, before_run=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=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=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=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), + ])