From 0cab16dd77e876b5b82bcca1e651eee3982a0d13 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 23 Oct 2022 12:42:48 -0500 Subject: [PATCH 01/19] Adds QR-based message signing using Specter Desktop QR format --- src/seedsigner/controller.py | 3 + src/seedsigner/gui/screens/seed_screens.py | 41 ++++- src/seedsigner/helpers/embit_utils.py | 32 +++- src/seedsigner/models/decode_qr.py | 73 +++++++- src/seedsigner/models/encode_qr.py | 49 ++++-- src/seedsigner/models/qr_type.py | 2 + src/seedsigner/views/psbt_views.py | 2 + src/seedsigner/views/scan_views.py | 12 ++ src/seedsigner/views/seed_views.py | 189 ++++++++++++++++++++- 9 files changed, 375 insertions(+), 28 deletions(-) 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/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index ac2230b8..1694bfd9 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -1326,7 +1326,7 @@ class AddressVerificationSigTypeScreen(ButtonListScreen): @dataclass -class SeedSingleSigAddressVerificationSelectSeedScreen(ButtonListScreen): +class SeedSelectSeedScreen(ButtonListScreen): text: str = "" def __post_init__(self): @@ -1482,6 +1482,8 @@ class MultisigWalletDescriptorScreen(ButtonListScreen): allow_text_overflow=True, )) + + @dataclass class SeedBIP85SelectChildIndexScreen(KeyboardScreen): def __post_init__(self): @@ -1495,3 +1497,40 @@ class SeedBIP85SelectChildIndexScreen(KeyboardScreen): self.show_save_button = True super().__post_init__() + + + +@dataclass +class SeedSignMessageScreen(ButtonListScreen): + derivation_path: int = None + message: int = None + + def __post_init__(self): + self.title = "Sign Message" + self.is_bottom_list = True + self.is_button_text_centered = True + self.button_data = ["Sign"] + super().__post_init__() + + derivation_path_display = IconTextLine( + icon_name=SeedSignerCustomIconConstants.PATH, + label_text="derivation path", + value_text=self.derivation_path, + is_text_centered=True, + screen_y=self.top_nav.height, + ) + self.components.append(derivation_path_display) + + message_display = TextArea( + text=self.message, + is_text_centered=False, + ) + start_y = derivation_path_display.screen_y + derivation_path_display.height + end_y = self.buttons[0].screen_y + + message_display.screen_y = start_y + int((end_y - start_y - message_display.height)/2) + if message_display.screen_y < start_y: + # full message text doesn't fit; let it run long. + # TODO: Add scrollable UI so whole message will be viewable. + message_display.screen_y = start_y + GUIConstants.COMPONENT_PADDING + self.components.append(message_display) diff --git a/src/seedsigner/helpers/embit_utils.py b/src/seedsigner/helpers/embit_utils.py index bdd95b7f..59404eb6 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,26 @@ 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 sign_message(seed_bytes: bytes, derivation, 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/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index 48139065..b2c70f67 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -177,6 +177,8 @@ class PSBTUnsupportedScriptTypeWarningView(View): skip_current_view=True, # Prevent going BACK to WarningViews ) + + class PSBTNoChangeWarningView(View): def run(self): selected_menu_num = WarningScreen( diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index 2774a3de..f394f81a 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -135,6 +135,18 @@ class ScanView(View): } ) + elif self.decoder.is_sign_message: + 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(NotYetImplementedView) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 0570b143..f5ecdd98 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -68,6 +68,90 @@ class SeedsMenuView(View): +class SeedSelectSeedView(View): + """ + 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). + """ + def __init__(self, flow: str = Controller.FLOW__VERIFY_SINGLESIG_ADDR): + super().__init__() + self.flow = flow + + + 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: + 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}") + + 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")) + + button_data.append(SCAN_SEED) + button_data.append(TYPE_12WORD) + button_data.append(TYPE_24WORD) + + selected_menu_num = seed_screens.SeedSelectSeedScreen( + title=title, + 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 + 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: + return Destination(SeedSignMessageConfirmView, view_args=view_args) + + self.controller.resume_main_flow = self.flow + + if button_data[selected_menu_num] == SCAN_SEED: + from seedsigner.views.scan_views import ScanView + return Destination(ScanView) + + 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) + + + """**************************************************************************** Loading seeds, passphrases, etc ****************************************************************************""" @@ -290,7 +374,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,7 +426,8 @@ class SeedOptionsView(View): VERIFY_ADDRESS = "Verify Addr" EXPORT_XPUB = "Export Xpub" EXPLORER = "Address Explorer" - BACKUP = ("Backup Seed", None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT) + SIGN_MESSAGE = "Sign Message" + BACKUP = ("Backup Seed", None, None, None, SeedSignerIconConstants.SMALL_CHEVRON_RIGHT) BIP85_CHILD_SEED = "BIP-85 Child Seed" DISCARD = ("Discard Seed", None, None, "red") @@ -368,6 +453,9 @@ 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) + if self.controller.resume_main_flow == Controller.FLOW__SIGN_MESSAGE: + return Destination(SeedSignMessageConfirmView, view_args=dict(seed_num=self.seed_num), 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: @@ -389,6 +477,7 @@ class SeedOptionsView(View): button_data.append(self.EXPORT_XPUB) button_data.append(self.EXPLORER) + button_data.append(self.SIGN_MESSAGE) button_data.append(self.BACKUP) if self.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__ENABLED: @@ -422,6 +511,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)) @@ -1501,7 +1596,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 @@ -1773,7 +1868,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 +1950,88 @@ class MultisigWalletDescriptorView(View): return Destination(ToolsAddressExplorerAddressTypeView) return Destination(MainMenuView) + + + +"""**************************************************************************** + Sign Message Views +****************************************************************************""" +class SeedSignMessageStartView(View): + def __init__(self, derivation_path: str, message: str): + super().__init__() + self.derivation_path = derivation_path + self.message = message + self.seed_num = None + + data = self.controller.sign_message_data + if not data: + data = {} + self.controller.sign_message_data = data + data["derivation_path"] = derivation_path + data["message"] = message + + if data and "seed_num" in data: + self.seed_num = data["seed_num"] + + + def run(self): + if self.seed_num: + # We already know which seed we're signing with + return Destination(SeedSignMessageConfirmView, skip_current_view=True) + else: + return Destination(SeedSelectSeedView, view_args=dict(flow=Controller.FLOW__SIGN_MESSAGE), skip_current_view=True) + + + +class SeedSignMessageConfirmView(View): + def __init__(self, seed_num: int = None): + super().__init__() + data = self.controller.sign_message_data + self.derivation_path = data["derivation_path"] + self.message = data["message"] + + if seed_num is not None: + self.seed_num = seed_num + data["seed_num"] = seed_num + + + def run(self): + from seedsigner.gui.screens.seed_screens import SeedSignMessageScreen + selected_menu_num = SeedSignMessageScreen( + derivation_path=self.derivation_path, + message=self.message, + ).display() + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + # User clicked "Sign" + return Destination(SeedSignMessageSignedMessageView) + + + +class SeedSignMessageSignedMessageView(View): + 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) + QRDisplayScreen( + qr_encoder=qr_encoder, + ).display() + + # 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) From df3a2898cc210cf8356f7c8adec9da8adff2bd34 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 23 Oct 2022 12:58:01 -0500 Subject: [PATCH 02/19] Update seed_views.py --- src/seedsigner/views/seed_views.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index f5ecdd98..36f4e49f 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -70,7 +70,8 @@ class SeedsMenuView(View): class SeedSelectSeedView(View): """ - Prompts the user to select amongst the already-loaded seeds OR to load a seed. + 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). @@ -1957,6 +1958,11 @@ class MultisigWalletDescriptorView(View): 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 @@ -2006,11 +2012,14 @@ class SeedSignMessageConfirmView(View): return Destination(BackStackView) # User clicked "Sign" - return Destination(SeedSignMessageSignedMessageView) + return Destination(SeedSignMessageSignedMessageQRView) -class SeedSignMessageSignedMessageView(View): +class SeedSignMessageSignedMessageQRView(View): + """ + Displays the signed message as a QR code. + """ def __init__(self): super().__init__() data = self.controller.sign_message_data From f0fd86e8fdcb8125c8981ee4465ba253e2fa8710 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 11 Dec 2022 14:19:13 -0600 Subject: [PATCH 03/19] Adds Settings option for message signing; disabled by default * Adds `OptionDisabledView` warning message. * Relocates `SeedBIP85SelectChildIndexScreen` to match ordering in seed_views.py. --- src/seedsigner/gui/screens/seed_screens.py | 15 ++++++++++++++ src/seedsigner/models/settings_definition.py | 7 +++++++ src/seedsigner/views/scan_views.py | 21 +++++++++++--------- src/seedsigner/views/seed_views.py | 4 +++- src/seedsigner/views/view.py | 19 ++++++++++++++++++ 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index 1694bfd9..b1c8e40b 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -528,6 +528,21 @@ 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): 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/scan_views.py b/src/seedsigner/views/scan_views.py index f394f81a..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 @@ -136,16 +136,19 @@ class ScanView(View): ) elif self.decoder.is_sign_message: - from seedsigner.views.seed_views import SeedSignMessageStartView - qr_data = self.decoder.get_qr_data() + 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"], + 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 36f4e49f..0c6d02a2 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -478,9 +478,11 @@ class SeedOptionsView(View): button_data.append(self.EXPORT_XPUB) button_data.append(self.EXPLORER) - button_data.append(self.SIGN_MESSAGE) 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) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 3bf92bee..f3fb704c 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -300,3 +300,22 @@ class UnhandledExceptionView(View): ) return Destination(MainMenuView, clear_history=True) + + + +class OptionDisabledView(View): + def __init__(self, error_msg: str): + self.error_msg = error_msg + + + 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) From f548ef19aaf14331f95f46fa2fbb00bfa84f7be5 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 11 Dec 2022 14:28:36 -0600 Subject: [PATCH 04/19] Horizontal overflow fix --- src/seedsigner/gui/screens/seed_screens.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index b1c8e40b..7b15200b 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -1539,6 +1539,7 @@ class SeedSignMessageScreen(ButtonListScreen): message_display = TextArea( text=self.message, is_text_centered=False, + allow_text_overflow=True, ) start_y = derivation_path_display.screen_y + derivation_path_display.height end_y = self.buttons[0].screen_y From 9495442cf9a0a87e93890f11532acc1d3975bee5 Mon Sep 17 00:00:00 2001 From: Everyday Bitcoiner Date: Fri, 23 Dec 2022 21:21:01 -0800 Subject: [PATCH 05/19] Fix flow for if seed is already selected. if self.seed_num was evaluating to False for seed_num = 0. if self.seed_num is not None correctly evaluates seed_num = 0 to True. --- src/seedsigner/views/seed_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 0c6d02a2..e44e643b 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -1983,7 +1983,7 @@ class SeedSignMessageStartView(View): def run(self): - if self.seed_num: + if self.seed_num is not None: # We already know which seed we're signing with return Destination(SeedSignMessageConfirmView, skip_current_view=True) else: From bab923105f214db4062653c263e56a68c331a4d7 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Thu, 27 Jul 2023 20:50:47 -0500 Subject: [PATCH 06/19] initial page / line splitter for multi-page text --- src/seedsigner/gui/components.py | 60 ++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index 7aafcbcb..3a612d8f 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -587,6 +587,7 @@ class IconTextLine(BaseComponent): self.icon.render() + @dataclass class ToastOverlay(BaseComponent): icon_name: str = None @@ -1418,3 +1419,62 @@ def calc_bezier_curve(p1: Tuple[int,int], p2: Tuple[int,int], p3: Tuple[int,int] return points + + +def calc_multipage_text(text: str, width: int, height: int, font: ImageFont.FreeTypeFont) -> list[list[str]]: + """ + Returns a list of "pages" consisting of n lines of text each where: + * each line of text is no longer than `width` pixels (when possible) + * the n lines of text render within the specified page `height` + """ + # Source text's individual lines + lines = text.strip().split("\n") + + # Re-flowed lines (new line breaks added as needed to fit within `width`) + reflowed_lines = [] + + next_line = "" + for line in lines: + while True: + if not line.strip(): + # Skip blank lines + break + # Measure from left baseline ("ls") + # Note: getbbox() seems to ignore "\n" but doesn't affect us here. + (left, top, line_width, bottom) = font.getbbox(line, anchor="ls") + if line_width <= width: + # The line fits! + reflowed_lines.append(line) + if next_line: + line = next_line + next_line = "" + else: + break + else: + # The line doesn't fit. Find the last space and break there. + last_space = line.rfind(" ") + if last_space == -1: + # No spaces found. This is one huge word. Too bad, it'll just have + # to overflow the screen. + reflowed_lines.append(line) + if next_line: + line = next_line + next_line = "" + else: + break + else: + # Move the last word down into `next_line` + next_line = line[(last_space+1):] + " " + next_line if next_line else line[(last_space+1):] + + # Try again with the now-shorter line + line = line[:last_space] + + line_height = font.getsize("A")[1] + line_spacer = GUIConstants.BODY_LINE_SPACING + lines_per_page = math.floor((height - line_spacer) / (line_height + line_spacer)) + + pages = [] + for i in range(0, len(reflowed_lines), lines_per_page): + pages.append(reflowed_lines[i:(i+lines_per_page)]) + + return pages From bda86ff311d32495aefc21e172c25631c2d1bea2 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Thu, 27 Jul 2023 22:07:08 -0500 Subject: [PATCH 07/19] work-in-progress Messages now paged over n screens. Page calc is too slow. --- src/seedsigner/gui/components.py | 11 +++-- src/seedsigner/gui/screens/seed_screens.py | 52 ++++++++++++++++------ src/seedsigner/views/seed_views.py | 36 ++++++++------- 3 files changed, 66 insertions(+), 33 deletions(-) diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index 3a612d8f..8cffed7b 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -1421,7 +1421,7 @@ def calc_bezier_curve(p1: Tuple[int,int], p2: Tuple[int,int], p3: Tuple[int,int] -def calc_multipage_text(text: str, width: int, height: int, font: ImageFont.FreeTypeFont) -> list[list[str]]: +def calc_multipage_text(text: str, width: int, height: int, font_name: str, font_size: int) -> list[str]: """ Returns a list of "pages" consisting of n lines of text each where: * each line of text is no longer than `width` pixels (when possible) @@ -1433,6 +1433,8 @@ def calc_multipage_text(text: str, width: int, height: int, font: ImageFont.Free # Re-flowed lines (new line breaks added as needed to fit within `width`) reflowed_lines = [] + font = Fonts.get_font(font_name=font_name, size=font_size) + next_line = "" for line in lines: while True: @@ -1469,12 +1471,13 @@ def calc_multipage_text(text: str, width: int, height: int, font: ImageFont.Free # Try again with the now-shorter line line = line[:last_space] - line_height = font.getsize("A")[1] + # _, line_height = font.getsize("A") + bbox_height = bottom - top line_spacer = GUIConstants.BODY_LINE_SPACING - lines_per_page = math.floor((height - line_spacer) / (line_height + line_spacer)) + lines_per_page = math.floor(height / (bbox_height + line_spacer)) pages = [] for i in range(0, len(reflowed_lines), lines_per_page): - pages.append(reflowed_lines[i:(i+lines_per_page)]) + pages.append("\n".join(reflowed_lines[i:(i+lines_per_page)])) return pages diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index 7b15200b..d09ccc1d 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, calc_multipage_text) from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay from seedsigner.hardware.buttons import HardwareButtons, HardwareButtonsConstants @@ -543,6 +542,7 @@ class SeedBIP85SelectChildIndexScreen(KeyboardScreen): super().__post_init__() + @dataclass class SeedWordsBackupTestPromptScreen(ButtonListScreen): def __post_init__(self): @@ -1500,35 +1500,61 @@ class MultisigWalletDescriptorScreen(ButtonListScreen): @dataclass -class SeedBIP85SelectChildIndexScreen(KeyboardScreen): +class SeedSignMessageConfirmMessageScreen(ButtonListScreen): + sign_message_data: dict = None + page_num: int = None + def __post_init__(self): - self.title = "BIP-85 Index" - self.user_input = "" + renderer = Renderer.get_instance() + start_y = GUIConstants.TOP_NAV_HEIGHT + GUIConstants.COMPONENT_PADDING + end_y = renderer.canvas_height - GUIConstants.BUTTON_HEIGHT - 2*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 + if "paged_messages" not in self.sign_message_data: + self.sign_message_data["paged_messages"] = calc_multipage_text( + text=self.sign_message_data["message"], + width=renderer.canvas_width - 2*GUIConstants.EDGE_PADDING, + height=message_height, + font_name=GUIConstants.BODY_FONT_NAME, + font_size=GUIConstants.BODY_FONT_SIZE, + ) + + if self.page_num >= len(self.sign_message_data["paged_messages"]): + raise Exception("Bug in paged_messages calculation") + if len(self.sign_message_data["paged_messages"]) == 1: + self.title = "Review Message" + else: + self.title = f"""Message (pt {self.page_num + 1}/{len(self.sign_message_data["paged_messages"])})""" + 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_messages"][self.page_num], + is_text_centered=False, + allow_text_overflow=True, + screen_y=start_y, + ) + self.components.append(message_display) + @dataclass -class SeedSignMessageScreen(ButtonListScreen): +class SeedSignMessageConfirmAddressScreen(ButtonListScreen): derivation_path: int = None message: int = None def __post_init__(self): - self.title = "Sign Message" + self.title = "Confirm Address" self.is_bottom_list = True self.is_button_text_centered = True self.button_data = ["Sign"] super().__post_init__() derivation_path_display = IconTextLine( - icon_name=SeedSignerCustomIconConstants.PATH, + icon_name=SeedSignerIconConstants.DERIVATION, label_text="derivation path", value_text=self.derivation_path, is_text_centered=True, diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index e44e643b..a4946925 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -135,7 +135,7 @@ class SeedSelectSeedView(View): return Destination(SeedAddressVerificationView, view_args=view_args) elif self.flow == Controller.FLOW__SIGN_MESSAGE: - return Destination(SeedSignMessageConfirmView, view_args=view_args) + return Destination(SeedSignMessageConfirmMessageView, view_args=view_args) self.controller.resume_main_flow = self.flow @@ -454,8 +454,8 @@ 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) - if self.controller.resume_main_flow == Controller.FLOW__SIGN_MESSAGE: - return Destination(SeedSignMessageConfirmView, view_args=dict(seed_num=self.seed_num), skip_current_view=True) + elif self.controller.resume_main_flow == Controller.FLOW__SIGN_MESSAGE: + return Destination(SeedSignMessageConfirmMessageView, view_args=dict(seed_num=self.seed_num), 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)): @@ -1985,36 +1985,40 @@ class SeedSignMessageStartView(View): def run(self): if self.seed_num is not None: # We already know which seed we're signing with - return Destination(SeedSignMessageConfirmView, skip_current_view=True) + return Destination(SeedSignMessageConfirmMessageView, skip_current_view=True) else: return Destination(SeedSelectSeedView, view_args=dict(flow=Controller.FLOW__SIGN_MESSAGE), skip_current_view=True) -class SeedSignMessageConfirmView(View): - def __init__(self, seed_num: int = None): +class SeedSignMessageConfirmMessageView(View): + def __init__(self, seed_num: int, page_num: int = 0): super().__init__() - data = self.controller.sign_message_data - self.derivation_path = data["derivation_path"] - self.message = data["message"] + self.seed_num = seed_num + self.page_num = page_num if seed_num is not None: - self.seed_num = seed_num + data = self.controller.sign_message_data data["seed_num"] = seed_num + + if data.get("paged_message") and page_num >= len(data["paged_message"]): + # We've reached the end of the message + # TODO: show derivation path and address details + return Destination(SeedSignMessageSignedMessageQRView, skip_current_view=True) def run(self): - from seedsigner.gui.screens.seed_screens import SeedSignMessageScreen - selected_menu_num = SeedSignMessageScreen( - derivation_path=self.derivation_path, - message=self.message, + from seedsigner.gui.screens.seed_screens import SeedSignMessageConfirmMessageScreen + selected_menu_num = SeedSignMessageConfirmMessageScreen( + sign_message_data=self.controller.sign_message_data, + page_num=self.page_num, ).display() if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - # User clicked "Sign" - return Destination(SeedSignMessageSignedMessageQRView) + # User clicked "Next" + return Destination(SeedSignMessageConfirmMessageView, view_args=dict(seed_num=self.seed_num, page_num=self.page_num + 1)) From 388b63e2961e5934f9de0908e35b28ac6d31e611 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 28 Jul 2023 20:38:40 -0500 Subject: [PATCH 08/19] TextArea refactor, simplification; psbt View bugfixes * binary search generalized for message signing w/paged message display. --- src/seedsigner/gui/components.py | 398 +++++++++++--------- src/seedsigner/gui/screens/screen.py | 3 +- src/seedsigner/gui/screens/seed_screens.py | 20 +- src/seedsigner/gui/screens/tools_screens.py | 5 +- src/seedsigner/views/psbt_views.py | 23 +- src/seedsigner/views/seed_views.py | 4 + 6 files changed, 249 insertions(+), 204 deletions(-) diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index 8cffed7b..56a8ceea 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,77 @@ 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") + height_calc_start = time() + 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 + 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) + + # 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 chars that dip 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 +372,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( + "RGBA", + ( + 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 +513,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 +534,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 +547,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 @@ -1317,10 +1296,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( @@ -1339,7 +1318,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, @@ -1421,63 +1401,115 @@ def calc_bezier_curve(p1: Tuple[int,int], p2: Tuple[int,int], p3: Tuple[int,int] -def calc_multipage_text(text: str, width: int, height: int, font_name: str, font_size: int) -> list[str]: +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]: """ - Returns a list of "pages" consisting of n lines of text each where: - * each line of text is no longer than `width` pixels (when possible) - * the n lines of text render within the specified page `height` + 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. """ - # Source text's individual lines - lines = text.strip().split("\n") - - # Re-flowed lines (new line breaks added as needed to fit within `width`) - reflowed_lines = [] - + # 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") - next_line = "" - for line in lines: - while True: - if not line.strip(): - # Skip blank lines - break - # Measure from left baseline ("ls") - # Note: getbbox() seems to ignore "\n" but doesn't affect us here. - (left, top, line_width, bottom) = font.getbbox(line, anchor="ls") - if line_width <= width: - # The line fits! - reflowed_lines.append(line) - if next_line: - line = next_line - next_line = "" - else: - break - else: - # The line doesn't fit. Find the last space and break there. - last_space = line.rfind(" ") - if last_space == -1: - # No spaces found. This is one huge word. Too bad, it'll just have - # to overflow the screen. - reflowed_lines.append(line) - if next_line: - line = next_line - next_line = "" + # 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 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: - break - else: - # Move the last word down into `next_line` - next_line = line[(last_space+1):] + " " + next_line if next_line else line[(last_space+1):] + # 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) - # Try again with the now-shorter line - line = line[:last_space] + 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") - # _, line_height = font.getsize("A") - bbox_height = bottom - top - line_spacer = GUIConstants.BODY_LINE_SPACING - lines_per_page = math.floor(height / (bbox_height + line_spacer)) + # 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:] + + print(f"{time() - start:0.2f}s") + return text_lines + + + +def break_lines_into_pages(lines: list[str], + height: int, + font_name=GUIConstants.BODY_FONT_NAME, + font_size=GUIConstants.BODY_FONT_SIZE, + line_spacer: int = GUIConstants.BODY_LINE_SPACING) -> 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. + """ + 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") + 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) + bottom: + lines_per_page = i + else: + break pages = [] - for i in range(0, len(reflowed_lines), lines_per_page): - pages.append("\n".join(reflowed_lines[i:(i+lines_per_page)])) - + 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/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 d09ccc1d..ab096677 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, calc_multipage_text) + IconTextLine, SeedSignerIconConstants, TextArea, GUIConstants, reflow_text_for_width, break_lines_into_pages) from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay from seedsigner.hardware.buttons import HardwareButtons, HardwareButtonsConstants @@ -1511,14 +1511,26 @@ class SeedSignMessageConfirmMessageScreen(ButtonListScreen): message_height = end_y - start_y if "paged_messages" not in self.sign_message_data: - self.sign_message_data["paged_messages"] = calc_multipage_text( + reflowed_lines_dicts = reflow_text_for_width( text=self.sign_message_data["message"], width=renderer.canvas_width - 2*GUIConstants.EDGE_PADDING, - height=message_height, 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 + ) + print(paged) + self.sign_message_data["paged_messages"] = paged + if self.page_num >= len(self.sign_message_data["paged_messages"]): raise Exception("Bug in paged_messages calculation") 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/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index b2c70f67..00068094 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -26,11 +26,14 @@ class PSBTSelectSeedView(View): if not self.controller.psbt: # Shouldn't be able to get here raise Exception("No PSBT currently loaded") - - seeds = self.controller.storage.seeds - button_data = [] - for seed in seeds: + 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) + + button_data = [] + for seed in self.controller.storage.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)): # Doesn't look like this seed can sign the current PSBT @@ -42,11 +45,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", @@ -57,6 +55,8 @@ class PSBTSelectSeedView(View): if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) + seeds = self.controller.storage.seeds + if len(seeds) > 0 and selected_menu_num < len(seeds): # User selected one of the n seeds self.controller.psbt_seed = self.controller.get_seed(selected_menu_num) @@ -241,16 +241,15 @@ class PSBTAddressDetailsView(View): def __init__(self, address_num): super().__init__() self.address_num = address_num - + def run(self): psbt_parser: PSBTParser = self.controller.psbt_parser 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: title += f" (#{self.address_num + 1})" diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index a4946925..6234e409 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -2015,6 +2015,10 @@ class SeedSignMessageConfirmMessageView(View): ).display() if selected_menu_num == RET_CODE__BACK_BUTTON: + if self.page_num == 1: + # We're exiting this flow entirely + self.controller.resume_main_flow = None + self.controller.sign_message_data = None return Destination(BackStackView) # User clicked "Next" From 5d5521cfc7f4f3a43297503193b92b3b3218dc98 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sat, 29 Jul 2023 09:47:54 -0500 Subject: [PATCH 09/19] Initial flow complete * Still needs tests and external verification --- src/seedsigner/gui/components.py | 8 +- src/seedsigner/gui/renderer.py | 118 --------------------- src/seedsigner/gui/screens/seed_screens.py | 46 ++++---- src/seedsigner/helpers/embit_utils.py | 50 ++++++++- src/seedsigner/views/seed_views.py | 93 ++++++++++++---- 5 files changed, 142 insertions(+), 173 deletions(-) diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index 56a8ceea..51c8b0f7 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -339,7 +339,7 @@ class TextArea(BaseComponent): 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 chars that dip below baseline + # Last line has at least one char that dips below baseline total_text_height += self.text_height_below_baseline if self.height is None: @@ -377,7 +377,7 @@ class TextArea(BaseComponent): resample_padding = 10 if self.supersampling_factor > 1.0 else 0 img = Image.new( - "RGBA", + "RGB", ( self.width * self.supersampling_factor, (self.height + 2*resample_padding) * self.supersampling_factor @@ -386,8 +386,8 @@ class TextArea(BaseComponent): ) draw = ImageDraw.Draw(img) - 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) + # 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)) 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/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index ab096677..bb213bbc 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -1501,16 +1501,17 @@ class MultisigWalletDescriptorScreen(ButtonListScreen): @dataclass class SeedSignMessageConfirmMessageScreen(ButtonListScreen): - sign_message_data: dict = None page_num: int = None def __post_init__(self): + from seedsigner.controller import Controller renderer = Renderer.get_instance() start_y = GUIConstants.TOP_NAV_HEIGHT + GUIConstants.COMPONENT_PADDING - end_y = renderer.canvas_height - GUIConstants.BUTTON_HEIGHT - 2*GUIConstants.COMPONENT_PADDING + end_y = renderer.canvas_height - GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT - GUIConstants.COMPONENT_PADDING message_height = end_y - start_y - if "paged_messages" not in self.sign_message_data: + 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( text=self.sign_message_data["message"], width=renderer.canvas_width - 2*GUIConstants.EDGE_PADDING, @@ -1528,23 +1529,22 @@ class SeedSignMessageConfirmMessageScreen(ButtonListScreen): lines=lines, height=message_height ) - print(paged) - self.sign_message_data["paged_messages"] = paged + self.sign_message_data["paged_message"] = paged - if self.page_num >= len(self.sign_message_data["paged_messages"]): - raise Exception("Bug in paged_messages calculation") + 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_messages"]) == 1: + 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_messages"])})""" + 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_messages"][self.page_num], + text=self.sign_message_data["paged_message"][self.page_num], is_text_centered=False, allow_text_overflow=True, screen_y=start_y, @@ -1555,14 +1555,14 @@ class SeedSignMessageConfirmMessageScreen(ButtonListScreen): @dataclass class SeedSignMessageConfirmAddressScreen(ButtonListScreen): - derivation_path: int = None - message: int = None + 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"] + self.button_data = ["Sign Message"] super().__post_init__() derivation_path_display = IconTextLine( @@ -1570,21 +1570,13 @@ class SeedSignMessageConfirmAddressScreen(ButtonListScreen): label_text="derivation path", value_text=self.derivation_path, is_text_centered=True, - screen_y=self.top_nav.height, + screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, ) self.components.append(derivation_path_display) - message_display = TextArea( - text=self.message, - is_text_centered=False, - allow_text_overflow=True, + address_display = FormattedAddress( + address=self.address, + max_lines=3, + screen_y=derivation_path_display.screen_y + derivation_path_display.height + 2*GUIConstants.COMPONENT_PADDING, ) - start_y = derivation_path_display.screen_y + derivation_path_display.height - end_y = self.buttons[0].screen_y - - message_display.screen_y = start_y + int((end_y - start_y - message_display.height)/2) - if message_display.screen_y < start_y: - # full message text doesn't fit; let it run long. - # TODO: Add scrollable UI so whole message will be viewable. - message_display.screen_y = start_y + GUIConstants.COMPONENT_PADDING - self.components.append(message_display) + self.components.append(address_display) diff --git a/src/seedsigner/helpers/embit_utils.py b/src/seedsigner/helpers/embit_utils.py index 59404eb6..66997527 100644 --- a/src/seedsigner/helpers/embit_utils.py +++ b/src/seedsigner/helpers/embit_utils.py @@ -101,7 +101,55 @@ def get_multisig_address(descriptor: Descriptor, index: int = 0, is_change: bool -def sign_message(seed_bytes: bytes, derivation, msg: bytes, compressed: bool = True, embit_network: str="main") -> bytes: +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: + # 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]) + 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 + + 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 """ diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 6234e409..c7d3c8e3 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -135,7 +135,8 @@ class SeedSelectSeedView(View): return Destination(SeedAddressVerificationView, view_args=view_args) elif self.flow == Controller.FLOW__SIGN_MESSAGE: - return Destination(SeedSignMessageConfirmMessageView, view_args=view_args) + self.controller.sign_message_data["seed_num"] = selected_menu_num + return Destination(SeedSignMessageConfirmMessageView) self.controller.resume_main_flow = self.flow @@ -455,7 +456,8 @@ class SeedOptionsView(View): 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: - return Destination(SeedSignMessageConfirmMessageView, view_args=dict(seed_num=self.seed_num), skip_current_view=True) + 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)): @@ -1968,9 +1970,8 @@ class SeedSignMessageStartView(View): def __init__(self, derivation_path: str, message: str): super().__init__() self.derivation_path = derivation_path - self.message = message - self.seed_num = None - + self.message = message + data = self.controller.sign_message_data if not data: data = {} @@ -1978,8 +1979,8 @@ class SeedSignMessageStartView(View): data["derivation_path"] = derivation_path data["message"] = message - if data and "seed_num" in data: - self.seed_num = data["seed_num"] + # May be None + self.seed_num = data.get("seed_num") def run(self): @@ -1992,27 +1993,23 @@ class SeedSignMessageStartView(View): class SeedSignMessageConfirmMessageView(View): - def __init__(self, seed_num: int, page_num: int = 0): + def __init__(self, page_num: int = 0): super().__init__() - self.seed_num = seed_num self.page_num = page_num - if seed_num is not None: - data = self.controller.sign_message_data - data["seed_num"] = seed_num - - if data.get("paged_message") and page_num >= len(data["paged_message"]): - # We've reached the end of the message - # TODO: show derivation path and address details - return Destination(SeedSignMessageSignedMessageQRView, skip_current_view=True) + self.data = self.controller.sign_message_data + self.seed_num = self.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 = SeedSignMessageConfirmMessageScreen( - sign_message_data=self.controller.sign_message_data, + + selected_menu_num = self.run_screen( + SeedSignMessageConfirmMessageScreen, page_num=self.page_num, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: if self.page_num == 1: @@ -2022,7 +2019,55 @@ class SeedSignMessageConfirmMessageView(View): return Destination(BackStackView) # User clicked "Next" - return Destination(SeedSignMessageConfirmMessageView, view_args=dict(seed_num=self.seed_num, page_num=self.page_num + 1)) + 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) + 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: + raise Exception(f"Current network setting ({self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK)}) doesn't match {self.derivation_path}") + + 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) @@ -2044,9 +2089,11 @@ class SeedSignMessageSignedMessageQRView(View): def run(self): qr_encoder = EncodeQR(qr_type=QRType.SIGN_MESSAGE, signed_message=self.signed_message) - QRDisplayScreen( + + self.run_screen( + QRDisplayScreen, qr_encoder=qr_encoder, - ).display() + ) # cleanup self.controller.resume_main_flow = None From d0e623be282a4530fd6ec0c962a87857c0e1eeda Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sat, 29 Jul 2023 16:42:14 -0500 Subject: [PATCH 10/19] cleanup, light refactor, new test --- src/seedsigner/gui/components.py | 29 ++++-- src/seedsigner/gui/screens/seed_screens.py | 18 +--- src/seedsigner/helpers/embit_utils.py | 19 +++- src/seedsigner/views/seed_views.py | 41 ++++---- tests/test_embit_utils.py | 65 +++++++++++++ tests/test_flows_seed.py | 104 ++++++++++++++++++++- 6 files changed, 229 insertions(+), 47 deletions(-) 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), + ]) From 2448206a140129cceeae26e3462aa0792d043657 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 7 Aug 2023 07:57:29 -0500 Subject: [PATCH 11/19] bugfix --- src/seedsigner/views/psbt_views.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index 00068094..129ce4d8 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -269,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) From 6209f31ceae8a963733e22de89240e03125f1f67 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 7 Aug 2023 08:44:30 -0500 Subject: [PATCH 12/19] button_data cleanup --- src/seedsigner/views/seed_views.py | 76 ++++-------------------------- tests/test_flows_seed.py | 1 - 2 files changed, 10 insertions(+), 67 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 6d0dea85..a00f6104 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -79,7 +79,6 @@ class SeedSelectSeedView(View): 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): @@ -107,25 +106,25 @@ class SeedSelectSeedView(View): else: raise Exception(f"Unsupported `flow` specified: {self.flow}") - self.button_data = [] + 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 - self.button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) + button_data.append((button_str, SeedSignerIconConstants.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(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=self.button_data + button_data=button_data, ) if selected_menu_num == RET_CODE__BACK_BUTTON: @@ -143,13 +142,13 @@ class SeedSelectSeedView(View): self.controller.resume_main_flow = self.flow - if self.button_data[selected_menu_num] == self.SCAN_SEED: + if button_data[selected_menu_num] == self.SCAN_SEED: from seedsigner.views.scan_views import ScanView return Destination(ScanView) - elif self.button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: + elif button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: from seedsigner.views.seed_views import SeedMnemonicEntryView - if self.button_data[selected_menu_num] == self.TYPE_12WORD: + 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) @@ -1561,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 @@ -1626,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 diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index f7964e96..aaeff5e7 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -291,7 +291,6 @@ class TestSeedFlows(FlowTest): ) - def test_sign_message_flow(self): """ """ From d5927ea9db68b7bfe4634725972b3b68658a7fb7 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 7 Aug 2023 09:43:34 -0500 Subject: [PATCH 13/19] fix rebase artifact --- src/seedsigner/views/psbt_views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index 129ce4d8..a3d2576f 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -250,6 +250,7 @@ class PSBTAddressDetailsView(View): # Should not be able to get here raise Exception("Routing error") + title = "Will Send" if psbt_parser.num_destinations > 1: title += f" (#{self.address_num + 1})" From 46e1af58a1d5f764087676bc4cef0419b2f79b42 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 7 Aug 2023 09:54:09 -0500 Subject: [PATCH 14/19] psbt_views light cleanup --- src/seedsigner/views/psbt_views.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index a3d2576f..b9cfa064 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -32,8 +32,9 @@ class PSBTSelectSeedView(View): # 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 self.controller.storage.seeds: + 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)): # Doesn't look like this seed can sign the current PSBT @@ -55,8 +56,6 @@ class PSBTSelectSeedView(View): if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - seeds = self.controller.storage.seeds - if len(seeds) > 0 and selected_menu_num < len(seeds): # User selected one of the n seeds self.controller.psbt_seed = self.controller.get_seed(selected_menu_num) @@ -241,7 +240,7 @@ class PSBTAddressDetailsView(View): def __init__(self, address_num): super().__init__() self.address_num = address_num - + def run(self): psbt_parser: PSBTParser = self.controller.psbt_parser From 734dfcb3e51f0f87f041e938355a832001801fd6 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 7 Aug 2023 19:36:25 -0500 Subject: [PATCH 15/19] Proper centering for `BtcAmount` --- src/seedsigner/gui/components.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index 1e7f18a6..edf7181a 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -889,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( @@ -916,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=( @@ -953,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( @@ -1019,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 = ( From bd7e7b8cb48dd6be9dd0430a600eaeade724a739 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Tue, 8 Aug 2023 18:42:56 -0500 Subject: [PATCH 16/19] Bugfix; adds `View.set_redirect()` mechanism --- src/seedsigner/gui/components.py | 6 +-- src/seedsigner/views/seed_views.py | 12 +++-- src/seedsigner/views/view.py | 82 ++++++++++++++++++++++++++---- 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index edf7181a..d4274a11 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -1424,7 +1424,7 @@ def reflow_text_for_width(text: str, def _add_text_line(text, text_width): text_lines.append({"text": text, "text_width": text_width}) - if full_text_width < 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) @@ -1477,7 +1477,6 @@ def reflow_text_for_width(text: str, _add_text_line(" ".join(words[0:index]), tw) words = words[index:] - print(f"{time() - start:0.2f}s") return text_lines @@ -1508,7 +1507,8 @@ def reflow_text_into_pages(text: str, 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") + # 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 diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index a00f6104..6dc99b23 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -1944,8 +1944,7 @@ class SeedSignMessageConfirmMessageView(View): super().__init__() self.page_num = page_num # Note: zero-indexed numbering! - self.data = self.controller.sign_message_data - self.seed_num = self.data.get("seed_num") + 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") @@ -1995,7 +1994,14 @@ class SeedSignMessageConfirmAddressView(View): if self.settings.get_value(SettingsConstants.SETTING__NETWORK) in [SettingsConstants.TESTNET, SettingsConstants.REGTEST]: addr_format["network"] = self.settings.get_value(SettingsConstants.SETTING__NETWORK) else: - raise Exception(f"Current network setting ({self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK)}) doesn't match {self.derivation_path}") + 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"]) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index f3fb704c..2a0ff822 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: """ @@ -113,6 +155,8 @@ class Destination: def run(self): self._instantiate_view() + if self.view.has_redirect: + return self.view.get_redirect() return self._run_view() @@ -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): @@ -303,9 +367,9 @@ class UnhandledExceptionView(View): +@dataclass class OptionDisabledView(View): - def __init__(self, error_msg: str): - self.error_msg = error_msg + error_msg: str def run(self): From b6cbf33bb82939bc4773b1901117155483777b6d Mon Sep 17 00:00:00 2001 From: kdmukai Date: Tue, 8 Aug 2023 19:06:06 -0500 Subject: [PATCH 17/19] bugfixes --- src/seedsigner/views/seed_views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 6dc99b23..a4236cb0 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -76,7 +76,7 @@ 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) + SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE) TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) @@ -431,7 +431,7 @@ class SeedOptionsView(View): EXPORT_XPUB = "Export Xpub" EXPLORER = "Address Explorer" SIGN_MESSAGE = "Sign Message" - BACKUP = ("Backup Seed", None, None, None, SeedSignerIconConstants.SMALL_CHEVRON_RIGHT) + BACKUP = ("Backup Seed", None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT) BIP85_CHILD_SEED = "BIP-85 Child Seed" DISCARD = ("Discard Seed", None, None, "red") From df0db6c28f45c6071fdfa774fb6ac732cbb8631e Mon Sep 17 00:00:00 2001 From: kdmukai Date: Wed, 9 Aug 2023 08:30:15 -0500 Subject: [PATCH 18/19] flow test reorg; add network mismatch flow test --- src/seedsigner/views/view.py | 4 +- tests/base.py | 9 +++ tests/test_flows_seed.py | 129 +++++++++++++++++++++++------------ 3 files changed, 98 insertions(+), 44 deletions(-) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 2a0ff822..dfd59eb3 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -150,13 +150,13 @@ class Destination: def _run_view(self): + if self.view.has_redirect: + return self.view.get_redirect() return self.view.run() def run(self): self._instantiate_view() - if self.view.has_redirect: - return self.view.get_redirect() return self._run_view() 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/test_flows_seed.py b/tests/test_flows_seed.py index aaeff5e7..fde32231 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,13 +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, View -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 @@ -291,52 +292,69 @@ class TestSeedFlows(FlowTest): ) + +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) - 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(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=load_seed_into_decoder), # simulate read SeedQR; ret val is ignored + 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=inject_mesage_as_paged_message, screen_return_value=0), + 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), @@ -346,12 +364,12 @@ class TestSeedFlows(FlowTest): 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(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=load_standard_message_into_decoder), # simulate read message QR; ret val is ignored + 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=inject_mesage_as_paged_message, screen_return_value=0), + 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), @@ -360,13 +378,13 @@ class TestSeedFlows(FlowTest): # 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(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=load_seed_into_decoder), # simulate read SeedQR; ret val is ignored + 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=inject_mesage_as_paged_message, screen_return_value=0), # page 1/5 + 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 @@ -389,3 +407,30 @@ class TestSeedFlows(FlowTest): 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), + ]) From 7926cbf909ed6e65286aa25a026f09dcb953380b Mon Sep 17 00:00:00 2001 From: kdmukai Date: Wed, 9 Aug 2023 08:57:24 -0500 Subject: [PATCH 19/19] add screenshots; reorg screenshots in subdirs --- src/seedsigner/gui/screens/seed_screens.py | 1 + tests/screenshot_generator/generator.py | 66 +++++++++++++--------- 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index 069bd04d..e1b88db0 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -1555,6 +1555,7 @@ class SeedSignMessageConfirmAddressScreen(ButtonListScreen): 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, 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)