diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a0649dc7..87a407fe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,7 +31,7 @@ jobs: target: [ "pi0", "pi2", "pi02w", "pi4" ] steps: - name: checkout seedsigner-os - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: "seedsigner/seedsigner-os" # use the os-ref input parameter in case of workflow_dispatch or default to main in case of cron triggers @@ -42,7 +42,7 @@ jobs: fetch-depth: 0 - name: checkout source - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: # ref defaults to repo default-branch=dev (cron) or SHA of event (workflow_dispatch) path: "seedsigner-os/opt/rootfs-overlay/opt" @@ -78,7 +78,7 @@ jobs: ls -la src - name: restore build cache - uses: actions/cache@v3 + uses: actions/cache@v4 # Caching reduces the build time to ~50% (currently: ~30 mins instead of ~1 hour, # while consuming ~850 MB storage space). with: @@ -89,10 +89,25 @@ jobs: restore-keys: | build-cache-${{ matrix.target }}- + - name: Create build container + run: | + cd seedsigner-os + docker build -t seedsigner-os-build . + - name: build run: | - cd seedsigner-os/opt - ./build.sh --${{ matrix.target }} --skip-repo --no-clean + mkdir -p \ + ~/.buildroot-ccache \ + seedsigner-os/buildroot_dl + docker run \ + --rm \ + -v "$(pwd)/seedsigner-os/opt:/opt" \ + -v "$(pwd)/seedsigner-os/images:/images" \ + -v "$(pwd)/seedsigner-os/buildroot_dl:/buildroot_dl" \ + -v "${HOME}/.buildroot-ccache:/root/.buildroot-ccache" \ + seedsigner-os-build \ + --${{ matrix.target }} --skip-repo --no-clean + sudo chown -R $USER:$USER seedsigner-os/images seedsigner-os/buildroot_dl ~/.buildroot-ccache/ - name: list image (before rename) run: | @@ -113,9 +128,9 @@ jobs: ls -la seedsigner-os/images - name: upload images - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: seedsigner_os_images + name: seedsigner_os_images-${{ matrix.target }} path: "seedsigner-os/images/*.img" if-no-files-found: error # maximum 90 days retention @@ -127,14 +142,13 @@ jobs: needs: build steps: - name: download images - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: - name: seedsigner_os_images path: images - name: list images run: | - ls -la images + ls -lRa images - name: get seedsigner latest commit hash id: get-seedsigner-hash @@ -145,12 +159,14 @@ jobs: - name: write sha256sum run: | cd images + # each downloaded image is in its own subfolder + find . -name "*.img" -exec mv {} . \; sha256sum *.img > seedsigner_os.${{ env.source_hash }}.sha256 - name: upload checksums - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: seedsigner_os_images + name: seedsigner_os_images_sha256 path: "images/*.sha256" if-no-files-found: error # maximum 90 days retention diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9fba7af5..9be339c0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,12 +26,12 @@ jobs: python-version: ["3.10", "3.12"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: # Needs to also pull the seedsigner-translations repo submodules: recursive - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -63,9 +63,9 @@ jobs: - name: Coverage report run: coverage report - name: Archive CI Artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: ci-artifacts + name: ci-artifacts-${{ matrix.python-version }} path: artifacts/** retention-days: 10 # Upload also when tests fail. The workflow result (red/green) will diff --git a/l10n/messages.pot b/l10n/messages.pot index 84ffd5bc..666c12cc 100644 --- a/l10n/messages.pot +++ b/l10n/messages.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: seedsigner 0.8.5-rc1\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2025-01-15 10:45-0600\n" +"POT-Creation-Date: 2025-01-28 13:28-0600\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -36,16 +36,6 @@ msgstr "" msgid "sats" msgstr "" -#. The abbreviated label for the special key on a standard keyboard. -#: src/seedsigner/gui/keyboard.py -msgid "del" -msgstr "" - -#. The abbreviated label for the special key on a standard keyboard. -#: src/seedsigner/gui/keyboard.py -msgid "space" -msgstr "" - #: src/seedsigner/gui/toast.py msgid "" "You can remove\n" @@ -215,7 +205,7 @@ msgstr "" msgid "OK" msgstr "" -#: src/seedsigner/gui/screens/screen.py src/seedsigner/views/psbt_views.py +#: src/seedsigner/gui/screens/screen.py msgid "Caution" msgstr "" @@ -231,6 +221,11 @@ msgstr "" msgid "Classified Info!" msgstr "" +#: src/seedsigner/gui/screens/screen.py src/seedsigner/views/scan_views.py +#: src/seedsigner/views/view.py +msgid "Error" +msgstr "" + #: src/seedsigner/gui/screens/screen.py msgid "Restarting" msgstr "" @@ -516,7 +511,7 @@ msgid "Home" msgstr "" #: src/seedsigner/gui/screens/tools_screens.py -msgid "click joystick" +msgid "click a button" msgstr "" #. A prompt to the user to either accept or reshoot the image @@ -901,13 +896,7 @@ msgstr "" #. Variable is either "change" or "self-transfer". #: src/seedsigner/views/psbt_views.py -msgid "" -"PSBT's {} address could not be verified with your multisig wallet " -"descriptor." -msgstr "" - -#: src/seedsigner/views/psbt_views.py -msgid "Suspicious PSBT" +msgid "PSBT's {} address could not be verified from wallet descriptor." msgstr "" #. Variable is either "change" or "self-transfer". @@ -915,6 +904,10 @@ msgstr "" msgid "PSBT's {} address could not be generated from your seed." msgstr "" +#: src/seedsigner/views/psbt_views.py +msgid "Suspicious PSBT" +msgstr "" + #: src/seedsigner/views/psbt_views.py msgid "Address Verification Failed" msgstr "" @@ -955,23 +948,6 @@ msgstr "" msgid "Wrong QR Type" msgstr "" -#: src/seedsigner/views/scan_views.py src/seedsigner/views/view.py -msgid "Error" -msgstr "" - -#: src/seedsigner/views/scan_views.py -msgid "Unknown QR Type" -msgstr "" - -#: src/seedsigner/views/scan_views.py -msgid "QRCode is invalid or is a data format not yet supported." -msgstr "" - -#: src/seedsigner/views/scan_views.py src/seedsigner/views/seed_views.py -#: src/seedsigner/views/view.py -msgid "Done" -msgstr "" - #: src/seedsigner/views/scan_views.py src/seedsigner/views/seed_views.py msgid "Scan PSBT" msgstr "" @@ -1004,6 +980,19 @@ msgstr "" msgid "Expected an address QR" msgstr "" +#: src/seedsigner/views/scan_views.py +msgid "Unknown QR Type" +msgstr "" + +#: src/seedsigner/views/scan_views.py +msgid "QRCode is invalid or is a data format not yet supported." +msgstr "" + +#: src/seedsigner/views/scan_views.py src/seedsigner/views/seed_views.py +#: src/seedsigner/views/view.py +msgid "Done" +msgstr "" + #. This is on the opening splash screen, displayed above the HRF logo #: src/seedsigner/views/screensaver.py msgid "With support from:" @@ -1459,10 +1448,13 @@ msgstr "" msgid "Back to Main Menu" msgstr "" +#. The network setting (mainnet/testnet/regtest) doesn't match the provided +#. derivation path #: src/seedsigner/views/view.py msgid "Network Mismatch" msgstr "" +#. Button option to alter a setting #: src/seedsigner/views/view.py msgid "Change Setting" msgstr "" diff --git a/pyproject.toml b/pyproject.toml index 84757b94..7f34e326 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ description = "Build an offline, airgapped Bitcoin signing device for less than name = "seedsigner" readme = "README.md" requires-python = ">=3.10" -version = "0.8.5-rc1" +version = "0.8.5" [project.urls] "Bug Tracker" = "https://github.com/SeedSigner/seedsigner/issues" diff --git a/seedsigner-screenshots b/seedsigner-screenshots index f3139067..8e97151b 160000 --- a/seedsigner-screenshots +++ b/seedsigner-screenshots @@ -1 +1 @@ -Subproject commit f313906766347bbcc99a167d434d055407c34dd7 +Subproject commit 8e97151b7e0658dcffc02e7551e1606abcc572bf diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 5d34fa29..7505408c 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -99,7 +99,7 @@ class Controller(Singleton): rather than at the top in order avoid circular imports. """ - VERSION = "0.8.5-rc1" + VERSION = "0.8.5" # Declare class member vars with type hints to enable richer IDE support throughout # the code. diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index 6414b71a..6c0f68ae 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -217,9 +217,9 @@ class SeedSignerIconConstants: # Messaging icons INFO = "\ue912" - ERROR = "\ue913" - SUCCESS = "\ue914" - WARNING = "\ue915" + SUCCESS = "\ue913" + WARNING = "\ue914" + ERROR = "\ue915" # Informational icons ADDRESS = "\ue916" @@ -241,8 +241,9 @@ class SeedSignerIconConstants: DELETE = "\ue922" SPACE = "\ue923" + # Must be updated whenever new icons are added. See usage in `Icon` class below. MIN_VALUE = SCAN - MAX_VALUE = QRCODE + MAX_VALUE = SPACE @@ -280,17 +281,6 @@ def calc_text_centering(font: ImageFont, -def load_icon(icon_name: str, load_selected_variant: bool = False): - icon_url = os.path.join(pathlib.Path(__file__).parent.resolve(), "..", "resources", "icons", icon_name) - icon = Image.open(icon_url + ".png").convert("RGB") - if not load_selected_variant: - return icon - else: - icon_selected = Image.open(icon_url + "_selected.png").convert("RGB") - return (icon, icon_selected) - - - def load_image(image_name: str) -> Image.Image: image_url = os.path.join(pathlib.Path(__file__).parent.resolve(), "..", "resources", "img", image_name) image = Image.open(image_url).convert("RGB") diff --git a/src/seedsigner/gui/keyboard.py b/src/seedsigner/gui/keyboard.py index b92b062b..bc679ec5 100644 --- a/src/seedsigner/gui/keyboard.py +++ b/src/seedsigner/gui/keyboard.py @@ -3,7 +3,7 @@ from PIL import Image, ImageDraw, ImageFont from typing import Tuple from gettext import gettext as _ -from seedsigner.gui.components import Fonts, GUIConstants +from seedsigner.gui.components import Fonts, GUIConstants, SeedSignerIconConstants from seedsigner.hardware.buttons import HardwareButtonsConstants @@ -30,58 +30,55 @@ class Keyboard: ENTER_RIGHT = "enter_right" REGULAR_KEY_FONT = "regular" - COMPACT_KEY_FONT = "compact" + ICON_KEY_FONT = GUIConstants.ICON_FONT_NAME__SEEDSIGNER - # TRANSLATOR_NOTE: The abbreviated label for the special key on a standard keyboard. - del_label = _("del") KEY_BACKSPACE = { "code": "DEL", - "letter": del_label, - "font": COMPACT_KEY_FONT, + "letter": SeedSignerIconConstants.DELETE, + "font": ICON_KEY_FONT, "size": 2, } - # TRANSLATOR_NOTE: The abbreviated label for the special key on a standard keyboard. - space_label = _("space") + KEY_SPACE = { "code": "SPACE", - "letter": space_label, - "font": COMPACT_KEY_FONT, + "letter": SeedSignerIconConstants.SPACE, + "font": ICON_KEY_FONT, "size": 1, } KEY_SPACE_2 = { "code": "SPACE", - "letter": space_label, - "font": COMPACT_KEY_FONT, + "letter": SeedSignerIconConstants.SPACE, + "font": ICON_KEY_FONT, "size": 2, } KEY_SPACE_3 = { "code": "SPACE", - "letter": space_label, - "font": COMPACT_KEY_FONT, + "letter": SeedSignerIconConstants.SPACE, + "font": ICON_KEY_FONT, "size": 3, } KEY_SPACE_4 = { "code": "SPACE", - "letter": space_label, - "font": COMPACT_KEY_FONT, + "letter": SeedSignerIconConstants.SPACE, + "font": ICON_KEY_FONT, "size": 4, } KEY_SPACE_5 = { "code": "SPACE", - "letter": space_label, - "font": COMPACT_KEY_FONT, + "letter": SeedSignerIconConstants.SPACE, + "font": ICON_KEY_FONT, "size": 5, } KEY_CURSOR_LEFT = { "code": "CURSOR_LEFT", - "letter": "<", - "font": REGULAR_KEY_FONT, + "letter": SeedSignerIconConstants.CHEVRON_LEFT, + "font": ICON_KEY_FONT, "size": 1, } KEY_CURSOR_RIGHT = { "code": "CURSOR_RIGHT", - "letter": ">", - "font": REGULAR_KEY_FONT, + "letter": SeedSignerIconConstants.CHEVRON_RIGHT, + "font": ICON_KEY_FONT, "size": 1, } KEY_PREVIOUS_PAGE = { @@ -123,9 +120,11 @@ class Keyboard: def render_key(self): font = self.keyboard.font + text_height = self.keyboard.text_height if self.is_additional_key: - if Keyboard.ADDITIONAL_KEYS[self.code]["font"] == Keyboard.COMPACT_KEY_FONT: - font = self.keyboard.additonal_key_compact_font + if Keyboard.ADDITIONAL_KEYS[self.code]["font"] == Keyboard.ICON_KEY_FONT: + font = self.keyboard.icon_key_font + text_height = self.keyboard.icon_key_height outline_color = "#333" if not self.is_active: @@ -159,15 +158,12 @@ class Keyboard: radius=4 ) - # Fixed-width fonts will all have same height, ignoring below baseline (e.g. "Q" or "q") - (left, top, right, bottom) = font.getbbox("X", anchor="ls") - text_height = -1 * top self.keyboard.draw.text( ( self.screen_x + int(self.keyboard.key_width * self.size / 2), self.screen_y + self.keyboard.key_height - int((self.keyboard.key_height - text_height)/2) ), - _(self.letter), + self.letter, fill=font_color, font=font, anchor="ms" @@ -217,8 +213,15 @@ class Keyboard: # Set up the rendering and state params self.active_keys = list(self.charset) + self.icon_key_font = Fonts.get_font(GUIConstants.ICON_FONT_NAME__SEEDSIGNER, 26) + + # Fixed-width fonts will all have same height, ignoring below baseline (e.g. "Q" or "q") + (left, top, right, bottom) = self.font.getbbox("X", anchor="ls") + self.text_height = -1 * top + + (left, top, right, bottom) = self.icon_key_font.getbbox(SeedSignerIconConstants.DELETE + SeedSignerIconConstants.SPACE, anchor="ls") + self.icon_key_height = -1 * top - self.additonal_key_compact_font = Fonts.get_font("RobotoCondensed-Bold", 18) self.x_start = rect[0] self.y_start = rect[1] self.x_gap = 2 diff --git a/src/seedsigner/gui/screens/screen.py b/src/seedsigner/gui/screens/screen.py index 637b00a9..47f6645e 100644 --- a/src/seedsigner/gui/screens/screen.py +++ b/src/seedsigner/gui/screens/screen.py @@ -309,7 +309,7 @@ class ButtonListScreen(BaseTopNavScreen): if len(self.button_data) == 1: button_list_height = button_height else: - button_list_height = (len(self.button_data) * button_height) + (GUIConstants.COMPONENT_PADDING * (len(self.button_data) - 1)) + button_list_height = (len(self.button_data) * button_height) + (GUIConstants.LIST_ITEM_PADDING * (len(self.button_data) - 1)) if self.is_bottom_list: button_list_y = self.canvas_height - (button_list_height + GUIConstants.EDGE_PADDING) @@ -1013,9 +1013,12 @@ class WarningEdgesMixin: @dataclass class WarningScreen(WarningEdgesMixin, LargeIconStatusScreen): + """ + Exclamation point icon + yellow WARNING color + """ title: str = _mft("Caution") status_icon_name: str = SeedSignerIconConstants.WARNING - status_color: str = "yellow" + status_color: str = GUIConstants.WARNING_COLOR status_headline: str = _mft("Privacy Leak!") # The colored text under the alert icon button_data: list = field(default_factory=lambda: [ButtonOption("I Understand")]) @@ -1023,11 +1026,25 @@ class WarningScreen(WarningEdgesMixin, LargeIconStatusScreen): @dataclass class DireWarningScreen(WarningScreen): + """ + Exclamation point icon + orange DIRE_WARNING color + """ status_headline: str = _mft("Classified Info!") # The colored text under the alert icon status_color: str = GUIConstants.DIRE_WARNING_COLOR +@dataclass +class ErrorScreen(WarningScreen): + """ + X icon + red ERROR color + """ + title: str = _mft("Error") + status_icon_name: str = SeedSignerIconConstants.ERROR + status_color: str = GUIConstants.ERROR_COLOR + + + @dataclass class ResetScreen(BaseTopNavScreen): def __post_init__(self): diff --git a/src/seedsigner/gui/screens/settings_screens.py b/src/seedsigner/gui/screens/settings_screens.py index 794e5b2e..c41eadfc 100644 --- a/src/seedsigner/gui/screens/settings_screens.py +++ b/src/seedsigner/gui/screens/settings_screens.py @@ -6,7 +6,7 @@ from PIL.ImageOps import autocontrast from typing import List from seedsigner.helpers.l10n import mark_for_translation as _mft -from seedsigner.gui.components import Button, CheckboxButton, CheckedSelectionButton, FontAwesomeIconConstants, Fonts, GUIConstants, Icon, IconButton, IconTextLine, TextArea +from seedsigner.gui.components import Button, CheckboxButton, CheckedSelectionButton, FontAwesomeIconConstants, Fonts, GUIConstants, Icon, IconButton, IconTextLine, SeedSignerIconConstants, TextArea from seedsigner.gui.screens.scan_screens import ScanScreen from seedsigner.gui.screens.screen import BaseScreen, BaseTopNavScreen, ButtonListScreen, ButtonOption from seedsigner.hardware.buttons import HardwareButtonsConstants @@ -82,7 +82,7 @@ class IOTestScreen(BaseTopNavScreen): self.components.append(self.joystick_click_button) self.joystick_up_button = IconButton( - icon_name=FontAwesomeIconConstants.ANGLE_UP, + icon_name=SeedSignerIconConstants.CHEVRON_UP, icon_size=GUIConstants.ICON_INLINE_FONT_SIZE, width=input_button_width, height=input_button_height, @@ -93,7 +93,7 @@ class IOTestScreen(BaseTopNavScreen): self.components.append(self.joystick_up_button) self.joystick_down_button = IconButton( - icon_name=FontAwesomeIconConstants.ANGLE_DOWN, + icon_name=SeedSignerIconConstants.CHEVRON_DOWN, icon_size=GUIConstants.ICON_INLINE_FONT_SIZE, width=input_button_width, height=input_button_height, @@ -104,9 +104,8 @@ class IOTestScreen(BaseTopNavScreen): self.components.append(self.joystick_down_button) self.joystick_left_button = IconButton( - text=FontAwesomeIconConstants.ANGLE_LEFT, - font_name=GUIConstants.ICON_FONT_NAME__FONT_AWESOME, - font_size=GUIConstants.ICON_INLINE_FONT_SIZE, + icon_name=SeedSignerIconConstants.CHEVRON_LEFT, + icon_size=GUIConstants.ICON_INLINE_FONT_SIZE, width=input_button_width, height=input_button_height, screen_x=dpad_center_x - input_button_width - GUIConstants.COMPONENT_PADDING, @@ -116,7 +115,7 @@ class IOTestScreen(BaseTopNavScreen): self.components.append(self.joystick_left_button) self.joystick_right_button = IconButton( - icon_name=FontAwesomeIconConstants.ANGLE_RIGHT, + icon_name=SeedSignerIconConstants.CHEVRON_RIGHT, icon_size=GUIConstants.ICON_INLINE_FONT_SIZE, width=input_button_width, height=input_button_height, diff --git a/src/seedsigner/gui/screens/tools_screens.py b/src/seedsigner/gui/screens/tools_screens.py index c99c2b7a..8fab9a16 100644 --- a/src/seedsigner/gui/screens/tools_screens.py +++ b/src/seedsigner/gui/screens/tools_screens.py @@ -43,8 +43,8 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen): time.sleep(0.01) continue - # Check for joystick click to take final entropy image - if self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_PRESS): + # Check for ANYCLICK to take final entropy image + if self.hw_inputs.check_for_low(keys=HardwareButtonsConstants.KEYS__ANYCLICK): # Have to manually update last input time since we're not in a wait_for loop self.hw_inputs.update_last_input_time() self.camera.stop_video_stream_mode() @@ -77,7 +77,7 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen): int(self.renderer.canvas_width/2), self.renderer.canvas_height - GUIConstants.EDGE_PADDING ), - text="< " + _("back") + " | " + _("click joystick"), # TODO: Render with UI elements instead of text + text="< " + _("back") + " | " + _("click a button"), # TODO: Render with UI elements instead of text fill=GUIConstants.BODY_FONT_COLOR, font=instructions_font, stroke_width=4, @@ -123,7 +123,8 @@ class ToolsImageEntropyFinalImageScreen(BaseScreen): ) self.renderer.show_image() - input = self.hw_inputs.wait_for([HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.KEY_RIGHT]) + # LEFT = reshoot, RIGHT / ANYCLICK = accept + input = self.hw_inputs.wait_for([HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.KEY_RIGHT] + HardwareButtonsConstants.KEYS__ANYCLICK) if input == HardwareButtonsConstants.KEY_LEFT: return RET_CODE__BACK_BUTTON @@ -140,7 +141,7 @@ class ToolsDiceEntropyEntryScreen(KeyboardScreen): self.rows = 3 self.cols = 3 self.keyboard_font_name = GUIConstants.ICON_FONT_NAME__FONT_AWESOME - self.keyboard_font_size = None # Force auto-scaling to Key height + self.keyboard_font_size = 36 self.keys_charset = "".join([ FontAwesomeIconConstants.DICE_ONE, FontAwesomeIconConstants.DICE_TWO, diff --git a/src/seedsigner/models/decode_qr.py b/src/seedsigner/models/decode_qr.py index e98a2c56..1135a140 100644 --- a/src/seedsigner/models/decode_qr.py +++ b/src/seedsigner/models/decode_qr.py @@ -507,8 +507,7 @@ class DecodeQR: def is_bitcoin_address(s): if re.search(r'^bitcoin\:.*', s, re.IGNORECASE): return True - elif re.search(r'^((bc1|tb1|bcr|[123]|[mn])[a-zA-HJ-NP-Z0-9]{25,62})$', s): - # TODO: Handle regtest bcrt? + elif re.search(r'^((bc1|tb1|bcr|[123]|[mn])[a-zA-HJ-NP-Z0-9]{25,62})$', s, re.IGNORECASE): return True else: return False @@ -940,61 +939,72 @@ class BitcoinAddressQrDecoder(BaseSingleFrameQrDecoder): def add(self, segment, qr_type=QRType.BITCOIN_ADDRESS): - r = re.search(r'((bc1q|tb1q|bcrt1q|bc1p|tb1p|bcrt1p|[123]|[mn])[a-zA-HJ-NP-Z0-9]{25,64})', segment) - if r != None: - self.address = r.group(1) - - if re.search(r'^((bc1q|tb1q|bcrt1q|bc1p|tb1p|bcrt1p|[123]|[mn])[a-zA-HJ-NP-Z0-9]{25,64})$', self.address) != None: - self.complete = True - self.collected_segments = 1 - - # get address type - r = re.search(r'^((bc1q|tb1q|bcrt1q|bc1p|tb1p|bcrt1p|[123]|[mn])[a-zA-HJ-NP-Z0-9]{25,64})$', self.address) - if r != None: - r = r.group(2) - - if r == "1": - # Legacy P2PKH. mainnet - self.address_type = (SettingsConstants.LEGACY_P2PKH, SettingsConstants.MAINNET) + """ + Input may be prefixed with "bitcoin:" but will be ignored. - elif r == "m" or r == "n": - self.address_type = (SettingsConstants.LEGACY_P2PKH, SettingsConstants.TESTNET) + RegEx searches for a recognizable bitcoin address. + * The `^` ensures that the specified address prefixes can only match at + the beginning of the address. - elif r == "3": - # Nested segwit single sig (p2sh-p2wpkh), nested segwit multisig (p2sh-p2wsh), or legacy multisig (p2sh); mainnet - # TODO: Would be more correct to use a P2SH constant - self.address_type = (SettingsConstants.NESTED_SEGWIT, SettingsConstants.MAINNET) + Result will yield the following match groups: + * group 1: complete address + * group 2: address prefix + """ + address_match = re.search(r'^((bc1q|tb1q|bcrt1q|bc1p|tb1p|bcrt1p|[123]|[mn])[a-zA-HJ-NP-Z0-9]{25,64})', segment.split(":")[-1], re.IGNORECASE) + if address_match != None: + self.address = address_match.group(1) + self.complete = True + self.collected_segments = 1 + + # Have to handle wallets that uppercase bech32 addresses. + # Note that it's safe to lowercase the prefix for ALL addr formats. + addr_prefix = address_match.group(2).lower() + + if addr_prefix == "1": + # Legacy P2PKH. mainnet + self.address_type = (SettingsConstants.LEGACY_P2PKH, SettingsConstants.MAINNET) - elif r == "2": - # Nested segwit single sig (p2sh-p2wpkh), nested segwit multisig (p2sh-p2wsh), or legacy multisig (p2sh); testnet / regtest - self.address_type = (SettingsConstants.NESTED_SEGWIT, SettingsConstants.TESTNET) + elif addr_prefix in ["m", "n"]: + self.address_type = (SettingsConstants.LEGACY_P2PKH, SettingsConstants.TESTNET) - elif r == "bc1q": - # Native Segwit (single sig or multisig), mainnet - self.address_type = (SettingsConstants.NATIVE_SEGWIT, SettingsConstants.MAINNET) + elif addr_prefix == "3": + # Nested segwit single sig (p2sh-p2wpkh), nested segwit multisig (p2sh-p2wsh), or legacy multisig (p2sh); mainnet + # TODO: Would be more correct to use a P2SH constant + self.address_type = (SettingsConstants.NESTED_SEGWIT, SettingsConstants.MAINNET) - elif r == "tb1q": - # Native Segwit (single sig or multisig), testnet - self.address_type = (SettingsConstants.NATIVE_SEGWIT, SettingsConstants.TESTNET) + elif addr_prefix == "2": + # Nested segwit single sig (p2sh-p2wpkh), nested segwit multisig (p2sh-p2wsh), or legacy multisig (p2sh); testnet / regtest + self.address_type = (SettingsConstants.NESTED_SEGWIT, SettingsConstants.TESTNET) - elif r == "bcrt1q": - # Native Segwit (single sig or multisig), regtest - self.address_type = (SettingsConstants.NATIVE_SEGWIT, SettingsConstants.REGTEST) + elif addr_prefix == "bc1q": + # Native Segwit (single sig or multisig), mainnet + self.address_type = (SettingsConstants.NATIVE_SEGWIT, SettingsConstants.MAINNET) - elif r == "bc1p": - # Native Segwit (single sig or multisig), mainnet - self.address_type = (SettingsConstants.TAPROOT, SettingsConstants.MAINNET) + elif addr_prefix == "tb1q": + # Native Segwit (single sig or multisig), testnet + self.address_type = (SettingsConstants.NATIVE_SEGWIT, SettingsConstants.TESTNET) - elif r == "tb1p": - # Native Segwit (single sig or multisig), testnet - self.address_type = (SettingsConstants.TAPROOT, SettingsConstants.TESTNET) + elif addr_prefix == "bcrt1q": + # Native Segwit (single sig or multisig), regtest + self.address_type = (SettingsConstants.NATIVE_SEGWIT, SettingsConstants.REGTEST) - elif r == "bcrt1p": - # Native Segwit (single sig or multisig), regtest - self.address_type = (SettingsConstants.TAPROOT, SettingsConstants.REGTEST) - - return DecodeQRStatus.COMPLETE + elif addr_prefix == "bc1p": + self.address_type = (SettingsConstants.TAPROOT, SettingsConstants.MAINNET) + elif addr_prefix == "tb1p": + self.address_type = (SettingsConstants.TAPROOT, SettingsConstants.TESTNET) + + elif addr_prefix == "bcrt1p": + self.address_type = (SettingsConstants.TAPROOT, SettingsConstants.REGTEST) + # Note: there is no final "else" here because the regex won't return any other matches. + + # If the addr type is case-insensitive, ensure we return it lowercase + if self.address_type[0] in [SettingsConstants.NATIVE_SEGWIT, SettingsConstants.TAPROOT]: + self.address = self.address.lower() + + return DecodeQRStatus.COMPLETE + + logger.debug(f"Invalid address: {segment}") return DecodeQRStatus.INVALID diff --git a/src/seedsigner/resources/seedsigner-translations b/src/seedsigner/resources/seedsigner-translations index 281a3dab..10ee02ae 160000 --- a/src/seedsigner/resources/seedsigner-translations +++ b/src/seedsigner/resources/seedsigner-translations @@ -1 +1 @@ -Subproject commit 281a3dab8304ab17eef57fb6b1a7170ae53c204b +Subproject commit 10ee02ae23156ab97600a407b2da66b84e89d29d diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index 636751d1..b39c9367 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -460,16 +460,14 @@ class PSBTAddressVerificationFailedView(View): def run(self): if self.is_multisig: - title = _("Caution") # TRANSLATOR_NOTE: Variable is either "change" or "self-transfer". - text = _("PSBT's {} address could not be verified with your multisig wallet descriptor.").format(_("change") if self.is_change else _("self-transfer")) + text = _("PSBT's {} address could not be verified from wallet descriptor.").format(_("change") if self.is_change else _("self-transfer")) else: - title = _("Suspicious PSBT") # TRANSLATOR_NOTE: Variable is either "change" or "self-transfer". text = _("PSBT's {} address could not be generated from your seed.").format(_("change") if self.is_change else _("self-transfer")) DireWarningScreen( - title=title, + title=_("Suspicious PSBT"), status_headline=_("Address Verification Failed"), text=text, button_data=[ButtonOption("Discard PSBT")], diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index aa83c6bd..54097c3e 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -5,6 +5,7 @@ from gettext import gettext as _ from seedsigner.helpers.l10n import mark_for_translation as _mft from seedsigner.models.settings import SettingsConstants from seedsigner.views.view import BackStackView, ErrorView, MainMenuView, NotYetImplementedView, View, Destination +from seedsigner.gui.screens.screen import ButtonOption logger = logging.getLogger(__name__) @@ -163,13 +164,7 @@ class ScanView(View): # For now, don't even try to re-do the attempted operation, just reset and # start everything over. self.controller.resume_main_flow = None - return Destination(ErrorView, view_args=dict( - title=_("Error"), - status_headline=_("Unknown QR Type"), - text=_("QRCode is invalid or is a data format not yet supported."), - button_text=_("Done"), - next_destination=Destination(MainMenuView, clear_history=True), - )) + return Destination(ScanInvalidQRTypeView) return Destination(MainMenuView) @@ -212,3 +207,23 @@ class ScanAddressView(ScanView): @property def is_valid_qr_type(self): return self.decoder.is_address + + + +class ScanInvalidQRTypeView(View): + def run(self): + from seedsigner.gui.screens import WarningScreen + + # TODO: This screen says "Error" but is intentionally using the WarningScreen in + # order to avoid the perception that something is broken on our end. This should + # either change to use the red ErrorScreen or the "Error" title should be + # changed to something softer. + self.run_screen( + WarningScreen, + title=_("Error"), + status_headline=_("Unknown QR Type"), + text=_("QRCode is invalid or is a data format not yet supported."), + button_data=[ButtonOption("Done")], + ) + + return Destination(MainMenuView, clear_history=True) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 79313f04..ae369587 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -279,8 +279,9 @@ class SeedMnemonicInvalidView(View): def run(self): button_data = [self.EDIT, self.DISCARD] selected_menu_num = self.run_screen( - WarningScreen, + DireWarningScreen, title=_("Invalid Mnemonic!"), + status_icon_name=SeedSignerIconConstants.ERROR, status_headline=None, text=_("Checksum failure; not a valid seed phrase."), show_back_button=False, @@ -1206,6 +1207,7 @@ class SeedBIP85InvalidChildIndexView(View): DireWarningScreen( title=_("BIP-85 Index Error"), show_back_button=False, + status_icon_name=SeedSignerIconConstants.ERROR, status_headline=_("Invalid Child Index"), text=_("BIP-85 Child Index must be between 0 and 2^31-1."), button_data=[ButtonOption("Try Again")] @@ -1366,6 +1368,7 @@ class SeedWordsBackupTestMistakeView(View): selected_menu_num = DireWarningScreen( title=_("Verification Error"), show_back_button=False, + status_icon_name=SeedSignerIconConstants.ERROR, status_headline=status_headline, button_data=button_data, text=text, @@ -1722,8 +1725,8 @@ class AddressVerificationStartView(View): destination = Destination(SeedSelectSeedView, view_args=dict(flow=Controller.FLOW__VERIFY_SINGLESIG_ADDR), skip_current_view=True) elif self.controller.unverified_address["script_type"] == SettingsConstants.TAPROOT: - # TODO: add Taproot support - return Destination(NotYetImplementedView) + sig_type = SettingsConstants.SINGLE_SIG + destination = Destination(SeedSelectSeedView, view_args=dict(flow=Controller.FLOW__VERIFY_SINGLESIG_ADDR), skip_current_view=True) derivation_path = embit_utils.get_standard_derivation_path( network=self.controller.unverified_address["network"], @@ -1816,10 +1819,6 @@ class SeedAddressVerificationView(View): self.sig_type = self.controller.unverified_address["sig_type"] self.network = self.controller.unverified_address["network"] - if self.script_type == SettingsConstants.TAPROOT: - # TODO: Taproot addr verification - return Destination(NotYetImplementedView) - # TODO: This should be in `Seed` or `PSBT` utility class embit_network = SettingsConstants.map_network_to_embit(self.network) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index e7673eef..66c3a229 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -5,7 +5,7 @@ from typing import Type from seedsigner.helpers.l10n import mark_for_translation as _mft from seedsigner.gui.components import SeedSignerIconConstants from seedsigner.gui.screens import RET_CODE__POWER_BUTTON, RET_CODE__BACK_BUTTON -from seedsigner.gui.screens.screen import BaseScreen, ButtonOption, DireWarningScreen, LargeButtonScreen, PowerOffScreen, PowerOffNotRequiredScreen, ResetScreen, WarningScreen +from seedsigner.gui.screens.screen import BaseScreen, ButtonOption, LargeButtonScreen, WarningScreen, ErrorScreen from seedsigner.models.settings import Settings, SettingsConstants from seedsigner.models.settings_definition import SettingsDefinition from seedsigner.models.threads import BaseThread @@ -245,6 +245,7 @@ class PowerOptionsView(View): class RestartView(View): def run(self): + from seedsigner.gui.screens.screen import ResetScreen thread = RestartView.DoResetThread() thread.start() self.run_screen(ResetScreen) @@ -270,6 +271,7 @@ class RestartView(View): class PowerOffView(View): def run(self): + from seedsigner.gui.screens.screen import PowerOffNotRequiredScreen self.run_screen(PowerOffNotRequiredScreen) return Destination(BackStackView) @@ -300,6 +302,7 @@ class NotYetImplementedView(View): class ErrorView(View): title: str = _mft("Error") show_back_button: bool = True + status_icon_name: str = SeedSignerIconConstants.ERROR status_headline: str = None text: str = None button_text: str = None @@ -307,8 +310,9 @@ class ErrorView(View): def run(self): self.run_screen( - WarningScreen, + ErrorScreen, title=self.title, + status_icon_name=self.status_icon_name, status_headline=self.status_headline, text=self.text, button_data=[ButtonOption(self.button_text)], @@ -324,9 +328,14 @@ class NetworkMismatchErrorView(ErrorView): def __post_init__(self): from seedsigner.views.settings_views import SettingsEntryUpdateSelectionView - self.title: str = _("Network Mismatch") - self.show_back_button: bool = False - self.button_text: str = _("Change Setting") + + # TRANSLATOR_NOTE: The network setting (mainnet/testnet/regtest) doesn't match the provided derivation path + self.title = _("Network Mismatch") + self.status_icon_name = SeedSignerIconConstants.WARNING + self.show_back_button = False + + # TRANSLATOR_NOTE: Button option to alter a setting + self.button_text = _("Change Setting") self.next_destination = Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=SettingsConstants.SETTING__NETWORK), clear_history=True) super().__post_init__() @@ -342,10 +351,9 @@ class NetworkMismatchErrorView(ErrorView): class UnhandledExceptionView(View): error: list[str] - def run(self): self.run_screen( - DireWarningScreen, + ErrorScreen, title=_("System Error"), status_headline=self.error[0], text=self.error[1] + "\n" + self.error[2], diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 51dcccb7..3f47bdc1 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -26,7 +26,7 @@ patch('PIL.ImageFont.core.HAVE_RAQM', False).start() from seedsigner.controller import Controller from seedsigner.gui.renderer import Renderer from seedsigner.gui.screens.seed_screens import SeedAddPassphraseScreen -from seedsigner.gui.toast import BaseToastOverlayManagerThread, RemoveSDCardToastManagerThread, SDCardStateChangeToastManagerThread +from seedsigner.gui.toast import RemoveSDCardToastManagerThread, SDCardStateChangeToastManagerThread from seedsigner.gui.toast import DefaultToast, InfoToast, SuccessToast, WarningToast, ErrorToast, DireWarningToast from seedsigner.hardware.microsd import MicroSD from seedsigner.helpers import embit_utils @@ -37,9 +37,9 @@ 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, seed_views, settings_views, tools_views) + psbt_views, seed_views, settings_views, tools_views, scan_views) from seedsigner.views.screensaver import OpeningSplashView -from seedsigner.views.view import ErrorView, NetworkMismatchErrorView, OptionDisabledView, PowerOffView, View +from seedsigner.views.view import NetworkMismatchErrorView, OptionDisabledView, PowerOffView from .utils import ScreenshotComplete, ScreenshotConfig, ScreenshotRenderer @@ -366,12 +366,7 @@ def generate_screenshots(locale): ScreenshotConfig(UnhandledExceptionView, dict(error=["IndexError", "line 1, in some_buggy_code.py", "list index out of range"])), ScreenshotConfig(NetworkMismatchErrorView, dict(derivation_path="m/84'/1'/0'")), ScreenshotConfig(OptionDisabledView, dict(settings_attr=SettingsConstants.SETTING__MESSAGE_SIGNING)), - ScreenshotConfig(ErrorView, dict( - title="Error", - status_headline="Unknown QR Type", - text="QRCode is invalid or is a data format not yet supported.", - button_text="Back", - )), + ScreenshotConfig(scan_views.ScanInvalidQRTypeView) ] } diff --git a/tests/test_decodepsbtqr.py b/tests/test_decodepsbtqr.py index 9b7d6981..7672d62b 100644 --- a/tests/test_decodepsbtqr.py +++ b/tests/test_decodepsbtqr.py @@ -281,69 +281,121 @@ def test_short_4_letter_mnemonic_qr(): assert d.get_seed_phrase() == ["height", "demise", "useless", "trap", "grow", "lion", "found", "off", "key", "clown", "transfer", "enroll"] -def test_bitcoin_address(): - bad1 = "loremipsum" - bad2 = "0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae" - bad3 = "121802020768124106400009195602431595117715840445" - - legacy_address1 = "1KFHE7w8BhaENAswwryaoccDb6qcT6DbYY" - legacy_address2 = "16ftSEQ4ctQFDtVZiUBusQUjRrGhM3JYwe" - - main_bech32_address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" - test_bech32_address = "tb1qkurj377gtlmu0j5flcykcsh2xagexh9h3jk06a" - - main_nested_segwit_address = "3Nu78Cqcf6hsD4sUBAN9nP13tYiHU9QPFX" - test_nested_segwit_address = "2N6JbrvPMMwbBhu2KxqXyyHUQz3XKspvyfm" - - main_bech32_address2 = "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=12000" - main_bech32_address3 = "BITCOIN:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?junk" - - d = DecodeQR() - d.add_data(bad1) - - assert d.qr_type == QRType.INVALID - - d = DecodeQR() - d.add_data(legacy_address1) - - assert d.get_address() == legacy_address1 - assert d.get_address_type() == (SettingsConstants.LEGACY_P2PKH, SettingsConstants.MAINNET) - - d = DecodeQR() - d.add_data(legacy_address2) - - assert d.get_address() == legacy_address2 - assert d.get_address_type() == (SettingsConstants.LEGACY_P2PKH, SettingsConstants.MAINNET) - - d = DecodeQR() - d.add_data(main_bech32_address) - - assert d.get_address() == main_bech32_address - assert d.get_address_type() == (SettingsConstants.NATIVE_SEGWIT, SettingsConstants.MAINNET) - - d = DecodeQR() - d.add_data(test_bech32_address) - - assert d.get_address() == test_bech32_address - assert d.get_address_type() == (SettingsConstants.NATIVE_SEGWIT, SettingsConstants.TESTNET) - - d = DecodeQR() - d.add_data(main_nested_segwit_address) - - assert d.get_address() == main_nested_segwit_address - assert d.get_address_type() == (SettingsConstants.NESTED_SEGWIT, SettingsConstants.MAINNET) - - d = DecodeQR() - d.add_data(test_nested_segwit_address) +# Test data for bitcoin address decoding. All generated from test key: ["abandon"] * 11 + ["about"] +legacy_address_mainnet = "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA" +legacy_address_testnet = "mkpZhYtJu2r87Js3pDiWJDmPte2NRZ8bJV" - assert d.get_address() == test_nested_segwit_address - assert d.get_address_type() == (SettingsConstants.NESTED_SEGWIT, SettingsConstants.TESTNET) +nested_segwit_address_mainnet = "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf" +nested_segwit_address_testnet = "2Mww8dCYPUpKHofjgcXcBCEGmniw9CoaiD2" - d = DecodeQR() - d.add_data(main_bech32_address2) - - assert d.get_address() == "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" - assert d.get_address_type() == (SettingsConstants.NATIVE_SEGWIT, SettingsConstants.MAINNET) +native_segwit_address_mainnet = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" +native_segwit_address_testnet = "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" +native_segwit_address_regtest = "bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk" + +taproot_address_mainnet = "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr" +taproot_address_testnet = "tb1p8wpt9v4frpf3tkn0srd97pksgsxc5hs52lafxwru9kgeephvs7rqlqt9zj" +taproot_address_regtest = "bcrt1p8wpt9v4frpf3tkn0srd97pksgsxc5hs52lafxwru9kgeephvs7rqjeprhg" + + + +def test_bitcoin_address(): + """ + Decoder should parse various types of valid bitcoin addresses with or without the + "bitcoin:" prefix and optional query params. + """ + def decode(address, expected_script_type, expected_network=SettingsConstants.MAINNET): + for data in [address, "bitcoin:" + address, "bitcoin:" + address + "?amount=12000"]: + d = DecodeQR() + d.add_data(data) + assert d.get_address() == address + assert d.get_address_type() == (expected_script_type, expected_network) + + decode(legacy_address_mainnet, SettingsConstants.LEGACY_P2PKH) + decode(legacy_address_testnet, SettingsConstants.LEGACY_P2PKH, SettingsConstants.TESTNET) + decode(nested_segwit_address_mainnet, SettingsConstants.NESTED_SEGWIT) + decode(nested_segwit_address_testnet, SettingsConstants.NESTED_SEGWIT, SettingsConstants.TESTNET) + decode(native_segwit_address_mainnet, SettingsConstants.NATIVE_SEGWIT) + decode(native_segwit_address_testnet, SettingsConstants.NATIVE_SEGWIT, SettingsConstants.TESTNET) + decode(native_segwit_address_regtest, SettingsConstants.NATIVE_SEGWIT, SettingsConstants.REGTEST) + decode(taproot_address_mainnet, SettingsConstants.TAPROOT) + decode(taproot_address_testnet, SettingsConstants.TAPROOT, SettingsConstants.TESTNET) + decode(taproot_address_regtest, SettingsConstants.TAPROOT, SettingsConstants.REGTEST) + + + +def test_invalid_bitcoin_address(): + """ + Decoder should fail to parse invalid address data. + * Test incorrect "bitcoin:" prefix. + * Test invalid addresses. + * Test valid addresses w/additional prefixes (to ensure regexp is not finding + mid-string matches) which make the data invalid. + """ + bad_inputs = [ + # wrong separator char + "bitcoin=bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + + # Unrecognized addr prefix + "bitcoin:bcfakehrp1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + + # valid addr w/garbage addr prefix + "abcbc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + + # valid "bitcoin:" prefix w/garbage addr prefix + "bitcoin:abcbc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + + # typo in "bitcoin:" prefix + "bitcon:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + ] + + for bad_input in bad_inputs: + d = DecodeQR() + status = d.add_data(bad_input) + assert status == DecodeQRStatus.INVALID + + + +def test_bitcoin_address_ignores_case_where_allowed(): + """ + Decoder should ignore case in QR data prefix and in the address itself (for the + address types where case is ignored). + """ + def decode(address, expected_script_type, is_case_sensitive, expected_network=SettingsConstants.MAINNET): + addr_variations = [address] + if not is_case_sensitive: + # Test as-is and all uppercase + addr_variations.append(address.upper()) + + for addr_variation in addr_variations: + # First add prefix capitalizations + variations_1 = ["bitcoin:" + addr_variation, "BITCOIN:" + addr_variation] + + # Now add query params + variations_2 = [v + "?amount=12000" for v in variations_1] + variations_3 = [v + "?AMOUNT=12000" for v in variations_1] + for data in [addr_variation] + variations_1 + variations_2 + variations_3: + d = DecodeQR() + d.add_data(data) + assert d.get_address_type() == (expected_script_type, expected_network) + + if not is_case_sensitive: + assert d.get_address() == addr_variation.lower() + else: + assert d.get_address() == addr_variation + + # Case sensitive address types + decode(legacy_address_mainnet, SettingsConstants.LEGACY_P2PKH, is_case_sensitive=True) + decode(legacy_address_testnet, SettingsConstants.LEGACY_P2PKH, is_case_sensitive=True, expected_network=SettingsConstants.TESTNET) + decode(nested_segwit_address_mainnet, SettingsConstants.NESTED_SEGWIT, is_case_sensitive=True) + decode(nested_segwit_address_testnet, SettingsConstants.NESTED_SEGWIT, is_case_sensitive=True, expected_network=SettingsConstants.TESTNET) + + # Case insensitive address types + decode(native_segwit_address_mainnet, SettingsConstants.NATIVE_SEGWIT, is_case_sensitive=False) + decode(native_segwit_address_testnet, SettingsConstants.NATIVE_SEGWIT, is_case_sensitive=False, expected_network=SettingsConstants.TESTNET) + decode(native_segwit_address_regtest, SettingsConstants.NATIVE_SEGWIT, is_case_sensitive=False, expected_network=SettingsConstants.REGTEST) + decode(taproot_address_mainnet, SettingsConstants.TAPROOT, is_case_sensitive=False) + decode(taproot_address_testnet, SettingsConstants.TAPROOT, is_case_sensitive=False, expected_network=SettingsConstants.TESTNET) + decode(taproot_address_regtest, SettingsConstants.TAPROOT, is_case_sensitive=False, expected_network=SettingsConstants.REGTEST) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 490e9f15..2e63e702 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -646,7 +646,7 @@ class TestMessageSigningFlows(FlowTest): 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_invalid_signmessage_qr), # simulate read message QR; ret val is ignored - FlowStep(ErrorView), + FlowStep(scan_views.ScanInvalidQRTypeView), FlowStep(MainMenuView), ]) diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index 968b72fc..a7bfa2c4 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -257,16 +257,25 @@ class TestToolsFlows(FlowTest): settings = controller.settings settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.REGTEST) - def load_address_into_decoder(view: scan_views.ScanView): + addrs = [ # Native segwit regtest receive addr @ index 6 - view.decoder.add_data("bcrt1q4e9q5taxnsvc6m0uxv6h75mkzvnkxeqk6l90u2") + "bcrt1q4e9q5taxnsvc6m0uxv6h75mkzvnkxeqk6l90u2", - self.run_sequence([ - FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), - FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.VERIFY_ADDRESS), - FlowStep(scan_views.ScanAddressView, before_run=load_address_into_decoder), # simulate read address QR - FlowStep(seed_views.AddressVerificationStartView, is_redirect=True), - FlowStep(seed_views.SeedSelectSeedView, screen_return_value=0), - FlowStep(seed_views.SeedAddressVerificationView), - FlowStep(seed_views.SeedAddressVerificationSuccessView), - ]) + # Taproot regtest change addr @ index 48 + "bcrt1pj5v8ean2hc5lh2djsgfx4j9uc0n67942ngv6q9r49qv88ex5mrwsn3u4f7", + ] + + for test_addr in addrs: + def load_address_into_decoder(view: scan_views.ScanView): + # Native segwit regtest receive addr @ index 6 + view.decoder.add_data(test_addr) + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.VERIFY_ADDRESS), + FlowStep(scan_views.ScanAddressView, before_run=load_address_into_decoder), # simulate read address QR + FlowStep(seed_views.AddressVerificationStartView, is_redirect=True), + FlowStep(seed_views.SeedSelectSeedView, screen_return_value=0), + FlowStep(seed_views.SeedAddressVerificationView), + FlowStep(seed_views.SeedAddressVerificationSuccessView), + ])