diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 73637522..00000000 --- a/.coveragerc +++ /dev/null @@ -1,40 +0,0 @@ -[run] -branch = True - -[report] -skip_empty = True -skip_covered = True - -# Omit; need a different approach to test modules with hardware dependencies -omit = - */__init__.py - */tests/* - */pyzbar/* - */gui/* - -# Regexes for lines to exclude from consideration -exclude_lines = - # Have to re-enable the standard pragma - pragma: no cover - - # Don't complain about missing debug-only code: - def __repr__ - def __str__ - if self\.debug - - # Don't complain if tests don't hit defensive assertion code: - raise AssertionError - raise NotImplementedError - - # Don't complain if non-runnable code isn't run: - if 0: - if __name__ == .__main__.: - - # Don't complain about abstract methods, they aren't run: - @(abc\.)?abstractmethod - - -[html] -directory = coverage_html_report -skip_empty = True -skip_covered = False diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cb7c0575..294e20be 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,9 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10"] + # 3.10: currently used by Seedsigner + # 3.12: latest stable Python as upper test bound + python-version: ["3.10", "3.12"] steps: - uses: actions/checkout@v3 @@ -34,7 +36,7 @@ jobs: sudo apt-get install libzbar0 python -m pip install --upgrade pip pip install -r requirements.txt -r tests/requirements.txt - python setup.py install + pip install . - name: Test with pytest run: | mkdir artifacts diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 4b597e4e..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -recursive-include src/seedsigner/resources * diff --git a/README.md b/README.md index 318ff485..59fe0d80 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ If you have specific questions about the project, our [Telegram Group](https://t * Optimized seed word entry interface * Support for Bitcoin Mainnet & Testnet * Support for custom user-defined derivation paths +* Support for loading Electrum Segwit seed phrases with feature limitations: [Electrum support info](docs/electrum.md) * On-demand receive address verification * Address Explorer for single sig and multisig wallets * User-configurable QR code display density diff --git a/docs/electrum.md b/docs/electrum.md new file mode 100644 index 00000000..6c07ca31 --- /dev/null +++ b/docs/electrum.md @@ -0,0 +1,15 @@ +# SeedSigner Electrum seed phrase support + +SeedSigner supports loading of [Electrum's Segwit seed phrases](https://electrum.readthedocs.io/en/latest/seedphrase.html#electrum-seed-version-system). This is considered an Advanced feature that is disabled by default. + +To load an Electrum Segwit seed phrase, first enable Electrum seed support in Settings -> Advanced -> Electrum seed support. After this option is enabled, the user will now be able to enter an Electrum seed phrase by selecting "Enter Electrum seed" in the Load Seed screen. + +Some SeedSigner functionality is deliberately disabled when using an Electrum mnemonic: + +- BIP-85 child seeds + - Not applicable for Electrum seed types +- SeedQR backups + - Since Electrum seeds are not supported by other SeedQR implementations, it would be dangerous to use SeedQR as a backup tool for Electrum seeds and is thus disabled +- Custom derivations + - Hard coded derivation path and script types in SeedSigner to match Electrum wallet software. These are m/0h for single sig and m/1h for multisig + - User-chosen custom derivations are thus not supported for Electrum seeds diff --git a/docs/seed_qr/README.md b/docs/seed_qr/README.md index 1d2a1a10..40beaf8d 100644 --- a/docs/seed_qr/README.md +++ b/docs/seed_qr/README.md @@ -1,8 +1,8 @@ # SeedQR Format Specification -[SeedSigner](https://github.com/SeedSigner/seedsigner/) is an open source, DIY, fully-airgapped Bitcoin hardware wallet that wipes all private data from memory each time it's turned off. That means users need to re-enter their Bitcoin private key each time they use it. +[SeedSigner](https://github.com/SeedSigner/seedsigner/) is an open source, DIY, fully-airgapped Bitcoin hardware wallet that wipes all private data from memory each time it's turned off. That means users need to re-enter their mnemonic seed phrase each time they use it. -To speed up this key entry process we have defined a way to encode a private key as a QR code that can be instantly scanned into a SeedSigner or potentially any other Bitcoin hardware wallet that has a camera. +To speed up this key entry process we have defined a way to encode a BIP-39 mnemonic seed phrase as a QR code that can be instantly scanned into a SeedSigner or potentially any other Bitcoin hardware wallet that has a camera. The approach is specifically designed to encode the minimum possible amount of data in order to keep the resulting QR code small enough that it can be transcribed *by hand*. This sounds ridiculous at first, but remember that this is secret data that should never be stored in any digital medium. And even printers present some additional risk vectors. @@ -20,7 +20,7 @@ Specifications for each follow below, as well as discussion of the pros and cons ## Quick Review of BIP-39 Mnemonic Seed Phrases -The typical method for backing up a Bitcoin private key is to store it as a [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) mnemonic seed phrase that consists of 12 or 24 words. +The typical method for backing up a Bitcoin wallet is to store its [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) mnemonic seed phrase consisting of 12 or 24 words. Each word comes from a [list of 2048 words](https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt). The words themselves are meaningless; all that matters is the word's position number (aka index) in the word list. @@ -150,7 +150,7 @@ But here the unit being described isn't alphanumeric characters or numeric digit 1 byte = 8 bits ``` -Rather than having the QR format interpret our data as numbers or characters, we can directly encode the relevant bits that determine our Bitcoin private key. +Rather than having the QR format interpret our data as numbers or characters, we can directly encode the relevant bits that determine our mnemonic seed phrase. We can extract exactly those bits from our mnemonic seed phrase digit stream that we generated above. @@ -311,7 +311,7 @@ Conversely, having limited support for reading binary QR codes and the complicat # Some Additional Notes on QR Codes -Our main use case is to be able to quickly initialize a SeedSigner with your Bitcoin private key. But using a QR code as your key loader--or even as your permanent backup etched in metal--has other advantages. +Our main use case is to be able to quickly initialize a SeedSigner with your mnemonic seed phrase. But using a QR code as your key loader--or even as your permanent backup etched in metal--has other advantages. QR codes are ubiquitous now so plenty of hardware and software exists to read and generate them. diff --git a/enclosures/pushcase/3mf/Bottom - Push Case - SeedSigner.3mf b/enclosures/pushcase/3mf/Bottom - Push Case - SeedSigner.3mf new file mode 100644 index 00000000..c8303e29 Binary files /dev/null and b/enclosures/pushcase/3mf/Bottom - Push Case - SeedSigner.3mf differ diff --git a/enclosures/pushcase/3mf/Buttons - Push Case - SeedSigner.3mf b/enclosures/pushcase/3mf/Buttons - Push Case - SeedSigner.3mf new file mode 100644 index 00000000..07f40bfc Binary files /dev/null and b/enclosures/pushcase/3mf/Buttons - Push Case - SeedSigner.3mf differ diff --git a/enclosures/pushcase/3mf/Middle - Push Case - SeedSigner.3mf b/enclosures/pushcase/3mf/Middle - Push Case - SeedSigner.3mf new file mode 100644 index 00000000..88fb6ced Binary files /dev/null and b/enclosures/pushcase/3mf/Middle - Push Case - SeedSigner.3mf differ diff --git a/enclosures/pushcase/3mf/Soldering Support - Push Case - SeedSigner.3mf b/enclosures/pushcase/3mf/Soldering Support - Push Case - SeedSigner.3mf new file mode 100644 index 00000000..bafe0dd1 Binary files /dev/null and b/enclosures/pushcase/3mf/Soldering Support - Push Case - SeedSigner.3mf differ diff --git a/enclosures/pushcase/3mf/Top - Push Case - SeedSigner.3mf b/enclosures/pushcase/3mf/Top - Push Case - SeedSigner.3mf new file mode 100644 index 00000000..38a0c50a Binary files /dev/null and b/enclosures/pushcase/3mf/Top - Push Case - SeedSigner.3mf differ diff --git a/enclosures/pushcase/README.md b/enclosures/pushcase/README.md new file mode 100644 index 00000000..0f26b4fb --- /dev/null +++ b/enclosures/pushcase/README.md @@ -0,0 +1,130 @@ +# The Push Case + + + +## Motivation + +The motivation for this design was to get a thin, screwless and good looking enclosure with minimal secondary hardware requirements. + +## Characterisics + +- No screws needed +- Soldering required +- Mini-HDMI port not accessible +- Removeable memory card accessible +- Data-enabled USB port not accessible + +## Hardware restrictions + +- Raspberry Pi Zero without pre-installed GPIO pins required. +- Raspberry Pi Camera in smaller "Zero" style version required. + +## Secondary hardware requirements + +- 40x pins (6mm / 0.04in) +- Soldering iron and solder + +## 3D prints and screwless design + +Please note that the prints must be accurate in order to fit tightly without screws and for the buttons to perform smoothly. The prints are well tested with a Prusa MK3s (.3mf files attached), Prusament PLA and hardware components from early 2024. With this setup, the enclosure holds strongly together, but can still be opened again with a little effort (although this is not recommended, as it may not hold together as tightly afterwards). Other printers or newer or older hardware may produce results that do not fit and in worst case damage the hardware. + +## Known issues + +- Bambu Studio Slicer: There is a strange problem when slicing the buttons. A few layers are missing. As a workaround you can use another slicer, e.g. the Prusa Slicer. + +## Assembly + +### 1. Get all the parts + + + +- Raspberry Pi Zero v.1.3 +- Waveshare LCD Display (240x240) +- Camera for Raspberry Pi Zero +- 40 pins (6mm / 0.04in, cut them to size if you can only find larger ones) +- Soldering iron and solder (not on picture) +- Soldering support (3D print) +- Top, middle, back and buttons (3D prints) +- Tweezers (if available - not on picture) + +### 2. Place the display and the Raspberry Pi on the soldering support + +

+ + + + +

+ +- Remove the screen protection of the LCD display. +- Place the display face down on the soldering support. +- Place the middle support on the display. +- Place the Raspberry Pi face down on the middle support. + +### 3. Solder the pins + +

+ + + +

+ +- Insert the 40 pins with the help of tweezers and push them all the way down. +- Solder the pins (please pay attention not to melt the orange support elements!). +- Optional: Pull out the Raspberry Pi to check that the pins are holding properly after soldering. + +### 4. Attach camera + +

+ + + +

+ +- Carefully pull out the camera cable mount (ideally with the support of a tool). +- Plug in the camera cable (it doesn't go very deep). +- Close the camera cable mount to fix the cable. + +### 5. Stick on the back part + +

+ + + +

+ +- Remove the camera protection. +- Bend the camera cable into position (do not use the adhesive tape!). +- Place the back part on the signer, but do not push it in completely yet. +- Align the camera precisely with the help of a tool. +- Push the back part in completely (the camera should fit perfectly and have no more play). + +### 6. Stick on the front part + +

+ + + + +

+ +- Remove the soldering support. +- Insert the 3 buttons into the front. +- Press the front completely into the signer (hold the device upside down so that the buttons do not fall out). +- Plug in the thumbstick. + +### 7. Flash a MicroSD card + +- Please follow the documentation on [SeedSigner.com](https://seedsigner.com). + +## Contact + +Get in touch if you have any questions or suggestions. Success stories are also welcome. :) You can reach me on [X (@kayth21)](https://x.com/kayth21) and [Telegram (@Kayth_21)](https://t.me/kayth_21). + +## Donation + +If you like my work and want to honor it, I appreciate any small donation to my Lightning Address kayth@blink.sv. But even more important, please consider a donation to the [Seedsigner Development Fund](https://seedsigner.com/donate-now/). + +## Shop + +You can also get a push case, or a fully assembled SeedSigner, from my store [bitcoin-store.org](https://bitcoin-store.org). \ No newline at end of file diff --git a/enclosures/pushcase/images/back1.png b/enclosures/pushcase/images/back1.png new file mode 100644 index 00000000..2bbd3675 Binary files /dev/null and b/enclosures/pushcase/images/back1.png differ diff --git a/enclosures/pushcase/images/back2.png b/enclosures/pushcase/images/back2.png new file mode 100644 index 00000000..b5852250 Binary files /dev/null and b/enclosures/pushcase/images/back2.png differ diff --git a/enclosures/pushcase/images/camera1.png b/enclosures/pushcase/images/camera1.png new file mode 100644 index 00000000..3c2316ce Binary files /dev/null and b/enclosures/pushcase/images/camera1.png differ diff --git a/enclosures/pushcase/images/camera2.png b/enclosures/pushcase/images/camera2.png new file mode 100644 index 00000000..240f082c Binary files /dev/null and b/enclosures/pushcase/images/camera2.png differ diff --git a/enclosures/pushcase/images/camera3.png b/enclosures/pushcase/images/camera3.png new file mode 100644 index 00000000..149f7601 Binary files /dev/null and b/enclosures/pushcase/images/camera3.png differ diff --git a/enclosures/pushcase/images/camera4.png b/enclosures/pushcase/images/camera4.png new file mode 100644 index 00000000..17e79cda Binary files /dev/null and b/enclosures/pushcase/images/camera4.png differ diff --git a/enclosures/pushcase/images/camera5.png b/enclosures/pushcase/images/camera5.png new file mode 100644 index 00000000..3c642afe Binary files /dev/null and b/enclosures/pushcase/images/camera5.png differ diff --git a/enclosures/pushcase/images/components.png b/enclosures/pushcase/images/components.png new file mode 100644 index 00000000..01603d24 Binary files /dev/null and b/enclosures/pushcase/images/components.png differ diff --git a/enclosures/pushcase/images/front1.png b/enclosures/pushcase/images/front1.png new file mode 100644 index 00000000..4bd4ac78 Binary files /dev/null and b/enclosures/pushcase/images/front1.png differ diff --git a/enclosures/pushcase/images/front2.png b/enclosures/pushcase/images/front2.png new file mode 100644 index 00000000..4fc4547f Binary files /dev/null and b/enclosures/pushcase/images/front2.png differ diff --git a/enclosures/pushcase/images/front3.png b/enclosures/pushcase/images/front3.png new file mode 100644 index 00000000..2d6ecbc8 Binary files /dev/null and b/enclosures/pushcase/images/front3.png differ diff --git a/enclosures/pushcase/images/front4.png b/enclosures/pushcase/images/front4.png new file mode 100644 index 00000000..6fe04dd7 Binary files /dev/null and b/enclosures/pushcase/images/front4.png differ diff --git a/enclosures/pushcase/images/pushcase.png b/enclosures/pushcase/images/pushcase.png new file mode 100644 index 00000000..50881b35 Binary files /dev/null and b/enclosures/pushcase/images/pushcase.png differ diff --git a/enclosures/pushcase/images/solder1.png b/enclosures/pushcase/images/solder1.png new file mode 100644 index 00000000..adf42626 Binary files /dev/null and b/enclosures/pushcase/images/solder1.png differ diff --git a/enclosures/pushcase/images/solder2.png b/enclosures/pushcase/images/solder2.png new file mode 100644 index 00000000..7321b060 Binary files /dev/null and b/enclosures/pushcase/images/solder2.png differ diff --git a/enclosures/pushcase/images/solder3.png b/enclosures/pushcase/images/solder3.png new file mode 100644 index 00000000..82640eb5 Binary files /dev/null and b/enclosures/pushcase/images/solder3.png differ diff --git a/enclosures/pushcase/images/stack1.png b/enclosures/pushcase/images/stack1.png new file mode 100644 index 00000000..5a34e05f Binary files /dev/null and b/enclosures/pushcase/images/stack1.png differ diff --git a/enclosures/pushcase/images/stack2.png b/enclosures/pushcase/images/stack2.png new file mode 100644 index 00000000..af4482bb Binary files /dev/null and b/enclosures/pushcase/images/stack2.png differ diff --git a/enclosures/pushcase/images/stack3.png b/enclosures/pushcase/images/stack3.png new file mode 100644 index 00000000..04fef4f8 Binary files /dev/null and b/enclosures/pushcase/images/stack3.png differ diff --git a/enclosures/pushcase/images/stack4.png b/enclosures/pushcase/images/stack4.png new file mode 100644 index 00000000..a5025c66 Binary files /dev/null and b/enclosures/pushcase/images/stack4.png differ diff --git a/enclosures/pushcase/stl/Bottom - Push Case - SeedSigner.stl b/enclosures/pushcase/stl/Bottom - Push Case - SeedSigner.stl new file mode 100644 index 00000000..9f885b17 Binary files /dev/null and b/enclosures/pushcase/stl/Bottom - Push Case - SeedSigner.stl differ diff --git a/enclosures/pushcase/stl/Button - Push Case - SeedSigner.stl b/enclosures/pushcase/stl/Button - Push Case - SeedSigner.stl new file mode 100644 index 00000000..bbaf8113 Binary files /dev/null and b/enclosures/pushcase/stl/Button - Push Case - SeedSigner.stl differ diff --git a/enclosures/pushcase/stl/Middle - Push Case - SeedSigner.stl b/enclosures/pushcase/stl/Middle - Push Case - SeedSigner.stl new file mode 100644 index 00000000..410ddb81 Binary files /dev/null and b/enclosures/pushcase/stl/Middle - Push Case - SeedSigner.stl differ diff --git a/enclosures/pushcase/stl/Soldering Support - Push Case - SeedSigner.stl b/enclosures/pushcase/stl/Soldering Support - Push Case - SeedSigner.stl new file mode 100644 index 00000000..60d1adbf Binary files /dev/null and b/enclosures/pushcase/stl/Soldering Support - Push Case - SeedSigner.stl differ diff --git a/enclosures/pushcase/stl/Thumbstick - Push Case - SeedSigner.stl b/enclosures/pushcase/stl/Thumbstick - Push Case - SeedSigner.stl new file mode 100644 index 00000000..1488ad3c Binary files /dev/null and b/enclosures/pushcase/stl/Thumbstick - Push Case - SeedSigner.stl differ diff --git a/enclosures/pushcase/stl/Top - Push Case - SeedSigner.stl b/enclosures/pushcase/stl/Top - Push Case - SeedSigner.stl new file mode 100644 index 00000000..36f19ff7 Binary files /dev/null and b/enclosures/pushcase/stl/Top - Push Case - SeedSigner.stl differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..002bfe2c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,69 @@ +[build-system] +build-backend = "setuptools.build_meta" +requires = ["setuptools", "wheel"] + +[project] +authors = [{name = "SeedSigner", email = "author@example.com"}] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent" +] +description = "Build an offline, airgapped Bitcoin signing device for less than $50!" +name = "seedsigner" +readme = "README.md" +requires-python = ">=3.10" +version = "0.8.0-rc1" + +[project.urls] +"Bug Tracker" = "https://github.com/SeedSigner/seedsigner/issues" +Homepage = "https://seedsigner.com/" +Repository = "https://github.com/SeedSigner/seedsigner" + +[tool.coverage.html] +directory = "coverage_html_report" +skip_covered = false +skip_empty = true + +[tool.coverage.report] +# Regexes for lines to exclude from consideration +exclude_lines = [ + # Have to re-enable the standard pragma + "pragma: no cover", + # Don't complain about missing debug-only code: + "def __repr__", + "def __str__", + "if self\\.debug", + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + # Don't complain if non-runnable code isn't run: + "if 0:", + "if __name__ == .__main__.:", + # Don't complain about abstract methods, they aren't run: + "@(abc\\.)?abstractmethod" +] +# Omit; need a different approach to test modules with hardware dependencies +omit = [ + "*/__init__.py", + "*/tests/*", + "*/pyzbar/*", + "*/gui/*" +] +skip_covered = true +skip_empty = true + +[tool.coverage.run] +branch = true + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.package-data] +"seedsigner.resources" = ["**"] + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index de19c9f0..00000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -testpaths = tests \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f86231b0..89a1a589 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -embit==0.7.0 -Pillow==9.4.0 +embit==0.8.0 +Pillow==10.0.1 pyzbar @ git+https://github.com/seedsigner/pyzbar.git@c3c237821c6a20b17953efe59b90df0b514a1c03 qrcode==7.3.1 urtypes @ git+https://github.com/selfcustody/urtypes.git@7fb280eab3b3563dfc57d2733b0bf5cbc0a96a6a diff --git a/setup.py b/setup.py deleted file mode 100644 index deb7c594..00000000 --- a/setup.py +++ /dev/null @@ -1,27 +0,0 @@ -import setuptools - -with open("README.md", "r", encoding="utf-8") as fh: - long_description = fh.read() - -setuptools.setup( - name="seedsigner", - version="0.7.0", - author="SeedSigner", - author_email="author@example.com", - description="Build an offline, airgapped Bitcoin signing device for less than $50!", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/SeedSigner/seedsigner", - project_urls={ - "Bug Tracker": "https://github.com/SeedSigner/seedsigner/issues", - }, - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - ], - package_dir={"": "src"}, - packages=setuptools.find_packages(where="src"), - include_package_data=True, - python_requires=">=3.10", -) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index f0a95287..37d80e47 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.7.0" + VERSION = "0.8.0-rc1" # Declare class member vars with type hints to enable richer IDE support throughout # the code. diff --git a/src/seedsigner/gui/screens/psbt_screens.py b/src/seedsigner/gui/screens/psbt_screens.py index 330742d5..502c3921 100644 --- a/src/seedsigner/gui/screens/psbt_screens.py +++ b/src/seedsigner/gui/screens/psbt_screens.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +import math from PIL import Image, ImageDraw, ImageFilter from typing import List import time @@ -21,6 +22,7 @@ class PSBTOverviewScreen(ButtonListScreen): num_self_transfer_outputs: int = 0 num_change_outputs: int = 0 destination_addresses: List[str] = None + has_op_return: bool = False def __post_init__(self): @@ -143,6 +145,9 @@ class PSBTOverviewScreen(ButtonListScreen): destination_column.append(f"fee") + if self.has_op_return: + destination_column.append("OP_RETURN") + if self.num_change_outputs > 0: for i in range(0, self.num_change_outputs): destination_column.append("change") @@ -681,6 +686,57 @@ class PSBTChangeDetailsScreen(ButtonListScreen): +@dataclass +class PSBTOpReturnScreen(ButtonListScreen): + op_return_data: bytes = None + + def __post_init__(self): + # Customize defaults + self.is_bottom_list = True + + super().__post_init__() + + try: + # Simple case: display human-readable text + self.components.append(TextArea( + text=self.op_return_data.decode(errors="strict"), # "strict" is a good enough heuristic to decide if it's human readable + font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE, + is_text_centered=True, + allow_text_overflow=True, + screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, + height=self.buttons[0].screen_y - self.top_nav.height - 2*GUIConstants.COMPONENT_PADDING, + )) + return + except UnicodeDecodeError: + # Contains data that can't be converted to UTF-8; probably encoded and not + # meant to be human readable. + font = Fonts.get_font(GUIConstants.FIXED_WIDTH_FONT_NAME, size=GUIConstants.BODY_FONT_SIZE) + (left, top, right, bottom) = font.getbbox("X", anchor="ls") + chars_per_line = int((self.canvas_width - 2*GUIConstants.EDGE_PADDING) / (right - left)) + decoded_str = self.op_return_data.hex() + num_lines = math.ceil(len(decoded_str) / chars_per_line) + text = "" + for i in range(num_lines): + text += (decoded_str[i*chars_per_line:(i+1)*chars_per_line]) + "\n" + text = text[:-1] + + label = TextArea( + text="raw hex data", + font_color=GUIConstants.LABEL_FONT_COLOR, + font_size=GUIConstants.LABEL_FONT_SIZE, + screen_y=self.top_nav.height, + ) + self.components.append(label) + + self.components.append(TextArea( + text=text, + font_name=GUIConstants.FIXED_WIDTH_FONT_NAME, + font_size=GUIConstants.BODY_FONT_SIZE, + screen_y=label.screen_y + label.height + GUIConstants.COMPONENT_PADDING, + )) + + + @dataclass class PSBTFinalizeScreen(ButtonListScreen): def __post_init__(self): diff --git a/src/seedsigner/gui/screens/screen.py b/src/seedsigner/gui/screens/screen.py index a4b3a742..a77997ab 100644 --- a/src/seedsigner/gui/screens/screen.py +++ b/src/seedsigner/gui/screens/screen.py @@ -670,7 +670,7 @@ class QRDisplayScreen(BaseScreen): self.tips_start_time = tips_start_time - def add_brightness_tips(self, image: Image.Image) -> None: + def render_brightness_tip(self, image: Image.Image) -> None: # TODO: Refactor ToastOverlay to support two lines of icon + text and use # that instead of this more manual approach. @@ -745,19 +745,29 @@ class QRDisplayScreen(BaseScreen): from seedsigner.models.settings import Settings settings = Settings.get_instance() cur_brightness_setting = settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS_TIPS) - show_brightness_tips = cur_brightness_setting == SettingsConstants.OPTION__ENABLED + is_brightness_tip_enabled = cur_brightness_setting == SettingsConstants.OPTION__ENABLED + pending_encoder_restart = False # Loop whether the QR is a single frame or animated; each loop might adjust # brightness setting. while self.keep_running: # convert the self.qr_brightness integer (31-255) into hex triplets hex_color = (hex(self.qr_brightness.cur_count).split('x')[1]) * 3 - image = self.qr_encoder.next_part_image(240, 240, border=2, background_color=hex_color) # Display the brightness tips toast duration = 10 ** 9 * 1.2 # 1.2 seconds - if show_brightness_tips and time.time_ns() - self.tips_start_time.cur_count < duration: - self.add_brightness_tips(image) + if is_brightness_tip_enabled and time.time_ns() - self.tips_start_time.cur_count < duration: + image = self.qr_encoder.part_to_image(self.qr_encoder.cur_part(), 240, 240, border=2, background_color=hex_color) + self.render_brightness_tip(image) + pending_encoder_restart = True + else: + # Only advance the QR animation when the brightness tip is not displayed + if pending_encoder_restart: + # Animated QRs should restart their frame sequence after the + # brightness tip is stowed. + self.qr_encoder.restart() + pending_encoder_restart = False + image = self.qr_encoder.next_part_image(240, 240, border=2, background_color=hex_color) with self.renderer.lock: self.renderer.show_image(image) diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index f657716d..c552a377 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -1512,6 +1512,8 @@ class SeedSignMessageConfirmMessageScreen(ButtonListScreen): end_y = renderer.canvas_height - GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT - GUIConstants.COMPONENT_PADDING message_height = end_y - start_y + # TODO: Pass the full message in from the View so that this Screen doesn't need to + # interact with the Controller here. self.sign_message_data = Controller.get_instance().sign_message_data if "paged_message" not in self.sign_message_data: paged = reflow_text_into_pages( diff --git a/src/seedsigner/models/encode_qr.py b/src/seedsigner/models/encode_qr.py index c368c05e..269a8f89 100644 --- a/src/seedsigner/models/encode_qr.py +++ b/src/seedsigner/models/encode_qr.py @@ -85,16 +85,15 @@ class BaseStaticQrEncoder(BaseQrEncoder): @dataclass class SeedQrEncoder(BaseStaticQrEncoder): - mnemonic: List[str] = None - wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH + seed : Seed = None def __post_init__(self): - self.wordlist = Seed.get_wordlist(self.wordlist_language_code) + self.wordlist = Seed.get_wordlist(self.seed.wordlist_language_code) super().__post_init__() self.data = "" # Output as Numeric data format - for word in self.mnemonic: + for word in self.seed.mnemonic_list: index = self.wordlist.index(word) self.data += str("%04d" % index) @@ -109,18 +108,18 @@ class CompactSeedQrEncoder(SeedQrEncoder): def next_part(self): # Output as binary data format binary_str = "" - for word in self.mnemonic: + for word in self.seed.mnemonic_list: index = self.wordlist.index(word) # Convert index to binary, strip out '0b' prefix; zero-pad to 11 bits binary_str += bin(index).split('b')[1].zfill(11) # We can exclude the checksum bits at the end - if len(self.mnemonic) == 24: + if len(self.seed.mnemonic_list) == 24: # 8 checksum bits in a 24-word seed binary_str = binary_str[:-8] - elif len(self.mnemonic) == 12: + elif len(self.seed.mnemonic_list) == 12: # 4 checksum bits in a 12-word seed binary_str = binary_str[:-4] @@ -149,22 +148,14 @@ class BaseXpubQrEncoder(BaseQrEncoder): """ Base Xpub QrEncoder for static and animated formats """ - mnemonic: list = None - passphrase: str = None + seed: Seed = None derivation: str = None network: str = SettingsConstants.MAINNET - wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH + sig_type : str = None def prep_xpub(self): - self.wordlist = Seed.get_wordlist(self.wordlist_language_code) - - if self.wordlist == None: - raise Exception('Wordlist Required') - version = bip32.detect_version(self.derivation, default="xpub", network=NETWORKS[SettingsConstants.map_network_to_embit(self.network)]) - self.seed = Seed(mnemonic=self.mnemonic, - passphrase=self.passphrase, - wordlist_language_code=self.wordlist_language_code) + version = self.seed.detect_version(self.derivation, self.network, self.sig_type) self.root = bip32.HDKey.from_seed(self.seed.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(self.network)]["xprv"]) self.fingerprint = self.root.child(0).fingerprint self.xprv = self.root.derive(self.derivation) diff --git a/src/seedsigner/models/psbt_parser.py b/src/seedsigner/models/psbt_parser.py index a20c13cc..8faba3fe 100644 --- a/src/seedsigner/models/psbt_parser.py +++ b/src/seedsigner/models/psbt_parser.py @@ -10,6 +10,11 @@ from seedsigner.models.seed import Seed from seedsigner.models.settings import SettingsConstants +class OPCODES: + OP_RETURN = 106 + OP_PUSHDATA1 = 76 + + class PSBTParser(): def __init__(self, p: PSBT, seed: Seed, network: str = SettingsConstants.MAINNET): @@ -26,6 +31,7 @@ class PSBTParser(): self.num_inputs = 0 self.destination_addresses = [] self.destination_amounts = [] + self.op_return_data: bytes = None self.root = None @@ -169,7 +175,11 @@ class PSBTParser(): if sc.data == self.psbt.tx.vout[i].script_pubkey.data: is_change = True - if is_change: + if self.psbt.tx.vout[i].script_pubkey.data[0] == OPCODES.OP_RETURN: + # The data is written as: OP_RETURN + OP_PUSHDATA1 + len(payload) + payload + self.op_return_data = self.psbt.tx.vout[i].script_pubkey.data[3:] + + elif is_change: addr = self.psbt.tx.vout[i].script_pubkey.address(NETWORKS[SettingsConstants.map_network_to_embit(self.network)]) fingerprints = [] derivation_paths = [] @@ -252,21 +262,22 @@ class PSBTParser(): # expected multisig script = None - if "p2wsh" in script_type and scope.witness_script is not None: - script = scope.witness_script + if script_type: + if "p2wsh" in script_type and scope.witness_script is not None: + script = scope.witness_script - elif "p2sh" in script_type and scope.redeem_script is not None: - script = scope.redeem_script + elif "p2sh" in script_type and scope.redeem_script is not None: + script = scope.redeem_script - if script is not None: - m, n, pubkeys = PSBTParser._parse_multisig(script) - - # check pubkeys are derived from cosigners - try: - cosigners = PSBTParser._get_cosigners(pubkeys, scope.bip32_derivations, xpubs) - policy.update({"m": m, "n": n, "cosigners": cosigners}) - except: - policy.update({"m": m, "n": n}) + if script is not None: + m, n, pubkeys = PSBTParser._parse_multisig(script) + + # check pubkeys are derived from cosigners + try: + cosigners = PSBTParser._get_cosigners(pubkeys, scope.bip32_derivations, xpubs) + policy.update({"m": m, "n": n, "cosigners": cosigners}) + except: + policy.update({"m": m, "n": n}) return policy diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index 51eb8555..6164ff84 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -1,4 +1,6 @@ import unicodedata +import hashlib +import hmac from binascii import hexlify from embit import bip39, bip32, bip85 @@ -18,7 +20,7 @@ class Seed: mnemonic: List[str] = None, passphrase: str = "", wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> None: - self.wordlist_language_code = wordlist_language_code + self._wordlist_language_code = wordlist_language_code if not mnemonic: raise Exception("Must initialize a Seed with a mnemonic List[str]") @@ -56,7 +58,11 @@ class Seed: @property def mnemonic_list(self) -> List[str]: return self._mnemonic - + + + @property + def wordlist_language_code(self) -> str: + return self._wordlist_language_code @property def mnemonic_display_str(self) -> str: @@ -100,6 +106,28 @@ class Seed: # TODO: Support other BIP-39 wordlist languages! raise Exception("Not yet implemented!") + @property + def script_override(self) -> list: + return None + + def derivation_override(self, wallet_type: str = SettingsConstants.SINGLE_SIG) -> str: + return None + + def detect_version(self, derivation_path: str, network: str = SettingsConstants.MAINNET, wallet_type: str = SettingsConstants.SINGLE_SIG) -> str: + embit_network = NETWORKS[SettingsConstants.map_network_to_embit(network)] + return bip32.detect_version(derivation_path, default="xpub", network=embit_network) + + @property + def passphrase_label(self) -> str: + return SettingsConstants.LABEL__BIP39_PASSPHRASE + + @property + def seedqr_supported(self) -> bool: + return True + + @property + def bip85_supported(self) -> bool: + return True def get_fingerprint(self, network: str = SettingsConstants.MAINNET) -> str: root = bip32.HDKey.from_seed(self.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(network)]["xprv"]) @@ -125,3 +153,65 @@ class Seed: if isinstance(other, Seed): return self.seed_bytes == other.seed_bytes return False + + + +class ElectrumSeed(Seed): + + + def _generate_seed(self) -> bool: + if len(self._mnemonic) != 12: + return False + s = hmac.digest(b"Seed version", self.mnemonic_str.encode('utf8'), hashlib.sha512).hex() + prefix = s[0:3] + # only support Electrum Segwit version for now + if SettingsConstants.ELECTRUM_SEED_SEGWIT == prefix: + self.seed_bytes=hashlib.pbkdf2_hmac('sha512', self.mnemonic_str.encode('utf-8'), b'electrum' + self._passphrase.encode('utf-8'), iterations = SettingsConstants.ELECTRUM_PBKDF2_ROUNDS) + return True + else: + raise InvalidSeedException("Unsupported electrum seed input") + return False + + def set_passphrase(self, passphrase: str, regenerate_seed: bool = True): + if passphrase: + self._passphrase = ElectrumSeed.normalize_electrum_passphrase(passphrase) + else: + # Passphrase must always have a string value, even if it's just the empty + # string. + self._passphrase = "" + + if regenerate_seed: + # Regenerate the internal seed since passphrase changes the result + self._generate_seed() + + @staticmethod + def normalize_electrum_passphrase(passphrase : str) -> str: + passphrase = unicodedata.normalize('NFKD', passphrase) + # lower + passphrase = passphrase.lower() + # normalize whitespaces + passphrase = u' '.join(passphrase.split()) + return passphrase + + @property + def script_override(self) -> list: + return [SettingsConstants.NATIVE_SEGWIT] + + def derivation_override(self, wallet_type: str = SettingsConstants.SINGLE_SIG) -> str: + return "m/0h" if SettingsConstants.SINGLE_SIG == wallet_type else "m/1h" + + def detect_version(self, derivation_path: str, network: str = SettingsConstants.MAINNET, wallet_type: str = SettingsConstants.SINGLE_SIG) -> str: + embit_network = NETWORKS[SettingsConstants.map_network_to_embit(network)] + return embit_network["zpub"] if SettingsConstants.SINGLE_SIG == wallet_type else embit_network["Zpub"] + + @property + def passphrase_label(self) -> str: + return SettingsConstants.LABEL__CUSTOM_EXTENSION + + @property + def seedqr_supported(self) -> bool: + return False + + @property + def bip85_supported(self) -> bool: + return False diff --git a/src/seedsigner/models/seed_storage.py b/src/seedsigner/models/seed_storage.py index 5b6c3049..85feb49c 100644 --- a/src/seedsigner/models/seed_storage.py +++ b/src/seedsigner/models/seed_storage.py @@ -1,5 +1,5 @@ from typing import List -from seedsigner.models.seed import Seed, InvalidSeedException +from seedsigner.models.seed import Seed, ElectrumSeed, InvalidSeedException from seedsigner.models.settings_definition import SettingsConstants @@ -9,6 +9,7 @@ class SeedStorage: self.seeds: List[Seed] = [] self.pending_seed: Seed = None self._pending_mnemonic: List[str] = [] + self._pending_is_electrum : bool = False def set_pending_seed(self, seed: Seed): @@ -58,8 +59,9 @@ class SeedStorage: return len(self._pending_mnemonic) - def init_pending_mnemonic(self, num_words:int = 12): + def init_pending_mnemonic(self, num_words:int = 12, is_electrum:bool = False): self._pending_mnemonic = [None] * num_words + self._pending_is_electrum = is_electrum def update_pending_mnemonic(self, word: str, index: int): @@ -81,16 +83,23 @@ class SeedStorage: def get_pending_mnemonic_fingerprint(self, network: str = SettingsConstants.MAINNET) -> str: try: - seed = Seed(self._pending_mnemonic) + if self._pending_is_electrum: + seed = ElectrumSeed(self._pending_mnemonic) + else: + seed = Seed(self._pending_mnemonic) return seed.get_fingerprint(network) except InvalidSeedException: return None def convert_pending_mnemonic_to_pending_seed(self): - self.pending_seed = Seed(self._pending_mnemonic) + if self._pending_is_electrum: + self.pending_seed = ElectrumSeed(self._pending_mnemonic) + else: + self.pending_seed = Seed(self._pending_mnemonic) self.discard_pending_mnemonic() def discard_pending_mnemonic(self): self._pending_mnemonic = [] + self._pending_is_electrum = False diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index c329d82e..8f33ee6b 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -160,6 +160,7 @@ class SettingsConstants: SETTING__CAMERA_ROTATION = "camera_rotation" SETTING__COMPACT_SEEDQR = "compact_seedqr" SETTING__BIP85_CHILD_SEEDS = "bip85_child_seeds" + SETTING__ELECTRUM_SEEDS = "electrum_seeds" SETTING__MESSAGE_SIGNING = "message_signing" SETTING__PRIVACY_WARNINGS = "privacy_warnings" SETTING__DIRE_WARNINGS = "dire_warnings" @@ -198,6 +199,15 @@ class SettingsConstants: TYPE__ENABLED_DISABLED_PROMPT_REQUIRED, ] + # Electrum seed constants + ELECTRUM_SEED_STANDARD = "01" + ELECTRUM_SEED_SEGWIT = "100" + ELECTRUM_SEED_2FA = "101" + ELECTRUM_PBKDF2_ROUNDS=2048 + + # Label strings + LABEL__BIP39_PASSPHRASE = "BIP-39 Passphrase" + LABEL__CUSTOM_EXTENSION = "Custom Extension" @dataclass class SettingsEntry: @@ -458,6 +468,13 @@ class SettingsDefinition: visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__DISABLED), + SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, + attr_name=SettingsConstants.SETTING__ELECTRUM_SEEDS, + abbreviated_name="Electrum", + display_name="Electrum seed support (Native Segwit only)", + visibility=SettingsConstants.VISIBILITY__ADVANCED, + default_value=SettingsConstants.OPTION__DISABLED), + SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__MESSAGE_SIGNING, display_name="Message signing", diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index 18e40312..b4019e33 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -7,7 +7,7 @@ from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconCo from seedsigner.models.encode_qr import UrPsbtQrEncoder from seedsigner.models.psbt_parser import PSBTParser from seedsigner.models.settings import SettingsConstants -from seedsigner.gui.screens.psbt_screens import PSBTOverviewScreen, PSBTMathScreen, PSBTAddressDetailsScreen, PSBTChangeDetailsScreen, PSBTFinalizeScreen +from seedsigner.gui.screens.psbt_screens import PSBTOpReturnScreen, PSBTOverviewScreen, PSBTMathScreen, PSBTAddressDetailsScreen, PSBTChangeDetailsScreen, PSBTFinalizeScreen from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, QRDisplayScreen) from seedsigner.views.view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination @@ -17,6 +17,7 @@ class PSBTSelectSeedView(View): SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE) TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_ELECTRUM = ("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD) def run(self): @@ -44,6 +45,8 @@ class PSBTSelectSeedView(View): button_data.append(self.SCAN_SEED) button_data.append(self.TYPE_12WORD) button_data.append(self.TYPE_24WORD) + if self.settings.get_value(SettingsConstants.SETTING__ELECTRUM_SEEDS) == SettingsConstants.OPTION__ENABLED: + button_data.append(self.TYPE_ELECTRUM) selected_menu_num = self.run_screen( ButtonListScreen, @@ -75,6 +78,18 @@ class PSBTSelectSeedView(View): self.controller.storage.init_pending_mnemonic(num_words=24) return Destination(SeedMnemonicEntryView) + elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: + self.run_screen( + WarningScreen, + title="Electrum warning", + status_headline=None, + text=f"Some features disabled for Electrum seeds", + show_back_button=False, + ) + from seedsigner.views.seed_views import SeedMnemonicEntryView + self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True) + return Destination(SeedMnemonicEntryView) + class PSBTOverviewView(View): @@ -137,7 +152,8 @@ class PSBTOverviewView(View): num_inputs=psbt_parser.num_inputs, num_self_transfer_outputs=num_self_transfer_outputs, num_change_outputs=num_change_outputs, - destination_addresses=psbt_parser.destination_addresses + destination_addresses=psbt_parser.destination_addresses, + has_op_return=psbt_parser.op_return_data is not None, ) if selected_menu_num == RET_CODE__BACK_BUTTON: @@ -276,12 +292,14 @@ class PSBTAddressDetailsView(View): # Move on to display change return Destination(PSBTChangeDetailsView, view_args={"change_address_num": 0}) + elif psbt_parser.op_return_data: + return Destination(PSBTOpReturnView) + else: # There's no change output to verify. Move on to sign the PSBT. return Destination(PSBTFinalizeView) - class PSBTChangeDetailsView(View): NEXT = "Next" SKIP_VERIFICATION = "Skip Verificiation" @@ -417,6 +435,10 @@ class PSBTChangeDetailsView(View): if self.change_address_num < psbt_parser.num_change_outputs - 1: return Destination(PSBTChangeDetailsView, view_args={"change_address_num": self.change_address_num + 1}) + + elif psbt_parser.op_return_data: + return Destination(PSBTOpReturnView) + else: # There's no more change to verify. Move on to sign the PSBT. return Destination(PSBTFinalizeView) @@ -456,6 +478,33 @@ class PSBTAddressVerificationFailedView(View): return Destination(MainMenuView, clear_history=True) +class PSBTOpReturnView(View): + """ + Shows the OP_RETURN data + """ + def run(self): + psbt_parser: PSBTParser = self.controller.psbt_parser + + if not psbt_parser: + # Should not be able to get here + raise Exception("Routing error") + + title = "OP_RETURN" + button_data = ["Next"] + + selected_menu_num = self.run_screen( + PSBTOpReturnScreen, + title=title, + button_data=button_data, + op_return_data=psbt_parser.op_return_data, + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + return Destination(PSBTFinalizeView) + + class PSBTFinalizeView(View): """ diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index a9bea1a8..c4fbc4ed 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -79,6 +79,7 @@ class SeedSelectSeedView(View): SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE) TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_ELECTRUM = ("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD) def __init__(self, flow: str = Controller.FLOW__VERIFY_SINGLESIG_ADDR): @@ -118,6 +119,8 @@ class SeedSelectSeedView(View): button_data.append(self.SCAN_SEED) button_data.append(self.TYPE_12WORD) button_data.append(self.TYPE_24WORD) + if self.settings.get_value(SettingsConstants.SETTING__ELECTRUM_SEEDS) == SettingsConstants.OPTION__ENABLED: + button_data.append(self.TYPE_ELECTRUM) selected_menu_num = self.run_screen( seed_screens.SeedSelectSeedScreen, @@ -154,6 +157,19 @@ class SeedSelectSeedView(View): self.controller.storage.init_pending_mnemonic(num_words=24) return Destination(SeedMnemonicEntryView) + elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: + self.run_screen( + WarningScreen, + title="Electrum warning", + status_headline=None, + text=f"Some features disabled for Electrum seeds", + show_back_button=False, + ) + + from seedsigner.views.seed_views import SeedMnemonicEntryView + self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True) + return Destination(SeedMnemonicEntryView) + """**************************************************************************** @@ -163,6 +179,7 @@ class LoadSeedView(View): SEED_QR = (" Scan a SeedQR", SeedSignerIconConstants.QRCODE) TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_ELECTRUM = ("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD) CREATE = (" Create a seed", SeedSignerIconConstants.PLUS) def run(self): @@ -172,6 +189,9 @@ class LoadSeedView(View): self.TYPE_24WORD, self.CREATE, ] + if self.settings.get_value(SettingsConstants.SETTING__ELECTRUM_SEEDS) == SettingsConstants.OPTION__ENABLED: + button_data.insert(len(button_data)-1, self.TYPE_ELECTRUM) + selected_menu_num = self.run_screen( ButtonListScreen, title="Load A Seed", @@ -194,6 +214,17 @@ class LoadSeedView(View): self.controller.storage.init_pending_mnemonic(num_words=24) return Destination(SeedMnemonicEntryView) + elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: + self.run_screen( + WarningScreen, + title="Electrum warning", + status_headline=None, + text=f"Some features disabled for Electrum seeds", + show_back_button=False, + ) + self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True) + return Destination(SeedMnemonicEntryView) + elif button_data[selected_menu_num] == self.CREATE: from .tools_views import ToolsMenuView return Destination(ToolsMenuView) @@ -288,7 +319,6 @@ class SeedMnemonicInvalidView(View): class SeedFinalizeView(View): FINALIZE = "Done" - PASSPHRASE = "BIP-39 Passphrase" def __init__(self): super().__init__() @@ -298,8 +328,9 @@ class SeedFinalizeView(View): def run(self): button_data = [self.FINALIZE] + passphrase_button = self.seed.passphrase_label if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) != SettingsConstants.OPTION__DISABLED: - button_data.append(self.PASSPHRASE) + button_data.append(passphrase_button) selected_menu_num = self.run_screen( seed_screens.SeedFinalizeScreen, @@ -311,7 +342,7 @@ class SeedFinalizeView(View): seed_num = self.controller.storage.finalize_pending_seed() return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True) - elif button_data[selected_menu_num] == self.PASSPHRASE: + elif button_data[selected_menu_num] == passphrase_button: return Destination(SeedAddPassphraseView) @@ -323,7 +354,8 @@ class SeedAddPassphraseView(View): def run(self): - ret_dict = self.run_screen(seed_screens.SeedAddPassphraseScreen, passphrase=self.seed.passphrase) + passphrase_title=self.seed.passphrase_label + ret_dict = self.run_screen(seed_screens.SeedAddPassphraseScreen, passphrase=self.seed.passphrase, title=passphrase_title) # The new passphrase will be the return value; it might be empty. self.seed.set_passphrase(ret_dict["passphrase"]) @@ -522,7 +554,7 @@ class SeedOptionsView(View): 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: + if self.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__ENABLED and self.seed.bip85_supported: button_data.append(self.BIP85_CHILD_SEED) button_data.append(self.DISCARD) @@ -581,7 +613,9 @@ class SeedBackupView(View): def run(self): - button_data = [self.VIEW_WORDS, self.EXPORT_SEEDQR] + button_data = [self.VIEW_WORDS] + if self.seed.seedqr_supported: + button_data.append(self.EXPORT_SEEDQR) selected_menu_num = self.run_screen( ButtonListScreen, @@ -647,9 +681,11 @@ class SeedExportXpubScriptTypeView(View): def run(self): from .tools_views import ToolsAddressExplorerAddressTypeView args = {"seed_num": self.seed_num, "sig_type": self.sig_type} - if len(self.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES)) == 1: + seed = self.controller.storage.seeds[self.seed_num] + script_types = seed.script_override if seed.script_override else self.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES) + if len(script_types) == 1: # Nothing to select; skip this screen - args["script_type"] = self.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES)[0] + args["script_type"] = script_types[0] if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: del args["sig_type"] @@ -833,8 +869,11 @@ class SeedExportXpubDetailsView(View): def run(self): + seed_derivation_override = self.seed.derivation_override(self.sig_type) if self.script_type == SettingsConstants.CUSTOM_DERIVATION: derivation_path = self.custom_derivation + elif seed_derivation_override: + derivation_path = seed_derivation_override else: derivation_path = embit_utils.get_standard_derivation_path( network=self.settings.get_value(SettingsConstants.SETTING__NETWORK), @@ -854,10 +893,10 @@ class SeedExportXpubDetailsView(View): try: embit_network = NETWORKS[SettingsConstants.map_network_to_embit(self.settings.get_value(SettingsConstants.SETTING__NETWORK))] - version = embit.bip32.detect_version( + version = self.seed.detect_version( derivation_path, - default="xpub", - network=embit_network + self.settings.get_value(SettingsConstants.SETTING__NETWORK), + self.sig_type ) root = embit.bip32.HDKey.from_seed( self.seed.seed_bytes, @@ -885,6 +924,7 @@ class SeedExportXpubDetailsView(View): dict(seed_num=self.seed_num, coordinator=self.coordinator, derivation_path=derivation_path, + sig_type=self.sig_type ) ) @@ -894,16 +934,16 @@ class SeedExportXpubDetailsView(View): class SeedExportXpubQRDisplayView(View): - def __init__(self, seed_num: int, coordinator: str, derivation_path: str): + def __init__(self, seed_num: int, coordinator: str, derivation_path: str, sig_type: str = SettingsConstants.SINGLE_SIG): super().__init__() self.seed = self.controller.get_seed(seed_num) encoder_args = dict( - mnemonic=self.seed.mnemonic_list, - passphrase=self.seed.passphrase, + seed=self.seed, derivation=derivation_path, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK), - qr_density=self.settings.get_value(SettingsConstants.SETTING__QR_DENSITY) + qr_density=self.settings.get_value(SettingsConstants.SETTING__QR_DENSITY), + sig_type=sig_type ) if coordinator == SettingsConstants.COORDINATOR__SPECTER_DESKTOP: @@ -1416,8 +1456,7 @@ class SeedTranscribeSeedQRWholeQRView(View): def run(self): - encoder_args = dict(mnemonic=self.seed.mnemonic_list, - wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)) + encoder_args = dict(seed=self.seed) if self.seedqr_format == QRType.SEED__SEEDQR: e = SeedQrEncoder(**encoder_args) elif self.seedqr_format == QRType.SEED__COMPACTSEEDQR: @@ -1453,8 +1492,7 @@ class SeedTranscribeSeedQRZoomedInView(View): def run(self): - encoder_args = dict(mnemonic=self.seed.mnemonic_list, - wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)) + encoder_args = dict(seed=self.seed) if self.seedqr_format == QRType.SEED__SEEDQR: e = SeedQrEncoder(**encoder_args) elif self.seedqr_format == QRType.SEED__COMPACTSEEDQR: @@ -1676,15 +1714,17 @@ class SeedAddressVerificationView(View): super().__init__() self.seed_num = seed_num self.is_multisig = self.controller.unverified_address["sig_type"] == SettingsConstants.MULTISIG + self.seed_derivation_override = "" if not self.is_multisig: if seed_num is None: raise Exception("Can't validate a single sig addr without specifying a seed") self.seed_num = seed_num self.seed = self.controller.get_seed(seed_num) + self.seed_derivation_override = self.seed.derivation_override(wallet_type=SettingsConstants.SINGLE_SIG) else: self.seed = None self.address = self.controller.unverified_address["address"] - self.derivation_path = self.controller.unverified_address["derivation_path"] + self.derivation_path = self.seed_derivation_override if self.seed_derivation_override else self.controller.unverified_address["derivation_path"] self.script_type = self.controller.unverified_address["script_type"] self.sig_type = self.controller.unverified_address["sig_type"] self.network = self.controller.unverified_address["network"] diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index 02c34f23..ad2940be 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -9,7 +9,7 @@ from PIL.ImageOps import autocontrast from seedsigner.controller import Controller from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants, SeedSignerIconConstants -from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen) +from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen) from seedsigner.gui.screens.tools_screens import (ToolsCalcFinalWordDoneScreen, ToolsCalcFinalWordFinalizePromptScreen, ToolsCalcFinalWordScreen, ToolsCoinFlipEntryScreen, ToolsDiceEntropyEntryScreen, ToolsImageEntropyFinalImageScreen, ToolsImageEntropyLivePreviewScreen, ToolsAddressExplorerAddressTypeScreen) @@ -440,6 +440,7 @@ class ToolsAddressExplorerSelectSourceView(View): SCAN_DESCRIPTOR = ("Scan wallet descriptor", SeedSignerIconConstants.QRCODE) TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_ELECTRUM = ("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD) def run(self): @@ -449,6 +450,8 @@ class ToolsAddressExplorerSelectSourceView(View): button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT)) button_data = button_data + [self.SCAN_SEED, self.SCAN_DESCRIPTOR, self.TYPE_12WORD, self.TYPE_24WORD] + if self.settings.get_value(SettingsConstants.SETTING__ELECTRUM_SEEDS) == SettingsConstants.OPTION__ENABLED: + button_data.append(self.TYPE_ELECTRUM) selected_menu_num = self.run_screen( ButtonListScreen, @@ -492,6 +495,18 @@ class ToolsAddressExplorerSelectSourceView(View): self.controller.storage.init_pending_mnemonic(num_words=24) return Destination(SeedMnemonicEntryView) + elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: + self.run_screen( + WarningScreen, + title="Electrum warning", + status_headline=None, + text=f"Some features disabled for Electrum seeds", + show_back_button=False, + ) + from seedsigner.views.seed_views import SeedMnemonicEntryView + self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True) + return Destination(SeedMnemonicEntryView) + class ToolsAddressExplorerAddressTypeView(View): @@ -525,9 +540,12 @@ class ToolsAddressExplorerAddressTypeView(View): if self.seed_num is not None: self.seed = self.controller.storage.seeds[seed_num] data["seed_num"] = self.seed + seed_derivation_override = self.seed.derivation_override(wallet_type=SettingsConstants.SINGLE_SIG) if self.script_type == SettingsConstants.CUSTOM_DERIVATION: derivation_path = self.custom_derivation + elif seed_derivation_override: + derivation_path = seed_derivation_override else: derivation_path = embit_utils.get_standard_derivation_path( network=self.settings.get_value(SettingsConstants.SETTING__NETWORK), diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index a5c4800c..a7c83d49 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Type from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants @@ -313,7 +313,7 @@ class ErrorView(View): status_headline: str = None text: str = None button_text: str = None - next_destination: Destination = Destination(MainMenuView, clear_history=True) + next_destination: Destination = field(default_factory=lambda: Destination(MainMenuView, clear_history=True)) def run(self): diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 6b7effe1..810e5d6f 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -1,11 +1,17 @@ import embit import os +import random import sys import time from unittest.mock import Mock, patch, MagicMock from seedsigner.helpers import embit_utils -from seedsigner.models.settings import Settings +from embit import compact +from embit.psbt import PSBT, OutputScope +from embit.script import Script + +from seedsigner.helpers import embit_utils +from seedsigner.models.psbt_parser import OPCODES, PSBTParser # Prevent importing modules w/Raspi hardware dependencies. @@ -22,8 +28,6 @@ sys.modules['seedsigner.hardware.microsd'] = MagicMock() from seedsigner.controller import Controller from seedsigner.gui.renderer import Renderer from seedsigner.gui.toast import BaseToastOverlayManagerThread, RemoveSDCardToastManagerThread, SDCardStateChangeToastManagerThread -from seedsigner.hardware.buttons import HardwareButtons -from seedsigner.hardware.camera import Camera from seedsigner.hardware.microsd import MicroSD from seedsigner.models.decode_qr import DecodeQR from seedsigner.models.qr_type import QRType @@ -86,6 +90,34 @@ def test_generate_screenshots(target_locale): controller.psbt = decoder.get_psbt() controller.psbt_seed = seed_12b + def add_op_return_to_psbt(psbt: PSBT, raw_payload_data: bytes): + data = (compact.to_bytes(OPCODES.OP_RETURN) + + compact.to_bytes(OPCODES.OP_PUSHDATA1) + + compact.to_bytes(len(raw_payload_data)) + + raw_payload_data) + script = Script(data) + output = OutputScope() + output.script_pubkey = script + output.value = 0 + psbt.outputs.append(output) + return psbt.to_string() + + # Prep a PSBT with a human-readable OP_RETURN + raw_payload_data = "Chancellor on the brink of third bailout for banks".encode() + psbt = PSBT.from_base64(BASE64_PSBT_1) + + # Simplify the output side + output = psbt.outputs[-1] + psbt.outputs.clear() + psbt.outputs.append(output) + assert len(psbt.outputs) == 1 + BASE64_PSBT_WITH_OP_RETURN_TEXT = add_op_return_to_psbt(psbt, raw_payload_data) + + # Prep a PSBT with a (repeatably) random 80-byte OP_RETURN + random.seed(6102) + BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES = add_op_return_to_psbt(PSBT.from_base64(BASE64_PSBT_1), random.randbytes(80)) + + # 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) @@ -194,6 +226,11 @@ def test_generate_screenshots(target_locale): (NotYetImplementedView, {}, "PSBTChangeDetailsView_multisig_unverified"), # Must manually re-run this below (psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), "PSBTChangeDetailsView_multisig_verified"), + + (NotYetImplementedView, {}, "PSBTOverviewView_op_return"), # Placeholder + (NotYetImplementedView, {}, "PSBTOpReturnView_text"), # Placeholder + (NotYetImplementedView, {}, "PSBTOpReturnView_raw_hex_data"), # Placeholder + (psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=False), "PSBTAddressVerificationFailedView_singlesig_change"), (psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=False), "PSBTAddressVerificationFailedView_singlesig_selftransfer"), (psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=True), "PSBTAddressVerificationFailedView_multisig_change"), @@ -242,7 +279,9 @@ def test_generate_screenshots(target_locale): readme = f"""# SeedSigner Screenshots\n""" - def screencap_view(view_cls: View, view_name: str, view_args: dict={}, toast_thread: BaseToastOverlayManagerThread = None): + def screencap_view(view_cls: View, view_args: dict = {}, view_name: str = None, toast_thread: BaseToastOverlayManagerThread = None): + if not view_name: + view_name = view_cls.__name__ screenshot_renderer.set_screenshot_filename(f"{view_name}.png") try: print(f"Running {view_name}") @@ -267,7 +306,6 @@ def test_generate_screenshots(target_locale): if toast_thread: toast_thread.stop() - 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)) @@ -290,25 +328,41 @@ def test_generate_screenshots(target_locale): view_name = view_cls.__name__ toast_thread = None - screencap_view(view_cls, view_name, view_args, toast_thread=toast_thread) + screencap_view(view_cls, view_args=view_args, view_name=view_name, toast_thread=toast_thread) readme += """ """ readme += f"""""" readme += """
{view_name}

\n""" readme += "" - # many screens don't work, leaving a missing image, re-run here for now + # Re-render some screens that require more manual intervention / setup than the above + # scripting can support. screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, "psbt_views")) + # Render the PSBTChangeDetailsView_multisig_unverified screenshot decoder = DecodeQR() decoder.add_data(BASE64_PSBT_1) controller.psbt = decoder.get_psbt() controller.psbt_seed = seed_12b controller.multisig_wallet_descriptor = None - screencap_view(psbt_views.PSBTChangeDetailsView, 'PSBTChangeDetailsView_multisig_unverified', dict(change_address_num=0)) + screencap_view(psbt_views.PSBTChangeDetailsView, view_name='PSBTChangeDetailsView_multisig_unverified', view_args=dict(change_address_num=0)) controller.psbt_seed = None - screencap_view(psbt_views.PSBTSelectSeedView, 'PSBTSelectSeedView', {}) + screencap_view(psbt_views.PSBTSelectSeedView, view_name='PSBTSelectSeedView') + + # Render OP_RETURN screens for real + controller.psbt_seed = seed_12b + decoder = DecodeQR() + decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_TEXT) + controller.psbt = decoder.get_psbt() + controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b) + screencap_view(psbt_views.PSBTOverviewView, view_name='PSBTOverviewView_op_return') + screencap_view(psbt_views.PSBTOpReturnView, view_name="PSBTOpReturnView_text") + + decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES) + controller.psbt = decoder.get_psbt() + controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b) + screencap_view(psbt_views.PSBTOpReturnView, view_name="PSBTOpReturnView_raw_hex_data") with open(os.path.join(screenshot_root, "README.md"), 'w') as readme_file: readme_file.write(readme) diff --git a/tests/test_encodepsbtqr.py b/tests/test_encodepsbtqr.py index 8a8ec368..34f28631 100644 --- a/tests/test_encodepsbtqr.py +++ b/tests/test_encodepsbtqr.py @@ -3,6 +3,7 @@ from embit import psbt from binascii import a2b_base64 from seedsigner.models.settings import SettingsConstants +from seedsigner.models.seed import Seed @@ -24,18 +25,19 @@ def test_ur_psbt_qr_encode(): def test_seedsigner_qr(): # test vector 1 from the SeedQR docs mnemonic = "attack pizza motion avocado network gather crop fresh patrol unusual wild holiday candy pony ranch winter theme error hybrid van cereal salon goddess expire".split() - e = SeedQrEncoder(mnemonic=mnemonic) + e = SeedQrEncoder(seed=Seed(mnemonic)) assert e.next_part() == "011513251154012711900771041507421289190620080870026613431420201617920614089619290300152408010643" - e = CompactSeedQrEncoder(mnemonic=mnemonic) + e = CompactSeedQrEncoder(seed=Seed(mnemonic)) assert e.next_part() == b'\x0et\xb6A\x07\xf9L\xc0\xcc\xfa\xe6\xa1=\xcb\xec6b\x15O\xecg\xe0\xe0\t\x99\xc0x\x92Y}\x19\n' # test vector 4 from the SeedQR docs mnemonic="forum undo fragile fade shy sign arrest garment culture tube off merit".split() - e = SeedQrEncoder(mnemonic=mnemonic) + seed = Seed(mnemonic) + e = SeedQrEncoder(seed=seed) assert e.next_part() == "073318950739065415961602009907670428187212261116" - e = CompactSeedQrEncoder(mnemonic=mnemonic) + e = CompactSeedQrEncoder(seed=seed) assert e.next_part() == b'[\xbd\x9dq\xa8\xecy\x90\x83\x1a\xff5\x9dBeE' @@ -43,8 +45,7 @@ def test_seedsigner_qr(): def test_xpub_qr(): mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash" - e = StaticXpubQrEncoder(mnemonic=mnemonic.split(), - passphrase="pass", + e = StaticXpubQrEncoder(seed=Seed(mnemonic.split(), passphrase="pass"), derivation="m/48h/1h/0h/2h", network=SettingsConstants.TESTNET) assert e.next_part() == "[c49122a5/48h/1h/0h/2h]Vpub5mXgECaX5yYDNc5VnUG4jVNptyEg65qUjuofWchQeuMWWiq8rcPBoMxfrVggXj5NJmaNEToWpax8GMMucozvAdqf1bW1JsZsfdBzsK3VUC5" @@ -54,7 +55,7 @@ def test_xpub_qr(): def test_specter_xpub_qr(): mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash" - e = SpecterXPubQrEncoder(mnemonic=mnemonic.split(" "), passphrase="pass", network=SettingsConstants.TESTNET, derivation="m/48h/1h/0h/2h", qr_density=SettingsConstants.DENSITY__LOW) + e = SpecterXPubQrEncoder(seed=Seed(mnemonic.split(" "), passphrase="pass"), network=SettingsConstants.TESTNET, derivation="m/48h/1h/0h/2h", qr_density=SettingsConstants.DENSITY__LOW) assert e.next_part() == "p1of4 [c49122a5/48h/1h/0h/2h]Vpub5mXgECaX5yYDN" assert e.next_part() == "p2of4 c5VnUG4jVNptyEg65qUjuofWchQeuMWWiq8rcPBo" @@ -67,8 +68,7 @@ def test_ur_xpub_qr(): mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash" e = UrXpubQrEncoder( - mnemonic=mnemonic.split(), - passphrase="pass", + seed=Seed(mnemonic.split(), passphrase="pass"), network=SettingsConstants.TESTNET, derivation="m/48h/1h/0h/2h", qr_density=SettingsConstants.DENSITY__MEDIUM diff --git a/tests/test_flows_psbt.py b/tests/test_flows_psbt.py index 67c5149e..c328fe24 100644 --- a/tests/test_flows_psbt.py +++ b/tests/test_flows_psbt.py @@ -2,7 +2,10 @@ from base import FlowTest, FlowStep from seedsigner.views.view import MainMenuView from seedsigner.views import scan_views, seed_views, psbt_views +from seedsigner.models.settings import SettingsConstants + +# TODO: Cleanup: convert TAB spacing to SPACE class TestPSBTFlows(FlowTest): def test_scan_psbt_first_then_correct_seedqr_flow(self): @@ -53,6 +56,7 @@ class TestPSBTFlows(FlowTest): FlowStep(psbt_views.PSBTSignedQRDisplayView), FlowStep(MainMenuView) ]) + def test_scan_multisig_psbt_seed_already_signed_flow(self): @@ -77,7 +81,7 @@ class TestPSBTFlows(FlowTest): FlowStep(psbt_views.PSBTSigningErrorView, button_data_selection=psbt_views.PSBTSigningErrorView.SELECT_DIFF_SEED), FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED), FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder), - FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE), + FlowStep(seed_views.SeedFinalizeView, button_data_selection=SettingsConstants.LABEL__BIP39_PASSPHRASE), FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="abc")), FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE), FlowStep(seed_views.SeedOptionsView, is_redirect=True), @@ -89,4 +93,46 @@ class TestPSBTFlows(FlowTest): FlowStep(psbt_views.PSBTSignedQRDisplayView), FlowStep(MainMenuView), ]) - + + + def test_parse_and_display_op_return_content(self): + """ + PSBTs that include an OP_RETURN should be able to be parsed like any other + PSBT and route to the dedicated OP_RETURN View to display the content + """ + def load_psbt_into_decoder(view: scan_views.ScanView): + """ + PSBT Tx and Wallet Details + - Single Sig Wallet P2WPKH (Native Segwit) with no passphrase + - Regtest 0fb882ff m/84'/1'/0' tpubDCfk37PqcQx6nFtFVuYHvRLJHxvYj33NjHkKRyRmWyCjyJ64sYBXyVjsTHaLBp5GLhM91VBgJ8nKDWDu52J2xVRy64c7ybEjjyWQJuQGLcg + - 1 Input + - 99,992,460 sats + - 2 Outputs + - 1 Output back to self (bcrt1qvwkhakqhz7m7kmz6332avatsmdy32m644g86vv) of 99,992,296 sats + - 1 OP_RETURN: "Chancellor on the brink of third bailout" + - Fee 164 sats + """ + view.decoder.add_data("cHNidP8BAIYCAAAAATpQ10o+gKdZ8ThpKsbfHiHYn3NhvUrQ5DvW0ZWX8jKLAAAAAAD9////AujC9QUAAAAAFgAUY61+2BcXt+tsWoxV1nVw20kVb1UAAAAAAAAAACtqTChDaGFuY2VsbG9yIG9uIHRoZSBicmluayBvZiB0aGlyZCBiYWlsb3V0aQAAAE8BBDWHzwNXmUmVgAAAANRFa7R5gYD84Wbha3d1QnjgfYPOBw87on6cXS32WoyqAsPFtPxB7PRTdbujUnBPUVDh9YUBtwrl4nc0OcRNGvIyEA+4gv9UAACAAQAAgAAAAIAAAQB0AgAAAAGNFK/1X0fP5q+nu5XX7Tk2VRa0EL+jkGI9CHiJvsjZCgAAAAAA/f///wKMw/UFAAAAABYAFIpZMNnUU6cQt8Q0YpZ0pnvsSA5fAAAAAAAAAAAZakwWYml0Y29pbiBpcyBmcmVlIHNwZWVjaGgAAAABAR+Mw/UFAAAAABYAFIpZMNnUU6cQt8Q0YpZ0pnvsSA5fAQMEAQAAACIGAvxDI0eNI1oQ2AU69R7A0jf+hUdilWCgrWHgdzkqlaXMGA+4gv9UAACAAQAAgAAAAIAAAAAAAQAAAAAiAgK9qKtzGWyiRrpmupdA99NVLriz3GQy6cENbyD19sfl/hgPuIL/VAAAgAEAAIAAAACAAAAAAAIAAAAAAA==") + + def load_seed_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("114006021552133507590698063102151531110102551496") + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, before_run=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored + FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED), + FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder), + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, is_redirect=True), + FlowStep(psbt_views.PSBTOverviewView), + FlowStep(psbt_views.PSBTMathView), + FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), + + # Should route to display OP_RETURN content + FlowStep(psbt_views.PSBTOpReturnView, button_data_selection=0), + + # Should be able to sign the psbt + FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT), + FlowStep(psbt_views.PSBTSignedQRDisplayView), + FlowStep(MainMenuView) + ]) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 4210543d..63230319 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -40,10 +40,10 @@ class TestSeedFlows(FlowTest): 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.PASSPHRASE), + FlowStep(seed_views.SeedFinalizeView, button_data_selection=SettingsConstants.LABEL__BIP39_PASSPHRASE), FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="muhpassphrase", is_back_button=True)), FlowStep(seed_views.SeedAddPassphraseExitDialogView, button_data_selection=seed_views.SeedAddPassphraseExitDialogView.DISCARD), - FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE), + FlowStep(seed_views.SeedFinalizeView, button_data_selection=SettingsConstants.LABEL__BIP39_PASSPHRASE), FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="muhpassphrase", is_back_button=True)), FlowStep(seed_views.SeedAddPassphraseExitDialogView, button_data_selection=seed_views.SeedAddPassphraseExitDialogView.EDIT), FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="muhpassphrase")), diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index 20ed8238..38d8325c 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -64,7 +64,7 @@ class TestToolsFlows(FlowTest): # Finalize the new seed w/passphrase self.run_sequence( sequence=[ - FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE), + FlowStep(seed_views.SeedFinalizeView, button_data_selection=SettingsConstants.LABEL__BIP39_PASSPHRASE), FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="mypassphrase")), FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE), FlowStep(seed_views.SeedOptionsView, is_redirect=True), diff --git a/tests/test_psbt_parser.py b/tests/test_psbt_parser.py index cf6f8db2..99b20c23 100644 --- a/tests/test_psbt_parser.py +++ b/tests/test_psbt_parser.py @@ -112,3 +112,46 @@ def test_p2sh_legacy_multisig(): # And the self-transfer receive addr assert psbt_parser.verify_multisig_output(descriptor, 1) + + +def test_parse_op_return_content(): + """ + Should successfully parse the OP_RETURN content from a PSBT. + + PSBT Tx and Wallet Details + - Single Sig Wallet P2WPKH (Native Segwit) with no passphrase + - Regtest 0fb882ff m/84'/1'/0' tpubDCfk37PqcQx6nFtFVuYHvRLJHxvYj33NjHkKRyRmWyCjyJ64sYBXyVjsTHaLBp5GLhM91VBgJ8nKDWDu52J2xVRy64c7ybEjjyWQJuQGLcg + - 1 Input + - 99,992,460 sats + - 2 Outputs + - 1 Output back to self (bcrt1qvwkhakqhz7m7kmz6332avatsmdy32m644g86vv) of 99,992,296 sats + - 1 OP_RETURN: "Chancellor on the brink of third bailout" + - Fee 164 sats + """ + psbt_base64 = "cHNidP8BAIYCAAAAATpQ10o+gKdZ8ThpKsbfHiHYn3NhvUrQ5DvW0ZWX8jKLAAAAAAD9////AujC9QUAAAAAFgAUY61+2BcXt+tsWoxV1nVw20kVb1UAAAAAAAAAACtqTChDaGFuY2VsbG9yIG9uIHRoZSBicmluayBvZiB0aGlyZCBiYWlsb3V0aQAAAE8BBDWHzwNXmUmVgAAAANRFa7R5gYD84Wbha3d1QnjgfYPOBw87on6cXS32WoyqAsPFtPxB7PRTdbujUnBPUVDh9YUBtwrl4nc0OcRNGvIyEA+4gv9UAACAAQAAgAAAAIAAAQB0AgAAAAGNFK/1X0fP5q+nu5XX7Tk2VRa0EL+jkGI9CHiJvsjZCgAAAAAA/f///wKMw/UFAAAAABYAFIpZMNnUU6cQt8Q0YpZ0pnvsSA5fAAAAAAAAAAAZakwWYml0Y29pbiBpcyBmcmVlIHNwZWVjaGgAAAABAR+Mw/UFAAAAABYAFIpZMNnUU6cQt8Q0YpZ0pnvsSA5fAQMEAQAAACIGAvxDI0eNI1oQ2AU69R7A0jf+hUdilWCgrWHgdzkqlaXMGA+4gv9UAACAAQAAgAAAAIAAAAAAAQAAAAAiAgK9qKtzGWyiRrpmupdA99NVLriz3GQy6cENbyD19sfl/hgPuIL/VAAAgAEAAIAAAACAAAAAAAIAAAAAAA==" + + raw = a2b_base64(psbt_base64) + tx = psbt.PSBT.parse(raw) + + mnemonic = "model ensure search plunge galaxy firm exclude brain satoshi meadow cable roast".split() + pw = "" + seed = Seed(mnemonic, passphrase=pw) + + psbt_parser = PSBTParser(p=tx, seed=seed, network=SettingsConstants.REGTEST) + + # Remember to do the comparison as bytes + assert psbt_parser.op_return_data == "Chancellor on the brink of third bailout".encode() + + # PSBT is an internal self-spend to the its own receive addr, but the parser categorizes it as "change" + assert psbt_parser.change_data == [ + { + 'output_index': 0, + 'address': 'bcrt1qvwkhakqhz7m7kmz6332avatsmdy32m644g86vv', + 'amount': 99992296, + 'fingerprint': ['0fb882ff'], + 'derivation_path': ["m/84h/1h/0h/0/2"]} + ] + assert psbt_parser.spend_amount == 0 # This is a self-spend; no value being spent, other than the tx fee + assert psbt_parser.change_amount == 99992296 + assert psbt_parser.destination_addresses == [] + assert psbt_parser.destination_amounts == [] diff --git a/tests/test_seed.py b/tests/test_seed.py index a7e082ee..04291f65 100644 --- a/tests/test_seed.py +++ b/tests/test_seed.py @@ -1,4 +1,4 @@ -from seedsigner.models.seed import Seed +from seedsigner.models.seed import Seed, ElectrumSeed from seedsigner.models.settings import SettingsConstants @@ -36,6 +36,10 @@ def test_seed(): # assert seed.passphrase == "test" - - - \ No newline at end of file +def test_electrum_seed(): + seed = ElectrumSeed(mnemonic="regular reject rare profit once math fringe chase until ketchup century escape".split()) + + intended_seed = b'\xcan|\xf8\x8a\x8d\xf78=Pq\xc4_\xe6\x02\x91\xfcs\xb2[\xed*\xdc\xc7%\xb6[_-(~D\xe5\x1e\x85%N\x9c\x03\x9dh\xafX}\x16\xb1\x99,\xbe\xc4\x11\xfaW\x0f\xb0\x89yD\xf4\x0f\xd5?\x8eA' + + assert seed.seed_bytes == intended_seed + diff --git a/tests/test_seedqr.py b/tests/test_seedqr.py index 826cd941..2202af0d 100644 --- a/tests/test_seedqr.py +++ b/tests/test_seedqr.py @@ -4,6 +4,7 @@ from seedsigner.helpers.qr import QR from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus from seedsigner.models.encode_qr import SeedQrEncoder, CompactSeedQrEncoder from seedsigner.models.qr_type import QRType +from seedsigner.models.seed import Seed @@ -13,9 +14,9 @@ def run_encode_decode_test(entropy: bytes, mnemonic_length, qr_type): assert len(mnemonic) == mnemonic_length if qr_type == QRType.SEED__SEEDQR: - e = SeedQrEncoder(mnemonic=mnemonic) + e = SeedQrEncoder(seed=Seed(mnemonic)) elif qr_type == QRType.SEED__COMPACTSEEDQR: - e = CompactSeedQrEncoder(mnemonic=mnemonic) + e = CompactSeedQrEncoder(seed=Seed(mnemonic)) data = e.next_part() print(data)